imeeting/frontend/src/pages/business/MeetingPointsManagement.tsx

471 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { listUsers } from "@/api";
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
InputNumber,
message,
Modal,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from "antd";
import { useEffect, useMemo, useState } from "react";
import PageContainer from "@/components/shared/PageContainer";
import ListTable from "@/components/shared/ListTable/ListTable";
import AppPagination from "@/components/shared/AppPagination";
import {
getMeetingPointsLedgerDetail,
getMeetingPointsLedgerPage,
getMeetingPointsOverview,
transferMeetingPoints,
type MeetingPointsChargeItemVO,
type MeetingPointsLedgerDetailVO,
type MeetingPointsLedgerListItemVO,
type MeetingPointsOverviewVO,
} from "@/api/business/meetingPoints";
import type { SysUser } from "@/types";
const { Text } = Typography;
const POINTS_TYPE_OPTIONS = [
{ label: "全部类型", value: "" },
{ label: "转录", value: "ASR" },
{ label: "总结", value: "LLM" },
];
function getAccountModeLabel(mode?: string) {
if (mode === "PERSONAL") return "个人账户";
if (mode === "BOTH") return "公共和个人共存";
return "公共账户";
}
function getChargePriorityLabel(priority?: string) {
return priority === "PUBLIC_FIRST" ? "公共优先" : "个人优先";
}
function getAccountTypeLabel(type?: string) {
return type === "PERSONAL" ? "个人账户" : "公共账户";
}
function getPointsTypeLabel(value?: string) {
if (value === "ASR") return "转录";
if (value === "LLM") return "总结";
if (value === "TRANSFER_OUT") return "转出";
if (value === "TRANSFER_IN") return "转入";
if (value === "INIT") return "初始化";
return value || "-";
}
function getPointsTypeColor(value?: string) {
if (value === "ASR") return "blue";
if (value === "LLM") return "purple";
if (value === "TRANSFER_IN") return "green";
if (value === "TRANSFER_OUT") return "orange";
return "default";
}
function getChargeTriggerLabel(value?: string) {
if (value === "RESUMMARY") return "重新总结";
if (value === "AUTO_SUMMARY") return "自动总结";
return "-";
}
function formatDateTime(value?: string) {
return value ? value.replace("T", " ").substring(0, 19) : "-";
}
export default function MeetingPointsManagement() {
const [overview, setOverview] = useState<MeetingPointsOverviewVO | null>(null);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [transferLoading, setTransferLoading] = useState(false);
const [records, setRecords] = useState<MeetingPointsLedgerListItemVO[]>([]);
const [total, setTotal] = useState(0);
const [detailOpen, setDetailOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [detail, setDetail] = useState<MeetingPointsLedgerDetailVO | null>(null);
const [users, setUsers] = useState<SysUser[]>([]);
const [params, setParams] = useState({
current: 1,
size: 20,
username: "",
pointsType: "",
});
const [transferForm] = Form.useForm();
const loadOverview = async () => {
const data = await getMeetingPointsOverview();
setOverview(data);
};
const loadUsers = async () => {
const data = await listUsers();
setUsers(data || []);
};
const loadPage = async (nextParams = params) => {
setLoading(true);
try {
const result = await getMeetingPointsLedgerPage(nextParams);
setRecords(result.records || []);
setTotal(result.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => {
void Promise.all([loadOverview(), loadPage(), loadUsers()]);
}, []);
const handleSearch = () => {
const nextParams = { ...params, current: 1 };
setParams(nextParams);
void loadPage(nextParams);
};
const handleReset = () => {
const nextParams = {
current: 1,
size: 20,
username: "",
pointsType: "",
};
setParams(nextParams);
void loadPage(nextParams);
};
const handleRefresh = async () => {
await Promise.all([loadOverview(), loadPage(), loadUsers()]);
message.success("已刷新积分数据");
};
const handleOpenDetail = async (ledgerId: number) => {
setDetailLoading(true);
setDetailOpen(true);
try {
const data = await getMeetingPointsLedgerDetail(ledgerId);
setDetail(data);
} finally {
setDetailLoading(false);
}
};
const handleTransferSubmit = async () => {
const values = await transferForm.validateFields();
setTransferLoading(true);
try {
await transferMeetingPoints(values);
message.success("积分分配成功");
setTransferOpen(false);
transferForm.resetFields();
await Promise.all([loadOverview(), loadPage()]);
} finally {
setTransferLoading(false);
}
};
const columns = useMemo(
() => [
{
title: "用户",
dataIndex: "ownerUserName",
key: "ownerUserName",
width: 140,
render: (value: string) => <Text strong>{value || "-"}</Text>,
},
{
title: "扣费账户",
dataIndex: "chargeAccountType",
key: "chargeAccountType",
width: 120,
render: (value: string) => <Tag>{getAccountTypeLabel(value)}</Tag>,
},
{
title: "消耗类型",
dataIndex: "pointsType",
key: "pointsType",
width: 100,
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
},
{
title: "消耗积分",
dataIndex: "consumedPoints",
key: "consumedPoints",
width: 110,
render: (value: number) => <Text>{value ?? 0}</Text>,
},
{
title: "会议标题",
dataIndex: "meetingTitle",
key: "meetingTitle",
ellipsis: true,
render: (value: string) => <Text>{value || "-"}</Text>,
},
{
title: "触发类型",
dataIndex: "chargeTriggerType",
key: "chargeTriggerType",
width: 130,
render: (value: string) => <Tag>{getChargeTriggerLabel(value)}</Tag>,
},
{
title: "消耗时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
render: (value: string) => <Text>{formatDateTime(value)}</Text>,
},
{
title: "操作",
key: "action",
width: 88,
fixed: "right" as const,
render: (_: unknown, record: MeetingPointsLedgerListItemVO) => (
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => void handleOpenDetail(record.id)}>
</Button>
),
},
],
[],
);
const chargeItemColumns = useMemo(
() => [
{
title: "阶段",
dataIndex: "chargeStage",
key: "chargeStage",
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
},
{
title: "扣费账户",
dataIndex: "accountType",
key: "accountType",
render: (value: string) => getAccountTypeLabel(value),
},
{
title: "账户用户ID",
dataIndex: "accountUserId",
key: "accountUserId",
},
{
title: "扣费积分",
dataIndex: "chargedPoints",
key: "chargedPoints",
},
{
title: "扣费前余额",
dataIndex: "balanceBefore",
key: "balanceBefore",
},
{
title: "扣费后余额",
dataIndex: "balanceAfter",
key: "balanceAfter",
},
],
[],
);
return (
<PageContainer
title="积分管理"
subtitle="查看公共账户、个人账户和总结扣费流水"
headerExtra={
<Space>
<Tag color="processing">{getAccountModeLabel(overview?.accountMode)}</Tag>
<Tag color="blue">{getChargePriorityLabel(overview?.chargePriority)}</Tag>
<Button icon={<PlusOutlined />} onClick={() => setTransferOpen(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
</Button>
</Space>
}
toolbar={
<Space wrap size="middle">
<Input
placeholder="按用户名搜索"
value={params.username}
onChange={(event) => setParams((prev) => ({ ...prev, username: event.target.value }))}
style={{ width: 220 }}
prefix={<SearchOutlined className="text-gray-400" />}
allowClear
/>
<Select
style={{ width: 140 }}
value={params.pointsType}
onChange={(value) => setParams((prev) => ({ ...prev, pointsType: value }))}
options={POINTS_TYPE_OPTIONS}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={6}>
<Card>
<Statistic title="当前模式可用总积分" value={overview?.totalAvailableBalance ?? 0} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="公共账户余额" value={overview?.publicBalance ?? 0} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="个人账户余额汇总" value={overview?.personalBalance ?? 0} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="累计消耗次数" value={overview?.totalChargeCount ?? 0} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={12}>
<Card>
<Statistic title="公共账户累计消耗积分" value={overview?.publicTotalPointsUsed ?? 0} />
</Card>
</Col>
<Col xs={24} md={12}>
<Card>
<Statistic title="个人账户累计消耗积分汇总" value={overview?.personalTotalPointsUsed ?? 0} />
</Card>
</Col>
</Row>
<Card
className="app-page__content-card"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, display: "flex", flexDirection: "column", minHeight: 0 } }}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
<ListTable
rowKey="id"
columns={columns as never}
dataSource={records}
loading={loading}
totalCount={total}
scroll={{ y: "calc(100vh - 470px)", x: 1200 }}
pagination={false}
/>
</div>
<AppPagination
current={params.current}
pageSize={params.size}
total={total}
onChange={(page, pageSize) => {
const nextParams = { ...params, current: page, size: pageSize };
setParams(nextParams);
void loadPage(nextParams);
}}
/>
</Card>
<Modal
title="积分流水详情"
open={detailOpen}
onCancel={() => {
setDetailOpen(false);
setDetail(null);
}}
footer={[
<Button
key="close"
onClick={() => {
setDetailOpen(false);
setDetail(null);
}}
>
</Button>,
]}
width={900}
confirmLoading={detailLoading}
>
{detail && (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="用户">{detail.ownerUserName || "-"}</Descriptions.Item>
<Descriptions.Item label="当前流水账户">{getAccountTypeLabel(detail.chargeAccountType)}</Descriptions.Item>
<Descriptions.Item label="消耗类型">{getPointsTypeLabel(detail.pointsType)}</Descriptions.Item>
<Descriptions.Item label="消耗积分">{detail.consumedPoints ?? 0}</Descriptions.Item>
<Descriptions.Item label="消耗时间">{formatDateTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="会议标题">{detail.meetingTitle || "-"}</Descriptions.Item>
<Descriptions.Item label="触发类型">{getChargeTriggerLabel(detail.chargeTriggerType)}</Descriptions.Item>
<Descriptions.Item label="录音时长(秒)">{detail.audioDurationSeconds ?? "-"}</Descriptions.Item>
<Descriptions.Item label="计费分钟数">{detail.chargedMinutes ?? "-"}</Descriptions.Item>
<Descriptions.Item label="应计总积分">{detail.totalPoints ?? 0}</Descriptions.Item>
<Descriptions.Item label="已扣总积分">{detail.chargedTotalPoints ?? 0}</Descriptions.Item>
<Descriptions.Item label="扣费前余额">{detail.balanceBefore ?? "-"}</Descriptions.Item>
<Descriptions.Item label="扣费后余额">{detail.balanceAfter ?? "-"}</Descriptions.Item>
</Descriptions>
<Card title="本次总结扣费明细" size="small">
<Table<MeetingPointsChargeItemVO>
rowKey="id"
size="small"
pagination={false}
columns={chargeItemColumns as never}
dataSource={detail.chargeItems || []}
/>
</Card>
</Space>
)}
</Modal>
<Modal
title="从公共账户分配积分"
open={transferOpen}
onCancel={() => {
setTransferOpen(false);
transferForm.resetFields();
}}
onOk={() => void handleTransferSubmit()}
confirmLoading={transferLoading}
>
<Form form={transferForm} layout="vertical">
<Form.Item name="targetUserId" label="目标用户" rules={[{ required: true, message: "请选择目标用户" }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="请选择用户"
options={users
.filter((user) => user.userId && user.userId > 0)
.map((user) => ({
label: `${user.displayName || user.username} (#${user.userId})`,
value: user.userId,
}))}
/>
</Form.Item>
<Form.Item name="points" label="分配积分" rules={[{ required: true, message: "请输入分配积分" }]}>
<InputNumber min={1} precision={0} style={{ width: "100%" }} placeholder="请输入正整数积分" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="可选,默认记为管理员从公共账户分配积分" maxLength={200} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}