471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|