527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
|
import { listUsers } from "@/api";
|
|
import {
|
|
Button,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
message,
|
|
Modal,
|
|
Select,
|
|
Space,
|
|
Tag,
|
|
Tabs,
|
|
Typography,
|
|
} from "antd";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import PageContainer from "@/components/shared/PageContainer";
|
|
import AppPagination from "@/components/shared/AppPagination";
|
|
import DataListPanel from "@/components/shared/DataListPanel";
|
|
import ListTable from "@/components/shared/ListTable/ListTable";
|
|
import SectionCard from "@/components/shared/SectionCard";
|
|
import {
|
|
getMeetingPointsLedgerPage,
|
|
getMeetingPointsOverview,
|
|
transferMeetingPoints,
|
|
type MeetingPointsLedgerListItemVO,
|
|
type MeetingPointsOverviewVO,
|
|
type MeetingPointsPersonalAccountVO,
|
|
} from "@/api/business/meetingPoints";
|
|
import type { SysUser } from "@/types";
|
|
import "./MeetingPointsManagement.css";
|
|
|
|
const { Text } = Typography;
|
|
|
|
const POINTS_TYPE_OPTIONS = [
|
|
{ label: "全部类型", value: "" },
|
|
{ label: "转录", value: "ASR" },
|
|
{ label: "总结", value: "LLM" },
|
|
];
|
|
|
|
const ACCOUNT_MODE_PUBLIC = "PUBLIC";
|
|
const ACCOUNT_MODE_PERSONAL = "PERSONAL";
|
|
const ACCOUNT_MODE_BOTH = "BOTH";
|
|
|
|
function getAccountModeLabel(mode?: string) {
|
|
if (mode === ACCOUNT_MODE_PERSONAL) return "个人账户";
|
|
if (mode === ACCOUNT_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 "geekblue";
|
|
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) : "-";
|
|
}
|
|
|
|
type OverviewRow = {
|
|
id: string;
|
|
metric: string;
|
|
value: string | number;
|
|
note: string;
|
|
};
|
|
|
|
export default function MeetingPointsManagement() {
|
|
const [overview, setOverview] = useState<MeetingPointsOverviewVO | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [transferLoading, setTransferLoading] = useState(false);
|
|
const [usersLoading, setUsersLoading] = useState(false);
|
|
const [records, setRecords] = useState<MeetingPointsLedgerListItemVO[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [transferOpen, setTransferOpen] = useState(false);
|
|
const [users, setUsers] = useState<SysUser[]>([]);
|
|
const [activeTabKey, setActiveTabKey] = useState("ledger");
|
|
const [personalAccountPagination, setPersonalAccountPagination] = useState({
|
|
current: 1,
|
|
pageSize: 10,
|
|
});
|
|
const [params, setParams] = useState({
|
|
current: 1,
|
|
size: 10,
|
|
username: "",
|
|
pointsType: "",
|
|
});
|
|
const [transferForm] = Form.useForm();
|
|
|
|
const isAdmin = Boolean(overview?.admin);
|
|
const isPublicOnly = overview?.accountMode === ACCOUNT_MODE_PUBLIC;
|
|
const isUnlimitedBalanceMode = overview?.balanceCheckEnabled === false;
|
|
const showTransferButton = isAdmin && !isPublicOnly && !isUnlimitedBalanceMode;
|
|
const showPersonalAccountSection = Boolean(overview) && !isPublicOnly;
|
|
|
|
const sectionTabs = useMemo(
|
|
() => [
|
|
{ key: "ledger", label: "积分流水" },
|
|
{ key: "overview", label: "账户概览" },
|
|
...(showPersonalAccountSection ? [{ key: "personal", label: "个人账户" }] : []),
|
|
],
|
|
[showPersonalAccountSection],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!sectionTabs.some((tab) => tab.key === activeTabKey)) {
|
|
setActiveTabKey("ledger");
|
|
}
|
|
}, [activeTabKey, sectionTabs]);
|
|
|
|
const personalAccountRows = useMemo<MeetingPointsPersonalAccountVO[]>(() => {
|
|
if (!overview || isPublicOnly) return [];
|
|
if (isAdmin) return overview.personalAccounts || [];
|
|
return [
|
|
{
|
|
userId: -1,
|
|
displayName: "当前账号",
|
|
currentBalance: overview.personalBalance ?? 0,
|
|
totalPointsUsed: overview.personalTotalPointsUsed ?? 0,
|
|
},
|
|
];
|
|
}, [overview, isAdmin, isPublicOnly]);
|
|
|
|
const pagedPersonalAccounts = useMemo(() => {
|
|
const start = (personalAccountPagination.current - 1) * personalAccountPagination.pageSize;
|
|
const end = start + personalAccountPagination.pageSize;
|
|
return personalAccountRows.slice(start, end);
|
|
}, [personalAccountPagination, personalAccountRows]);
|
|
|
|
useEffect(() => {
|
|
setPersonalAccountPagination((prev) => {
|
|
const maxPage = Math.max(1, Math.ceil(personalAccountRows.length / prev.pageSize));
|
|
return prev.current > maxPage ? { ...prev, current: maxPage } : prev;
|
|
});
|
|
}, [personalAccountRows.length]);
|
|
|
|
const loadOverview = async () => {
|
|
const data = await getMeetingPointsOverview();
|
|
setOverview(data);
|
|
};
|
|
|
|
const loadUsers = async () => {
|
|
setUsersLoading(true);
|
|
try {
|
|
const data = await listUsers();
|
|
setUsers(data || []);
|
|
} finally {
|
|
setUsersLoading(false);
|
|
}
|
|
};
|
|
|
|
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()]);
|
|
}, []);
|
|
|
|
const handleSearch = () => {
|
|
const nextParams = { ...params, current: 1 };
|
|
setParams(nextParams);
|
|
void loadPage(nextParams);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
const nextParams = {
|
|
current: 1,
|
|
size: 10,
|
|
username: "",
|
|
pointsType: "",
|
|
};
|
|
setParams(nextParams);
|
|
void loadPage(nextParams);
|
|
};
|
|
|
|
const handleRefresh = async () => {
|
|
await Promise.all([loadOverview(), loadPage()]);
|
|
message.success("已刷新积分数据");
|
|
};
|
|
|
|
const handleOpenTransfer = async () => {
|
|
setTransferOpen(true);
|
|
if (!users.length) {
|
|
await loadUsers();
|
|
}
|
|
};
|
|
|
|
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 ledgerColumns = [
|
|
{
|
|
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>,
|
|
},
|
|
];
|
|
|
|
const personalAccountColumns = [
|
|
{
|
|
title: "序号",
|
|
key: "index",
|
|
width: 80,
|
|
render: (_: unknown, __: MeetingPointsPersonalAccountVO, index: number) =>
|
|
(personalAccountPagination.current - 1) * personalAccountPagination.pageSize + index + 1,
|
|
},
|
|
{
|
|
title: "账户",
|
|
key: "displayName",
|
|
width: 260,
|
|
render: (_: unknown, record: MeetingPointsPersonalAccountVO) => (
|
|
<Space direction="vertical" size={2}>
|
|
<Text strong>{record.displayName || record.username || `用户 #${record.userId}`}</Text>
|
|
<Text type="secondary">{record.username ? `@${record.username}` : `ID ${record.userId}`}</Text>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: "当前余额",
|
|
dataIndex: "currentBalance",
|
|
key: "currentBalance",
|
|
width: 140,
|
|
render: (value: number) => <Text strong>{value ?? 0}</Text>,
|
|
},
|
|
{
|
|
title: "累计消耗",
|
|
dataIndex: "totalPointsUsed",
|
|
key: "totalPointsUsed",
|
|
width: 140,
|
|
render: (value: number) => <Text>{value ?? 0}</Text>,
|
|
},
|
|
{
|
|
title: "账户类型",
|
|
key: "accountType",
|
|
width: 120,
|
|
render: () => <Tag color="blue">个人账户</Tag>,
|
|
},
|
|
];
|
|
|
|
const overviewRows = useMemo<OverviewRow[]>(() => {
|
|
if (!overview) return [];
|
|
return [
|
|
{ id: "accountMode", metric: "账户模式", value: getAccountModeLabel(overview.accountMode), note: "当前租户积分账户组合方式" },
|
|
{ id: "chargePriority", metric: "扣费优先级", value: getChargePriorityLabel(overview.chargePriority), note: "公共账户与个人账户的扣费顺序" },
|
|
{ id: "balanceCheckEnabled", metric: "余额校验状态", value: overview.balanceCheckEnabled ? "校验余额模式" : "无限余额模式", note: "控制会议提交时是否执行余额拦截" },
|
|
{ id: "publicBalance", metric: "公共账户余额", value: overview.publicBalance ?? 0, note: "公共账户当前可用积分" },
|
|
{ id: "publicTotalPointsUsed", metric: "公共账户累计消耗", value: overview.publicTotalPointsUsed ?? 0, note: "公共账户已消耗积分总量" },
|
|
{ id: "personalBalance", metric: "个人账户余额", value: overview.personalBalance ?? 0, note: "个人账户当前可用积分" },
|
|
{ id: "personalTotalPointsUsed", metric: "个人账户累计消耗", value: overview.personalTotalPointsUsed ?? 0, note: "个人账户已消耗积分总量" },
|
|
{ id: "totalAvailableBalance", metric: "总可用余额", value: overview.totalAvailableBalance ?? 0, note: "当前账户体系可直接使用的积分余额" },
|
|
{ id: "totalChargeCount", metric: "累计扣费次数", value: overview.totalChargeCount ?? 0, note: "已产生的积分扣费记录数" },
|
|
];
|
|
}, [overview]);
|
|
|
|
const overviewColumns = [
|
|
{
|
|
title: "指标",
|
|
dataIndex: "metric",
|
|
key: "metric",
|
|
width: 180,
|
|
render: (value: string) => <Text strong>{value || "-"}</Text>,
|
|
},
|
|
{
|
|
title: "数值",
|
|
dataIndex: "value",
|
|
key: "value",
|
|
width: 180,
|
|
render: (value: string | number, record: OverviewRow) =>
|
|
record.id === "balanceCheckEnabled" ? (
|
|
<Tag color={overview?.balanceCheckEnabled ? "green" : "volcano"}>{value}</Tag>
|
|
) : (
|
|
<Text strong>{value ?? "-"}</Text>
|
|
),
|
|
},
|
|
{
|
|
title: "说明",
|
|
dataIndex: "note",
|
|
key: "note",
|
|
ellipsis: true,
|
|
render: (value: string) => <Text type="secondary">{value || "-"}</Text>,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<PageContainer title={null} className="meeting-points-page">
|
|
<SectionCard
|
|
title="积分管理"
|
|
description="查看当前租户下的积分账面余额、累计消耗和会议消耗记录。"
|
|
tabs={
|
|
<Tabs
|
|
activeKey={activeTabKey}
|
|
onChange={setActiveTabKey}
|
|
items={sectionTabs}
|
|
size="middle"
|
|
type="card"
|
|
/>
|
|
}
|
|
>
|
|
<DataListPanel
|
|
leftActions={
|
|
isLookupTab(activeTabKey) && showTransferButton ? (
|
|
<Button icon={<PlusOutlined />} onClick={() => void handleOpenTransfer()}>
|
|
分配积分
|
|
</Button>
|
|
) : null
|
|
}
|
|
rightActions={
|
|
<Space wrap>
|
|
{activeTabKey === "ledger" ? (
|
|
<>
|
|
<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>
|
|
</>
|
|
) : null}
|
|
<Button
|
|
icon={<ReloadOutlined />}
|
|
onClick={() => void handleRefresh()}
|
|
title="刷新"
|
|
aria-label="刷新"
|
|
/>
|
|
</Space>
|
|
}
|
|
footer={
|
|
activeTabKey === "overview" ? (
|
|
<div className="app-pagination-container meeting-points-page__static-pagination">
|
|
<div className="app-pagination-total">共 {overviewRows.length} 条记录</div>
|
|
</div>
|
|
) : activeTabKey === "personal" ? (
|
|
<AppPagination
|
|
current={personalAccountPagination.current}
|
|
pageSize={personalAccountPagination.pageSize}
|
|
total={personalAccountRows.length}
|
|
onChange={(page, size) => setPersonalAccountPagination({ current: page, pageSize: size })}
|
|
/>
|
|
) : (
|
|
<AppPagination
|
|
current={params.current}
|
|
pageSize={params.size}
|
|
total={total}
|
|
onChange={(page, size) => {
|
|
const nextParams = { ...params, current: page, size };
|
|
setParams(nextParams);
|
|
void loadPage(nextParams);
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
>
|
|
{activeTabKey === "overview" ? (
|
|
<ListTable<any>
|
|
key="overview"
|
|
rowKey="id"
|
|
columns={overviewColumns}
|
|
dataSource={overviewRows}
|
|
loading={false}
|
|
scroll={{ x: 900, y: "100%" }}
|
|
pagination={false}
|
|
/>
|
|
) : activeTabKey === "personal" ? (
|
|
<ListTable<any>
|
|
key="personal"
|
|
rowKey="userId"
|
|
columns={personalAccountColumns}
|
|
dataSource={pagedPersonalAccounts}
|
|
loading={false}
|
|
scroll={{ x: 900, y: "100%" }}
|
|
pagination={false}
|
|
/>
|
|
) : (
|
|
<ListTable<any>
|
|
key="ledger"
|
|
rowKey="id"
|
|
columns={ledgerColumns}
|
|
dataSource={records}
|
|
loading={loading}
|
|
scroll={{ x: 1100, y: "100%" }}
|
|
pagination={false}
|
|
/>
|
|
)}
|
|
</DataListPanel>
|
|
</SectionCard>
|
|
|
|
<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
|
|
loading={usersLoading}
|
|
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>
|
|
);
|
|
}
|
|
|
|
function isLookupTab(tabKey: string) {
|
|
return tabKey === "ledger" || tabKey === "personal";
|
|
}
|