Initial CMPP frontend prototype

This commit is contained in:
hectorzhao
2026-06-30 16:09:46 +08:00
commit 2f3c274a30
98 changed files with 25255 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
import { useMemo, useState } from 'react';
import {
BarChart3,
Clock3,
DollarSign,
FileCheck2,
RadioTower,
Send,
ShieldCheck,
Users,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Chart,
Modal,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
import { auditTrend, hourlySendTrend } from '@/mock/chartData';
import { adminService, type AuditStatus } from '@/mock';
import { createAuditColumns } from '@/apps/admin/auditColumns';
import { createBarOption, createLineOption } from '@/theme/chartOptions';
type SignatureRank = {
id: string;
signature: string;
customer: string;
type: '不含引流' | '仅引流';
successCount: number;
successRate: number;
averageSeconds: number;
status: '正常' | '关注' | '异常';
};
type EnterpriseSpendRank = {
id: string;
city: string;
enterprise: string;
contact: string;
todaySpend: number;
balanceStatus: '充足' | '紧张' | '欠费';
availableBalance: number;
};
const signatureRanks: SignatureRank[] = [
{ id: 'SIG-001', signature: '[XX银行]', customer: '上海云舟科技', type: '不含引流', successCount: 1278, successRate: 77.8, averageSeconds: 3.7, status: '正常' },
{ id: 'SIG-002', signature: '[XX科技有限公司]', customer: '杭州星澜商贸', type: '不含引流', successCount: 627, successRate: 65.3, averageSeconds: 2.5, status: '关注' },
{ id: 'SIG-003', signature: '[XXAPP]', customer: '深圳北辰出行', type: '不含引流', successCount: 322, successRate: 97.2, averageSeconds: 115.2, status: '关注' },
{ id: 'SIG-004', signature: '[XXXXX公司]', customer: '广州麦芒科技', type: '不含引流', successCount: 125, successRate: 33.2, averageSeconds: 13, status: '异常' },
{ id: 'SIG-005', signature: '[XXXXX公司]', customer: '北京鸣川科技', type: '不含引流', successCount: 45, successRate: 0, averageSeconds: 0.3, status: '异常' },
{ id: 'SIG-101', signature: '[XX银行]', customer: '上海云舟科技', type: '仅引流', successCount: 1278, successRate: 77.8, averageSeconds: 3.7, status: '正常' },
{ id: 'SIG-102', signature: '[XX科技有限公司]', customer: '杭州星澜商贸', type: '仅引流', successCount: 527, successRate: 65.3, averageSeconds: 2.5, status: '关注' },
{ id: 'SIG-103', signature: '[XXAPP]', customer: '深圳北辰出行', type: '仅引流', successCount: 322, successRate: 97.2, averageSeconds: 115.2, status: '关注' },
{ id: 'SIG-104', signature: '[XXXXX公司]', customer: '广州麦芒科技', type: '仅引流', successCount: 125, successRate: 33.2, averageSeconds: 13, status: '异常' },
{ id: 'SIG-105', signature: '[XXXXX公司]', customer: '北京鸣川科技', type: '仅引流', successCount: 45, successRate: 0, averageSeconds: 0.3, status: '异常' },
];
const enterpriseSpendRanks: EnterpriseSpendRank[] = [
{ id: 'ENT-001', city: '上海', enterprise: '上海XXXXX科技有限公司', contact: '赵先生', todaySpend: 1123.4, balanceStatus: '充足', availableBalance: 286420 },
{ id: 'ENT-002', city: '上海', enterprise: '上海云舟科技有限公司', contact: '王女士', todaySpend: 256.3, balanceStatus: '充足', availableBalance: 94220 },
{ id: 'ENT-003', city: '深圳', enterprise: '深圳XXXXX科技有限公司', contact: '陈先生', todaySpend: 97.25, balanceStatus: '紧张', availableBalance: 1200 },
{ id: 'ENT-004', city: '北京', enterprise: '北京XXXXX科技有限公司', contact: '刘女士', todaySpend: 66.2, balanceStatus: '充足', availableBalance: 55200 },
{ id: 'ENT-005', city: '杭州', enterprise: '杭州XXXXX科技有限公司', contact: '周先生', todaySpend: 12, balanceStatus: '欠费', availableBalance: 0 },
];
const rankStatusTone = {
: 'success',
: 'warning',
: 'danger',
} as const;
const balanceTone = {
: 'success',
: 'warning',
: 'danger',
} as const;
function formatCurrency(value: number) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function formatCount(value: number) {
return value.toLocaleString('zh-CN');
}
export function AdminHome() {
const navigate = useNavigate();
const overview = adminService.getOverview();
const [audits, setAudits] = useState(() => adminService.getAudits());
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
const channels = adminService.getChannels();
function updateAuditStatus(id: string, status: AuditStatus) {
setAudits(adminService.updateAuditStatus(id, status));
}
const auditColumns = useMemo(() => createAuditColumns(updateAuditStatus), []);
const pendingAudits = audits.filter((item) => item.status === 'pending');
const noDiversionSignatureRanks = signatureRanks.filter((item) => item.type === '不含引流');
const diversionSignatureRanks = signatureRanks.filter((item) => item.type === '仅引流');
const totalSend = signatureRanks.reduce((sum, item) => sum + item.successCount, 0);
const averageSuccessRate = signatureRanks.reduce((sum, item) => sum + item.successRate, 0) / signatureRanks.length;
const todaySpend = enterpriseSpendRanks.reduce((sum, item) => sum + item.todaySpend, 0);
const activeSignatureCount = new Set(signatureRanks.map((item) => item.signature)).size;
const sendTrendOption = useMemo(
() => createLineOption({
labels: hourlySendTrend.map((item) => item.time),
series: [
{ name: '提交量', data: hourlySendTrend.map((item) => item.sent) },
{ name: '成功量', data: hourlySendTrend.map((item) => item.success) },
],
}),
[],
);
const auditTrendOption = useMemo(
() => createBarOption({
labels: auditTrend.map((item) => item.day),
series: [
{ name: '通过', data: auditTrend.map((item) => item.approved) },
{ name: '驳回', data: auditTrend.map((item) => item.rejected) },
{ name: '待审', data: auditTrend.map((item) => item.pending) },
],
}),
[],
);
const signatureColumns: Array<TableColumn<SignatureRank>> = [
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
{ key: 'signature', title: '签名', render: (record) => <strong>{record.signature}</strong> },
{ key: 'successCount', title: '成功总数', align: 'right', render: (record) => formatCount(record.successCount) },
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate}%` },
{ key: 'averageSeconds', title: '平均时长(秒)', align: 'right', render: (record) => record.averageSeconds },
];
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
{
key: 'enterprise',
title: '企业名称',
render: (record) => (
<div>
<strong>{record.enterprise}</strong>
<p className="text-caption">{record.city} · {record.contact}</p>
</div>
),
},
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpend)}` },
{ key: 'availableBalance', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance) },
{ key: 'balanceStatus', title: '余额状态', render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag> },
{
key: 'actions',
title: '操作',
align: 'right',
render: (record) => (
<Button onClick={() => setSelectedEnterprise(record)} size="sm" variant="ghost">
</Button>
),
},
];
const channelColumns: Array<TableColumn<ReturnType<typeof adminService.getChannels>[number]>> = [
{ key: 'name', title: '通道名称', render: (record) => <strong>{record.name}</strong> },
{ key: 'region', title: '区域', render: (record) => <span className="muted">{record.region}</span> },
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate}%` },
{ key: 'latencyMs', title: '平均延迟', align: 'right', render: (record) => `${record.latencyMs}ms` },
{ key: 'enabled', title: '状态', render: (record) => <Tag tone={record.enabled ? 'success' : 'neutral'}>{record.enabled ? '运行中' : '已停用'}</Tag> },
];
return (
<section className="page-stack admin-dashboard">
<div className="overview-hero admin-dashboard-hero">
<div>
<p className="eyebrow"></p>
<h1></h1>
<p className="muted"></p>
</div>
<div className="page-actions">
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
</Button>
<Button icon={<RadioTower size={16} />} onClick={() => navigate('/admin/channels')}>
</Button>
</div>
</div>
<div className="dashboard-grid admin-metric-grid">
<div className="surface metric-card">
<span></span>
<strong>{(totalSend / 10000).toFixed(4)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{averageSuccessRate.toFixed(1)}%</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{activeSignatureCount}</strong>
<small></small>
</div>
</div>
<div className="chart-grid">
<div className="surface chart-card">
<h2></h2>
<p className="muted"> 3 </p>
<Chart height={300} option={sendTrendOption} />
</div>
<div className="surface chart-card">
<h2></h2>
<p className="muted"> 7 </p>
<Chart height={300} option={auditTrendOption} />
</div>
</div>
<div className="overview-grid admin-signature-rank-grid">
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2> - </h2>
<p className="muted"></p>
</div>
<Tag tone="info">{noDiversionSignatureRanks.length} </Tag>
</div>
<Table columns={signatureColumns} data={noDiversionSignatureRanks} rowKey="id" />
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2> - </h2>
<p className="muted"></p>
</div>
<Tag tone="info">{diversionSignatureRanks.length} </Tag>
</div>
<Table columns={signatureColumns} data={diversionSignatureRanks} rowKey="id" />
</div>
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost">
</Button>
</div>
<Table columns={enterpriseColumns} data={enterpriseSpendRanks} rowKey="id" />
</div>
<div className="overview-grid">
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<Button onClick={() => navigate('/admin/channels')} size="sm" variant="ghost">
</Button>
</div>
<Table columns={channelColumns} data={channels} rowKey="id" />
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<BarChart3 size={20} className="status-info" />
</div>
<div className="overview-grid overview-grid--three">
<div className="mini-status-card">
<FileCheck2 size={22} />
<div>
<span></span>
<strong>{pendingAudits.length} </strong>
<small></small>
</div>
</div>
<div className="mini-status-card">
<ShieldCheck size={22} />
<div>
<span></span>
<strong>{overview.averageWaitMinutes} </strong>
<small></small>
</div>
</div>
<div className="mini-status-card">
<Users size={22} />
<div>
<span></span>
<strong>{overview.channelHealth}%</strong>
<small></small>
</div>
</div>
</div>
</div>
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<Button icon={<Clock3 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
</Button>
</div>
<Table columns={auditColumns} data={pendingAudits} rowKey="id" emptyText="暂无待审核记录" />
</div>
<Modal
footer={(
<>
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost"></Button>
<Button onClick={() => navigate('/admin/billing')}></Button>
</>
)}
onClose={() => setSelectedEnterprise(null)}
open={Boolean(selectedEnterprise)}
title={(
<div className="ui-detail-title">
<h2></h2>
<p>{selectedEnterprise?.id}</p>
</div>
)}
>
{selectedEnterprise ? (
<div className="ui-detail-info-grid">
<div className="ui-detail-info-grid__item">
<span></span>
<strong>{selectedEnterprise.enterprise}</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>{selectedEnterprise.city}</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>{selectedEnterprise.contact}</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>
<Tag tone={balanceTone[selectedEnterprise.balanceStatus]}>{selectedEnterprise.balanceStatus}</Tag>
</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>¥{formatCurrency(selectedEnterprise.todaySpend)}</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>{formatCount(selectedEnterprise.availableBalance)}</strong>
</div>
</div>
) : null}
</Modal>
</section>
);
}