fix: connect operations pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 15:16:34 +08:00
parent ab421cf8a7
commit 321cf716f2
22 changed files with 1255 additions and 429 deletions
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication } from '@/api/adminApi';
type SmsApp = {
id: string;
@@ -48,41 +49,6 @@ type MmsApp = Omit<SmsApp, 'cmppStatus' | 'cmppConnections' | 'cmppParams'> & {
type AppKind = 'sms' | 'mms';
const initialSmsApps: SmsApp[] = [
{
id: 'app-1',
name: '营销推广平台',
enterprise: '上海XXXXX科技有限公司',
appId: 'AK_2024010912345678',
enabled: true,
sentToday: 1500,
deliveryRate: 95,
unitPrice: 0.05,
cmppStatus: 'connected',
cmppParams: { host: '127.0.0.1', port: 7890, enterpriseCode: '900123', account: 'AC900123', password: 'PW-9x8k2m', accessNumber: '106900123', maxConnections: 2, heartbeatSeconds: 30, windowSize: 32, protocolVersion: 'CMPP 2.0' },
cmppConnections: [
{ id: 'CMPP-001-A', state: 'open', bindType: 'transceiver', clientIp: '10.24.8.12:32516', sourceAddr: '900123', establishedAt: '2026-07-02 08:42:11', lastHeartbeatAt: '2026-07-02 10:18:32', lastSubmitAt: '2026-07-02 10:17:58', pendingWindow: 18 },
{ id: 'CMPP-001-B', state: 'open', bindType: 'submitter', clientIp: '10.24.8.13:32520', sourceAddr: '900123', establishedAt: '2026-07-02 08:43:02', lastHeartbeatAt: '2026-07-02 10:18:28', lastSubmitAt: '2026-07-02 10:18:06', pendingWindow: 11 },
],
},
{
id: 'app-2',
name: '客户服务系统',
enterprise: '重庆进载数智',
appId: 'AK_2024010987654321',
enabled: true,
sentToday: 800,
deliveryRate: 90,
unitPrice: 0.06,
cmppStatus: 'disconnected',
cmppParams: { host: '127.0.0.1', port: 7891, enterpriseCode: '901778', account: 'AC901778', password: 'PW-4n7q1a', accessNumber: '106901778', maxConnections: 1, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 2.0' },
cmppConnections: [
{ id: 'CMPP-002-A', state: 'closed', bindType: 'transceiver', clientIp: '10.24.9.21:31888', sourceAddr: '901778', establishedAt: '2026-07-02 07:55:19', lastHeartbeatAt: '2026-07-02 09:21:44', lastSubmitAt: '2026-07-02 09:20:17', pendingWindow: 0 },
],
},
{ id: 'app-3', name: '验证码服务', enterprise: '超感世纪互三网', appId: 'AK_2024010811223344', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.04, cmppStatus: 'inactive', cmppParams: { host: '127.0.0.1', port: 7892, enterpriseCode: '902456', account: 'AC902456', password: 'PW-2d6f8p', accessNumber: '106902456', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 2.0' }, cmppConnections: [] },
];
const initialMmsApps: MmsApp[] = [
{ id: 'mms-app-1', name: '营销活动彩信', enterprise: '上海XXXXX科技有限公司', appId: 'MMS_2024020112345678', enabled: true, sentToday: 320, deliveryRate: 92, unitPrice: 0.15, pointPrice: 50 },
{ id: 'mms-app-2', name: '节日祝福彩信', enterprise: '重庆进载数智', appId: 'MMS_2024020187654321', enabled: true, sentToday: 180, deliveryRate: 88, unitPrice: 0.12, pointPrice: 30 },
@@ -117,18 +83,18 @@ const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone
reconnecting: { label: '重连中', tone: 'warning' },
};
function formatCmppParams(app: SmsApp) {
const { cmppParams } = app;
function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
const cmppParams = params ?? app.cmppParams;
return [
`应用名称: ${app.name}`,
`企业名称: ${app.enterprise}`,
`AppID: ${app.appId}`,
`CMPP网关地址: ${cmppParams.host}`,
`CMPP网关端口: ${cmppParams.port}`,
`CMPP网关地址: ${'gatewayHost' in cmppParams ? cmppParams.gatewayHost : cmppParams.host}`,
`CMPP网关端口: ${'gatewayPort' in cmppParams ? cmppParams.gatewayPort : cmppParams.port}`,
`企业代码: ${cmppParams.enterpriseCode}`,
`接口账号: ${cmppParams.account}`,
`接口密码: ${cmppParams.password}`,
`接入号: ${cmppParams.accessNumber}`,
`接口密码: ${'passwordCipher' in cmppParams ? cmppParams.passwordCipher : cmppParams.password}`,
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
`最大连接数: ${cmppParams.maxConnections}`,
`心跳间隔: ${cmppParams.heartbeatSeconds}`,
`提交窗口: ${cmppParams.windowSize}`,
@@ -136,9 +102,13 @@ function formatCmppParams(app: SmsApp) {
].join('\n');
}
function CmppParamsModal({ app, onClose }: { app: SmsApp; onClose: () => void }) {
function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: ApplicationCmppParams | null; onClose: () => void }) {
const [copied, setCopied] = useState(false);
const paramsText = formatCmppParams(app);
const paramsText = formatCmppParams(app, params);
const host = params?.gatewayHost ?? app.cmppParams.host;
const port = params?.gatewayPort ?? app.cmppParams.port;
const password = params?.passwordCipher ?? app.cmppParams.password;
const srcId = params?.srcId ?? app.cmppParams.accessNumber;
async function copyParams() {
await navigator.clipboard.writeText(paramsText);
@@ -161,16 +131,16 @@ function CmppParamsModal({ app, onClose }: { app: SmsApp; onClose: () => void })
>
<div className="cmpp-param-detail">
<div className="cmpp-param-grid">
<div><span>CMPP网关地址</span><strong>{app.cmppParams.host}</strong></div>
<div><span>CMPP网关端口</span><strong>{app.cmppParams.port}</strong></div>
<div><span></span><strong>{app.cmppParams.enterpriseCode}</strong></div>
<div><span></span><strong>{app.cmppParams.account}</strong></div>
<div><span></span><strong>{app.cmppParams.password}</strong></div>
<div><span></span><strong>{app.cmppParams.accessNumber}</strong></div>
<div><span></span><strong>{app.cmppParams.maxConnections}</strong></div>
<div><span></span><strong>{app.cmppParams.heartbeatSeconds} </strong></div>
<div><span></span><strong>{app.cmppParams.windowSize}</strong></div>
<div><span></span><strong>{app.cmppParams.protocolVersion}</strong></div>
<div><span>CMPP网关地址</span><strong>{host}</strong></div>
<div><span>CMPP网关端口</span><strong>{port}</strong></div>
<div><span></span><strong>{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}</strong></div>
<div><span></span><strong>{params?.account ?? app.cmppParams.account}</strong></div>
<div><span></span><strong>{password}</strong></div>
<div><span></span><strong>{srcId}</strong></div>
<div><span></span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
<div><span></span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} </strong></div>
<div><span></span><strong>{params?.windowSize ?? app.cmppParams.windowSize}</strong></div>
<div><span></span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
</div>
<pre className="cmpp-param-copy">{paramsText}</pre>
</div>
@@ -236,64 +206,79 @@ function CmppConnectionModal({
export function AdminEnterpriseApplicationsPage() {
const navigate = useNavigate();
const [smsApps, setSmsApps] = useState(initialSmsApps);
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
const [mmsApps, setMmsApps] = useState(initialMmsApps);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
const [error, setError] = useState('');
const [confirmAction, setConfirmAction] = useState<
| { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean }
| { action: 'delete'; kind: AppKind; id: string; name: string }
| null
>(null);
function confirmToggle(kind: AppKind, id: string) {
async function loadSmsApps() {
try {
const applications = await adminApi.listEnterpriseApplications({ keyword: enterpriseKeyword });
setSmsApps(applications.map(mapApplication));
setError('');
} catch (err) {
setSmsApps([]);
setError(err instanceof Error ? err.message : '企业应用加载失败');
}
}
useEffect(() => {
void loadSmsApps();
}, [enterpriseKeyword]);
async function confirmToggle(kind: AppKind, id: string) {
if (kind === 'sms') {
setSmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
const app = smsApps.find((item) => item.id === id);
if (app) {
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
await loadSmsApps();
}
return;
}
setMmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
}
function confirmDelete(kind: AppKind, id: string) {
async function confirmDelete(kind: AppKind, id: string) {
if (kind === 'sms') {
setSmsApps((current) => current.filter((item) => item.id !== id));
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
await loadSmsApps();
} else {
setMmsApps((current) => current.filter((item) => item.id !== id));
}
}
function runConfirmedAction() {
async function runConfirmedAction() {
if (!confirmAction) {
return;
}
if (confirmAction.action === 'toggle') {
confirmToggle(confirmAction.kind, confirmAction.id);
await confirmToggle(confirmAction.kind, confirmAction.id);
} else {
confirmDelete(confirmAction.kind, confirmAction.id);
await confirmDelete(confirmAction.kind, confirmAction.id);
}
setConfirmAction(null);
}
function deleteConnection(appId: string, connectionId: string) {
let nextConnectionApp: SmsApp | null = null;
setSmsApps((current) => current.map((app) => {
if (app.id !== appId) {
return app;
}
async function deleteConnection(appId: string, connectionId: string) {
await adminApi.disconnectApplicationConnection(appId, connectionId, '运营端断开企业应用 CMPP 连接');
const data = await adminApi.listApplicationConnections(appId);
const nextApp = mapApplication({ ...data.application, cmppConnections: data.connections, cmppStatus: data.summary.status as EnterpriseApplication['cmppStatus'] });
setConnectionApp(nextApp);
await loadSmsApps();
}
const nextConnections = app.cmppConnections.filter((connection) => connection.id !== connectionId);
const nextOpenCount = nextConnections.filter((connection) => connection.state === 'open').length;
const nextApp: SmsApp = {
...app,
cmppConnections: nextConnections,
cmppStatus: nextOpenCount > 0 ? 'connected' : app.enabled ? 'disconnected' : 'inactive',
};
nextConnectionApp = nextApp;
return nextApp;
}));
setConnectionApp(nextConnectionApp);
async function openParams(app: SmsApp) {
setParamsApp(app);
setParamsDetail(await adminApi.getApplicationCmppParams(app.id));
}
const filteredSmsApps = useMemo(
@@ -325,7 +310,7 @@ export function AdminEnterpriseApplicationsPage() {
<button onClick={() => setConnectionApp(record)} type="button">
{record.cmppConnections.filter((item) => item.state === 'open').length}
</button>
<button className="cmpp-status-cell__params" onClick={() => setParamsApp(record)} type="button">
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
<Settings2 size={13} />
</button>
@@ -397,6 +382,8 @@ export function AdminEnterpriseApplicationsPage() {
<Button onClick={() => setEnterpriseKeyword('')} variant="ghost"></Button>
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
<div className="surface section-stack">
<Tabs
items={[
@@ -413,17 +400,49 @@ export function AdminEnterpriseApplicationsPage() {
? `确认删除应用“${confirmAction.name}”吗?`
: `确认${confirmAction.enabled ? '停用' : '启用'}应用“${confirmAction.name}”吗?`}
onCancel={() => setConfirmAction(null)}
onConfirm={runConfirmedAction}
onConfirm={() => { void runConfirmedAction(); }}
/>
) : null}
{connectionApp ? (
<CmppConnectionModal
app={connectionApp}
onClose={() => setConnectionApp(null)}
onDeleteConnection={(connectionId) => deleteConnection(connectionApp.id, connectionId)}
onDeleteConnection={(connectionId) => { void deleteConnection(connectionApp.id, connectionId); }}
/>
) : null}
{paramsApp ? <CmppParamsModal app={paramsApp} onClose={() => setParamsApp(null)} /> : null}
{paramsApp ? <CmppParamsModal app={paramsApp} params={paramsDetail} onClose={() => { setParamsApp(null); setParamsDetail(null); }} /> : null}
</section>
);
}
function mapApplication(application: EnterpriseApplication): SmsApp {
const connections = (application.cmppConnections ?? []).map(mapConnection);
return {
id: application.id,
name: application.name,
enterprise: application.tenant?.name ?? application.tenantId,
appId: application.id,
enabled: application.status === 'active',
sentToday: application.sentToday ?? 0,
deliveryRate: application.deliveryRate ?? 0,
unitPrice: 0,
cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
cmppParams: { host: '', port: 0, enterpriseCode: application.tenant?.code ?? application.tenantId, account: application.tenant?.code ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' },
cmppConnections: connections,
};
}
function mapConnection(connection: CmppConnectionState): CmppConnection {
const isOpen = ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0;
return {
id: connection.connectionId,
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
bindType: 'transceiver',
clientIp: String(connection.channel?.gatewayHost ?? ''),
sourceAddr: String(connection.channel?.enterpriseCode ?? ''),
establishedAt: connection.lastConnectedAt ? new Date(connection.lastConnectedAt).toLocaleString('zh-CN') : '',
lastHeartbeatAt: connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN') : '',
lastSubmitAt: connection.updatedAt ? new Date(connection.updatedAt).toLocaleString('zh-CN') : '',
pendingWindow: connection.currentConnections,
};
}
+64 -142
View File
@@ -1,11 +1,9 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
BarChart3,
Clock3,
DollarSign,
FileCheck2,
RadioTower,
Send,
ShieldCheck,
Users,
} from 'lucide-react';
@@ -19,59 +17,17 @@ import {
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 { adminApi, type DashboardResponse } from '@/api/adminApi';
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',
@@ -91,57 +47,65 @@ function formatCount(value: number) {
export function AdminHome() {
const navigate = useNavigate();
const overview = adminService.getOverview();
const [audits, setAudits] = useState(() => adminService.getAudits());
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [error, setError] = useState('');
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
const channels = adminService.getChannels();
const [channels, setChannels] = useState<Array<{ id: string; name: string; status: string; rateLimitPerSecond: number }>>([]);
function updateAuditStatus(id: string, status: AuditStatus) {
setAudits(adminService.updateAuditStatus(id, status));
}
useEffect(() => {
Promise.all([adminApi.getDashboard(), adminApi.listChannels()])
.then(([nextDashboard, nextChannels]) => {
setDashboard(nextDashboard);
setChannels(nextChannels);
})
.catch((err) => {
setError(err instanceof Error ? err.message : '运营看板加载失败');
setDashboard(null);
});
}, []);
const auditColumns = useMemo(() => createAuditColumns(updateAuditStatus), []);
const pendingAudits = audits.filter((item) => item.status === 'pending');
const enterpriseSpendRanks = useMemo<EnterpriseSpendRank[]>(() => {
return (dashboard?.accounts ?? []).map((account) => {
const todaySpend = Math.abs(dashboard?.recentRecharges
.filter((item) => item.tenantId === account.tenantId)
.reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 100;
const availableBalance = (account.balanceCents + account.creditCents) / 100;
return {
id: account.tenantId,
enterprise: account.tenant?.name ?? account.tenantId,
todaySpend,
availableBalance,
balanceStatus: (availableBalance <= 0 ? '欠费' : availableBalance < 100 ? '紧张' : '充足') as EnterpriseSpendRank['balanceStatus'],
};
}).sort((left, right) => right.todaySpend - left.todaySpend);
}, [dashboard]);
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 totalSend = dashboard?.today.sent ?? 0;
const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
const sendTrendOption = useMemo(
() => createLineOption({
labels: hourlySendTrend.map((item) => item.time),
labels: ['今日'],
series: [
{ name: '提交量', data: hourlySendTrend.map((item) => item.sent) },
{ name: '成功量', data: hourlySendTrend.map((item) => item.success) },
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
],
}),
[],
[dashboard],
);
const auditTrendOption = useMemo(
() => createBarOption({
labels: auditTrend.map((item) => item.day),
labels: ['待审核'],
series: [
{ name: '通过', data: auditTrend.map((item) => item.approved) },
{ name: '驳回', data: auditTrend.map((item) => item.rejected) },
{ name: '待审', data: auditTrend.map((item) => item.pending) },
{ name: '待审', data: [dashboard?.pendingAuditCount ?? 0] },
],
}),
[],
[dashboard],
);
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 },
{
@@ -150,7 +114,7 @@ export function AdminHome() {
render: (record) => (
<div>
<strong>{record.enterprise}</strong>
<p className="text-caption">{record.city} · {record.contact}</p>
<p className="text-caption">{record.id}</p>
</div>
),
},
@@ -169,12 +133,10 @@ export function AdminHome() {
},
];
const channelColumns: Array<TableColumn<ReturnType<typeof adminService.getChannels>[number]>> = [
const channelColumns: Array<TableColumn<(typeof channels)[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> },
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status}</Tag> },
{ key: 'rateLimitPerSecond', title: '限速', align: 'right', render: (record) => `${record.rateLimitPerSecond}/s` },
];
return (
@@ -197,68 +159,45 @@ export function AdminHome() {
<div className="dashboard-grid admin-metric-grid">
<div className="surface metric-card">
<span></span>
<strong>{(totalSend / 10000).toFixed(4)}</strong>
<small></small>
<strong>{formatCount(totalSend)} </strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{averageSuccessRate.toFixed(1)}%</strong>
<small></small>
<small>delivered / </small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<small></small>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{activeSignatureCount}</strong>
<small></small>
<span>线</span>
<strong>{activeConnectionCount}</strong>
<small>Gateway </small>
</div>
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
<div className="chart-grid">
<div className="surface chart-card">
<h2></h2>
<p className="muted"> 3 </p>
<p className="muted"></p>
<Chart height={300} option={sendTrendOption} />
</div>
<div className="surface chart-card">
<h2></h2>
<p className="muted"> 7 </p>
<p className="muted"></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>
<p className="muted"></p>
</div>
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost">
@@ -294,7 +233,7 @@ export function AdminHome() {
<FileCheck2 size={22} />
<div>
<span></span>
<strong>{pendingAudits.length} </strong>
<strong>{dashboard?.pendingAuditCount ?? 0} </strong>
<small></small>
</div>
</div>
@@ -302,35 +241,22 @@ export function AdminHome() {
<ShieldCheck size={22} />
<div>
<span></span>
<strong>{overview.averageWaitMinutes} </strong>
<small></small>
<strong>{dashboard?.taskCount ?? 0} </strong>
<small></small>
</div>
</div>
<div className="mini-status-card">
<Users size={22} />
<div>
<span></span>
<strong>{overview.channelHealth}%</strong>
<small></small>
<strong>{activeConnectionCount}</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={(
<>
@@ -354,12 +280,8 @@ export function AdminHome() {
<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>
<span>ID</span>
<strong>{selectedEnterprise.id}</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
+80 -52
View File
@@ -1,33 +1,16 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
type RechargeRecord = {
id: string;
enterprise: string;
rechargedAt: string;
amount?: number;
balance?: number;
operator?: string;
type: 'manual' | 'package';
remark?: string;
};
import { adminApi, type AccountTransaction, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
type ManualRechargeForm = {
enterprise: string;
tenantId: string;
amount: string;
smsUnits: string;
operator: string;
remark: string;
};
const rechargeRecordsSeed: RechargeRecord[] = [
{ id: 'RCG202601120001', enterprise: 'XXXX科技有限公司', rechargedAt: '2026-01-12 19:27:19', amount: 1000, balance: 1000, operator: '李XXX', type: 'manual', remark: '线下转账到账' },
{ id: 'RCG202601120002', enterprise: 'XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 500, balance: 5896.25, operator: '张三', type: 'package' },
{ id: 'RCG202601120003', enterprise: 'XXX公司名字XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 192.29, balance: 0, operator: '张三', type: 'manual', remark: '运营补差额' },
{ id: 'RCG202601120004', enterprise: '北京鸣川科技', rechargedAt: '2026-01-12 19:27:19', amount: 2617.09, balance: 0, operator: '李四', type: 'package' },
{ id: 'RCG202601120005', enterprise: '广州麦芒科技', rechargedAt: '2026-01-12 19:27:19', amount: 122, balance: 0, operator: '王五', type: 'manual', remark: '客服人工充值' },
];
function getDate(value: string) {
return value.slice(0, 10);
}
@@ -52,21 +35,54 @@ function RemarkCell({ value }: { value?: string }) {
}
export function AdminRechargeRecordsPage() {
const [records, setRecords] = useState(rechargeRecordsSeed);
const [records, setRecords] = useState<RechargeOrder[]>([]);
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [manualOpen, setManualOpen] = useState(false);
const [form, setForm] = useState<ManualRechargeForm>({ enterprise: '', amount: '', operator: '运营', remark: '' });
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
async function loadData() {
setLoading(true);
setError('');
try {
const [nextTenants, nextRecords, nextAccounts, nextTransactions] = await Promise.all([
adminApi.listTenants(),
adminApi.listManualRecharges(),
adminApi.listAccounts(),
adminApi.listTransactions(),
]);
setTenants(nextTenants);
setRecords(nextRecords);
setAccounts(nextAccounts);
setTransactions(nextTransactions);
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
} catch (err) {
setError(err instanceof Error ? err.message : '充值记录加载失败');
setRecords([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadData();
}, []);
const filteredRows = useMemo(
() => records.filter((item) => {
const rechargeDate = getDate(item.rechargedAt);
const matchesEnterprise = !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword);
const rechargeDate = getDate(item.paidAt ?? item.createdAt);
const tenantName = item.tenant?.name ?? tenants.find((tenant) => tenant.id === item.tenantId)?.name ?? item.tenantId;
const matchesEnterprise = !enterpriseKeyword || tenantName.includes(enterpriseKeyword);
const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start;
const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end;
return matchesEnterprise && matchesStartDate && matchesEndDate;
}),
[dateRange.end, dateRange.start, enterpriseKeyword, records],
[dateRange.end, dateRange.start, enterpriseKeyword, records, tenants],
);
function resetFilters() {
@@ -78,26 +94,21 @@ export function AdminRechargeRecordsPage() {
setForm((current) => ({ ...current, [key]: value }));
}
function submitManualRecharge() {
async function submitManualRecharge() {
const amount = Number(form.amount);
if (!form.enterprise.trim() || !Number.isFinite(amount) || amount <= 0) {
const smsUnits = Number(form.smsUnits || 0);
if (!form.tenantId || !Number.isFinite(amount) || amount <= 0 || !Number.isFinite(smsUnits) || smsUnits < 0) {
return;
}
setRecords((current) => [
{
id: `RCG${Date.now()}`,
enterprise: form.enterprise,
rechargedAt: '2026-07-01 13:58:00',
amount,
balance: amount + 1200,
operator: form.operator,
type: 'manual',
remark: form.remark,
},
...current,
]);
await adminApi.createManualRecharge({
tenantId: form.tenantId,
amountCents: Math.round(amount * 100),
smsUnits,
remark: [form.operator, form.remark].filter(Boolean).join(' / '),
});
await loadData();
setManualOpen(false);
setForm({ enterprise: '', amount: '', operator: '运营', remark: '' });
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
}
return (
@@ -134,17 +145,28 @@ export function AdminRechargeRecordsPage() {
</tr>
</thead>
<tbody>
{filteredRows.map((record) => (
{error ? (
<tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
) : loading ? (
<tr><td className="ui-table__empty" colSpan={7}>...</td></tr>
) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={7}></td></tr>
) : filteredRows.map((record) => {
const account = accounts.find((item) => item.tenantId === record.tenantId);
const transaction = transactions.find((item) => item.relatedId === record.id);
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
return (
<tr key={record.id}>
<td><strong>{record.enterprise}</strong></td>
<td>{record.rechargedAt}</td>
<td>{formatAmount(record.amount)}</td>
<td>{formatAmount(record.balance)}</td>
<td><Tag tone={record.type === 'manual' ? 'warning' : 'info'}>{record.type === 'manual' ? '人工充值' : '套餐充值'}</Tag></td>
<td>{record.operator}</td>
<td><RemarkCell value={record.remark} /></td>
<td><strong>{tenantName}</strong></td>
<td>{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}</td>
<td>{formatAmount(record.amountCents / 100)}</td>
<td>{formatAmount((transaction?.balanceAfter ?? account?.balanceCents ?? 0) / 100)}</td>
<td><Tag tone="warning"></Tag></td>
<td>{record.operatorId || '运营'}</td>
<td><RemarkCell value={record.remark ?? undefined} /></td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -177,8 +199,14 @@ export function AdminRechargeRecordsPage() {
title="企业人工充值"
>
<div className="admin-system-modal-form">
<Input label="企业名称" onChange={(event) => updateForm('enterprise', event.target.value)} value={form.enterprise} />
<Select
label="企业名称"
onChange={(event) => updateForm('tenantId', event.target.value)}
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
value={form.tenantId}
/>
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" type="number" value={form.amount} />
<Input label="短信条数" onChange={(event) => updateForm('smsUnits', event.target.value)} type="number" value={form.smsUnits} />
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} value={form.operator} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
</div>
+28 -38
View File
@@ -1,22 +1,10 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type OperationLogItem } from '@/api/adminApi';
type LogLevel = 'info' | 'success' | 'warning' | 'error';
type AdminSystemLog = {
id: string;
time: string;
level: LogLevel;
tenant: string;
module: string;
operator: string;
action: string;
resourceId: string;
detail: string;
ip: string;
};
const levelLabelMap: Record<LogLevel, string> = {
info: '信息',
success: '成功',
@@ -31,14 +19,6 @@ const levelToneMap: Record<LogLevel, 'info' | 'success' | 'warning' | 'danger'>
error: 'danger',
};
const logsSeed: AdminSystemLog[] = [
{ id: 'SYS202607010001', time: '2026-07-01 13:42:10', level: 'success', tenant: '上海云舟科技有限公司', module: '账户计费', operator: '运营', action: '人工充值', resourceId: 'RCG202607010001', detail: '人工充值 ¥2,000.00,备注:线下转账到账', ip: '10.0.1.12' },
{ id: 'SYS202607010002', time: '2026-07-01 13:20:33', level: 'info', tenant: '杭州星澜商贸有限公司', module: '短信审核', operator: '审核员A', action: '审核通过', resourceId: 'AUD202607010018', detail: '营销短信任务进入发送队列', ip: '10.0.1.15' },
{ id: 'SYS202607010003', time: '2026-07-01 12:58:44', level: 'warning', tenant: '深圳北辰出行服务有限公司', module: '风控', operator: 'system', action: '触发人工审核', resourceId: 'RISK202607010009', detail: '重复号码比例 21.4%,超过阈值 20%', ip: '127.0.0.1' },
{ id: 'SYS202607010004', time: '2026-07-01 12:11:02', level: 'error', tenant: '广州麦芒科技', module: '发送链路', operator: 'gateway', action: 'SubmitResp失败', resourceId: 'MSG-7b9e', detail: '通道返回 REJECT,错误码 8', ip: '10.0.2.21' },
{ id: 'SYS202607010005', time: '2026-07-01 11:46:29', level: 'info', tenant: '平台', module: '系统管理', operator: '平台管理员', action: '创建用户', resourceId: 'USR202607010006', detail: '新增运营用户:report-admin', ip: '10.0.1.10' },
];
export function AdminSystemLogsPage() {
const [keyword, setKeyword] = useState('');
const [level, setLevel] = useState('all');
@@ -46,25 +26,35 @@ export function AdminSystemLogsPage() {
const [range, setRange] = useState('today');
const [page, setPage] = useState(1);
const pageSize = 5;
const [logs, setLogs] = useState<OperationLogItem[]>([]);
const [modules, setModules] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [error, setError] = useState('');
useEffect(() => {
adminApi.listSystemLogs({ keyword, level, module, range, page, pageSize })
.then((data) => {
setLogs(data.items);
setModules(data.modules);
setTotal(data.total);
setError('');
})
.catch((err) => {
setLogs([]);
setTotal(0);
setError(err instanceof Error ? err.message : '系统日志加载失败');
});
}, [keyword, level, module, range, page]);
const moduleOptions = useMemo(() => {
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
}, []);
}, [modules]);
const filteredLogs = logsSeed.filter((item) => {
const target = `${item.tenant} ${item.operator} ${item.action} ${item.resourceId} ${item.detail}`;
const matchesKeyword = !keyword || target.toLowerCase().includes(keyword.toLowerCase());
const matchesLevel = level === 'all' || item.level === level;
const matchesModule = module === 'all' || item.module === module;
return matchesKeyword && matchesLevel && matchesModule;
});
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedLogs = filteredLogs.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const columns = useMemo<Array<TableColumn<AdminSystemLog>>>(() => [
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{record.time}</span> },
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
{ key: 'level', title: '级别', width: '100px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'tenant', title: '企业', width: '190px', render: (record) => <strong>{record.tenant}</strong> },
{ key: 'module', title: '模块', width: '130px', render: (record) => record.module },
@@ -78,7 +68,7 @@ export function AdminSystemLogsPage() {
render: (record) => (
<div className="system-log-detail-card">
<strong>{record.action}</strong>
<span>{record.detail}</span>
<span>{JSON.stringify(record.detail)}</span>
<small>{record.resourceId}</small>
</div>
),
@@ -137,14 +127,14 @@ export function AdminSystemLogsPage() {
</div>
<div className="surface system-table-card">
<Table columns={columns} data={pagedLogs} emptyText="暂无系统日志" rowKey="id" />
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} rowKey="id" />
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredLogs.length}
total={total}
/>
</div>
</section>
+66 -50
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
BadgeCheck,
BellRing,
@@ -11,61 +11,76 @@ import {
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
import { channelShare, hourlySendTrend } from '@/mock/chartData';
import { clientService, type RecentMessage, type TemplateStatus } from '@/mock';
import { clientApi, type DashboardResponse } from '@/api/adminApi';
import { createLineOption, createPieOption } from '@/theme/chartOptions';
const statusLabelMap: Record<RecentMessage['status'], string> = {
success: '发送完成',
warning: '排队中',
info: '发送中',
danger: '发送失败',
type RecentTaskRow = {
id: string;
taskNo: string;
scene: string;
count: number;
channel: string;
createdAt: string;
status: string;
};
const templateStatusLabelMap: Record<TemplateStatus, string> = {
draft: '草稿',
pending: '待审核',
approved: '已通过',
rejected: '已驳回',
};
const columns: Array<TableColumn<RecentMessage>> = [
{ key: 'id', title: '批次编号', render: (record) => record.id },
const columns: Array<TableColumn<RecentTaskRow>> = [
{ key: 'taskNo', title: '批次编号', render: (record) => record.taskNo },
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')}` },
{ key: 'channel', title: '通道', render: (record) => record.channel },
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status}>{statusLabelMap[record.status]}</Tag> },
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>{record.status}</Tag> },
];
export function ClientHome() {
const navigate = useNavigate();
const overview = clientService.getOverview();
const recentMessages = clientService.getRecentMessages();
const templates = clientService.getTemplates();
const signatures = clientService.getSignatures();
const invoices = clientService.getInvoices();
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [error, setError] = useState('');
const approvedTemplates = templates.filter((item) => item.status === 'approved').length;
const approvedSignatures = signatures.filter((item) => item.status === 'approved').length;
const pendingTemplates = templates.filter((item) => item.status === 'pending').length;
const pendingSignatures = signatures.filter((item) => item.status === 'pending').length;
const latestInvoice = invoices[0];
const balanceBaseline = overview.availableBalance + overview.todaySpend - overview.todayRefund;
const balancePercent = Math.min(100, Math.round((overview.availableBalance / balanceBaseline) * 100));
useEffect(() => {
clientApi.getDashboard()
.then(setDashboard)
.catch((err) => {
setError(err instanceof Error ? err.message : '客户端工作台加载失败');
setDashboard(null);
});
}, []);
const account = dashboard?.accounts[0];
const availableBalance = ((account?.balanceCents ?? 0) + (account?.creditCents ?? 0)) / 100;
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const todayRefund = Math.abs((dashboard?.transactions._sum.amountCents ?? 0) < 0 ? 0 : dashboard?.transactions._sum.amountCents ?? 0) / 100;
const balanceBaseline = Math.max(availableBalance + todaySpend - todayRefund, availableBalance, 1);
const balancePercent = Math.min(100, Math.round((availableBalance / balanceBaseline) * 100));
const recentMessages = useMemo<RecentTaskRow[]>(() => (dashboard?.recentTasks ?? []).map((task) => ({
id: String(task.id ?? task.taskNo),
taskNo: String(task.taskNo ?? task.id),
scene: String(task.category ?? task.content ?? '短信发送'),
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
channel: Array.isArray(task.messages) && task.messages[0]?.channel?.name ? String(task.messages[0].channel.name) : '待路由',
createdAt: task.createdAt ? new Date(String(task.createdAt)).toLocaleString('zh-CN') : '',
status: String(task.status ?? 'unknown'),
})), [dashboard]);
const latestRecharge = dashboard?.recentRecharges[0];
const sendTrendOption = useMemo(
() => createLineOption({
labels: hourlySendTrend.map((item) => item.time),
labels: ['今日'],
series: [
{ name: '提交量', data: hourlySendTrend.map((item) => item.sent) },
{ name: '成功量', data: hourlySendTrend.map((item) => item.success) },
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
],
}),
[],
[dashboard],
);
const channelShareOption = useMemo(() => createPieOption({ data: channelShare }), []);
const channelShareOption = useMemo(() => createPieOption({
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
name: item.status,
value: item._sum.currentConnections ?? item._count._all,
})),
}), [dashboard]);
return (
<section className="page-stack">
@@ -86,20 +101,21 @@ export function ClientHome() {
<div className="dashboard-grid">
<div className="surface metric-card metric-card--featured">
<span></span>
<strong>¥{overview.availableBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
<small> ¥{overview.todaySpend.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</small>
<strong>¥{availableBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
<small> ¥{todaySpend.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{overview.todaySent.toLocaleString('zh-CN')}</strong>
<small> {overview.todaySuccessRate}%</small>
<strong>{(dashboard?.today.sent ?? 0).toLocaleString('zh-CN')}</strong>
<small> {dashboard?.today.successRate ?? 0}%</small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{overview.todayRefund.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
<strong>¥{todayRefund.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
<small>退</small>
</div>
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
<div className="overview-grid">
<div className="surface section-stack">
@@ -118,12 +134,12 @@ export function ClientHome() {
<button className="quick-action" onClick={() => navigate('/client/templates')} type="button">
<FileText size={20} />
<span></span>
<small>{approvedTemplates} </small>
<small></small>
</button>
<button className="quick-action" onClick={() => navigate('/client/signatures')} type="button">
<PenLine size={20} />
<span></span>
<small>{approvedSignatures} </small>
<small></small>
</button>
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
<WalletCards size={20} />
@@ -139,20 +155,20 @@ export function ClientHome() {
<h2></h2>
<p className="muted"></p>
</div>
<Tag tone="success"></Tag>
<Tag tone={account?.status === 'active' ? 'success' : 'warning'}>{account?.status ?? '未知'}</Tag>
</div>
<div className="summary-list">
<div>
<span></span>
<strong></strong>
<strong>{account?.tenant?.name ?? account?.tenantId ?? '当前租户'}</strong>
</div>
<div>
<span></span>
<strong></strong>
<strong> API </strong>
</div>
<div>
<span></span>
<strong>{latestInvoice.title}</strong>
<strong>{latestRecharge ? `¥${(latestRecharge.amountCents / 100).toFixed(2)}` : '暂无充值'}</strong>
</div>
</div>
<div>
@@ -172,16 +188,16 @@ export function ClientHome() {
<BadgeCheck size={22} />
<div>
<span></span>
<strong>{approvedTemplates} </strong>
<small>{templates.map((item) => templateStatusLabelMap[item.status]).join(' / ')}</small>
<strong>{dashboard?.pendingAuditCount ?? 0} </strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<PenLine size={22} />
<div>
<span></span>
<strong>{approvedSignatures} </strong>
<small> {pendingSignatures} {signatures.filter((item) => item.status === 'rejected').length}</small>
<strong> API</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
+28 -41
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
import {
Button,
@@ -9,20 +9,10 @@ import {
Tag,
type TableColumn,
} from '@/components/ui';
import { clientApi, type OperationLogItem } from '@/api/adminApi';
type LogLevel = 'info' | 'success' | 'warning' | 'error';
type SystemLog = {
id: string;
time: string;
level: LogLevel;
module: string;
operator: string;
action: string;
detail: string;
ip: string;
};
const levelLabelMap: Record<LogLevel, string> = {
info: '信息',
success: '成功',
@@ -37,18 +27,6 @@ const levelToneMap: Record<LogLevel, 'info' | 'success' | 'warning' | 'danger'>
error: 'danger',
};
const logsSeed: SystemLog[] = [
{ id: 'LOG001', time: '2026-03-17 14:35:22', level: 'info', module: '用户管理', operator: '张三', action: '创建用户', detail: '创建用户账号:李四(lisi@example.com', ip: '192.168.1.100' },
{ id: 'LOG002', time: '2026-03-17 14:20:15', level: 'success', module: '短信服务', operator: '李四', action: '发送短信', detail: '批量发送短信至500个号码,发送成功', ip: '192.168.1.101' },
{ id: 'LOG003', time: '2026-03-17 13:45:33', level: 'warning', module: '彩信服务', operator: '王五', action: '模板审核', detail: '彩信模板“春节祝福”审核未通过,原因:内容包含敏感词', ip: '192.168.1.102' },
{ id: 'LOG004', time: '2026-03-17 12:10:08', level: 'error', module: '系统管理', operator: '赵六', action: '登录失败', detail: '用户登录失败,错误:密码错误(连续3次)', ip: '192.168.1.103' },
{ id: 'LOG005', time: '2026-03-17 11:30:45', level: 'info', module: '用户管理', operator: '张三', action: '修改权限', detail: '修改用户“孙七”的角色:普通用户 → 管理员', ip: '192.168.1.100' },
{ id: 'LOG006', time: '2026-03-17 10:15:20', level: 'success', module: '短信服务', operator: '李四', action: '签名审核', detail: '短信签名“优品商城”审核通过', ip: '192.168.1.101' },
{ id: 'LOG007', time: '2026-03-17 09:50:12', level: 'info', module: '彩信服务', operator: '王五', action: '创建模板', detail: '创建彩信模板“新品发布”(模板IDMMS_1a2b3c4d', ip: '192.168.1.102' },
{ id: 'LOG008', time: '2026-03-17 09:05:33', level: 'error', module: '短信服务', operator: '李四', action: '发送失败', detail: '短信发送失败,错误:余额不足', ip: '192.168.1.101' },
{ id: 'LOG009', time: '2026-03-17 08:40:18', level: 'warning', module: '系统管理', operator: 'system', action: '系统告警', detail: '系统磁盘使用率超过80%,当前使用率:85%', ip: '127.0.0.1' },
];
export function ClientSystemLogsPage() {
const [keyword, setKeyword] = useState('');
const [level, setLevel] = useState('all');
@@ -56,30 +34,39 @@ export function ClientSystemLogsPage() {
const [range, setRange] = useState('today');
const [page, setPage] = useState(1);
const pageSize = 5;
const [logs, setLogs] = useState<OperationLogItem[]>([]);
const [modules, setModules] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [error, setError] = useState('');
useEffect(() => {
clientApi.listSystemLogs({ keyword, level, module, range, page, pageSize })
.then((data) => {
setLogs(data.items);
setModules(data.modules);
setTotal(data.total);
setError('');
})
.catch((err) => {
setLogs([]);
setTotal(0);
setError(err instanceof Error ? err.message : '系统日志加载失败');
});
}, [keyword, level, module, range, page]);
const moduleOptions = useMemo(() => {
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
}, []);
const filteredLogs = logsSeed.filter((item) => {
const target = `${item.operator} ${item.action} ${item.detail}`;
const matchesKeyword = !keyword || target.toLowerCase().includes(keyword.toLowerCase());
const matchesLevel = level === 'all' || item.level === level;
const matchesModule = module === 'all' || item.module === module;
return matchesKeyword && matchesLevel && matchesModule;
});
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize));
}, [modules]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedLogs = filteredLogs.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const columns = useMemo<Array<TableColumn<SystemLog>>>(() => [
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{record.time}</span> },
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
{ key: 'level', title: '级别', width: '110px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'module', title: '模块', width: '150px', render: (record) => <strong>{record.module}</strong> },
{ key: 'operator', title: '操作人', width: '130px', render: (record) => <strong>{record.operator}</strong> },
{ key: 'action', title: '操作', width: '160px', render: (record) => <strong>{record.action}</strong> },
{ key: 'detail', title: '详情', render: (record) => <span className="system-log-detail">{record.detail}</span> },
{ key: 'detail', title: '详情', render: (record) => <span className="system-log-detail">{JSON.stringify(record.detail)}</span> },
{ key: 'ip', title: 'IP地址', width: '150px', render: (record) => <span className="muted">{record.ip}</span> },
], []);
@@ -134,14 +121,14 @@ export function ClientSystemLogsPage() {
</div>
<div className="surface system-table-card">
<Table columns={columns} data={pagedLogs} emptyText="暂无系统日志" rowKey="id" />
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} rowKey="id" />
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredLogs.length}
total={total}
/>
</div>
</section>