feat: wire admin workflows to real APIs
This commit is contained in:
@@ -86,6 +86,27 @@ const channelNames: Record<string, string> = {
|
||||
'67': '联通-行政-上海甲医院-34',
|
||||
};
|
||||
|
||||
const channelCopyStorageKey = 'cmpp-channel-copies';
|
||||
|
||||
type ChannelCopyMeta = {
|
||||
sourceId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function readChannelCopyMeta(): Record<string, ChannelCopyMeta> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(channelCopyStorageKey);
|
||||
return raw ? JSON.parse(raw) as Record<string, ChannelCopyMeta> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getChannelName(channelId: string) {
|
||||
const copyMeta = readChannelCopyMeta()[channelId];
|
||||
return copyMeta?.name ?? channelNames[channelId] ?? `短信通道 ${channelId}`;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '报备成功', value: 'success' },
|
||||
@@ -426,11 +447,12 @@ function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubm
|
||||
export function AdminChannelReportPage() {
|
||||
const navigate = useNavigate();
|
||||
const { channelId = '88827' } = useParams();
|
||||
const channelName = getChannelName(channelId);
|
||||
const [reports, setReports] = useState(initialReports);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['sig-1']));
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [statusTarget, setStatusTarget] = useState<{ signatureId: string; drainageId?: string } | null>(null);
|
||||
const [nextStatus, setNextStatus] = useState<ReportStatus>('success');
|
||||
@@ -493,10 +515,10 @@ export function AdminChannelReportPage() {
|
||||
return (
|
||||
<section className="page-stack channel-report-page">
|
||||
<div className="surface channel-report-hero">
|
||||
<Breadcrumb items={[channelNames[channelId] ?? `短信通道 ${channelId}`]} />
|
||||
<Breadcrumb items={[channelName]} />
|
||||
<div className="channel-report-heading">
|
||||
<Button icon={<ChevronLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">返回列表</Button>
|
||||
<h1>{channelNames[channelId] ?? `短信通道 ${channelId}`}</h1>
|
||||
<h1>{channelName}</h1>
|
||||
<Button icon={<ChevronRight size={16} />} variant="ghost">下一个</Button>
|
||||
<div className="channel-report-config-actions">
|
||||
<Button icon={<FileUp size={16} />} onClick={() => setReceiptOpen(true)} variant="secondary">导入回执</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
@@ -31,6 +32,16 @@ type ChannelModalState = {
|
||||
channel?: SmsChannel;
|
||||
};
|
||||
|
||||
type ChannelConfirmAction = {
|
||||
type: 'toggle' | 'delete' | 'copy';
|
||||
channel: SmsChannel;
|
||||
};
|
||||
|
||||
type ChannelLogState = {
|
||||
channel: SmsChannel;
|
||||
data?: ChannelLinkLogResponse;
|
||||
};
|
||||
|
||||
const carrierOptions = [
|
||||
{ label: '全部运营商', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
@@ -171,6 +182,39 @@ const initialChannels: SmsChannel[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function mapApiChannel(channel: AdminChannel): SmsChannel {
|
||||
const statusMap: Record<string, ChannelStatus> = {
|
||||
active: 'normal',
|
||||
disabled: 'stopped',
|
||||
deleted: 'stopped',
|
||||
connecting: 'connecting',
|
||||
failed: 'failed',
|
||||
};
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' ? channel.carrier : 'mobile',
|
||||
unitPrice: channel.unitPrice,
|
||||
status: statusMap[channel.status] ?? 'normal',
|
||||
total: 0,
|
||||
successRate: 0,
|
||||
successCount: 0,
|
||||
unknownRate: 0,
|
||||
unknownCount: 0,
|
||||
failureRate: 0,
|
||||
failureCount: 0,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: String(channel.gatewayPort),
|
||||
corpCode: channel.enterpriseCode ?? channel.code,
|
||||
account: channel.account,
|
||||
accessNo: channel.srcId,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUiStatusToApi(channel: SmsChannel) {
|
||||
return channel.status === 'stopped' ? 'active' : 'disabled';
|
||||
}
|
||||
|
||||
function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) {
|
||||
return (
|
||||
<div className={`sms-channel-rate sms-channel-rate--${tone}`}>
|
||||
@@ -362,6 +406,14 @@ export function AdminChannelsPage() {
|
||||
const [status, setStatus] = useState('all');
|
||||
const [modal, setModal] = useState<ChannelModalState | null>(null);
|
||||
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
|
||||
const [logState, setLogState] = useState<ChannelLogState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listChannels()
|
||||
.then((items) => setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel)))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const filteredChannels = useMemo(
|
||||
() => channels.filter((channel) => {
|
||||
@@ -381,16 +433,63 @@ export function AdminChannelsPage() {
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
function toggleChannel(id: string) {
|
||||
setChannels((items) => items.map((item) => (
|
||||
item.id === id ? { ...item, status: item.status === 'stopped' ? 'connecting' : 'stopped' } : item
|
||||
)));
|
||||
async function toggleChannel(channel: SmsChannel) {
|
||||
const updated = await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
|
||||
setChannels((items) => items.map((item) => (item.id === channel.id ? mapApiChannel(updated) : item)));
|
||||
}
|
||||
|
||||
function deleteChannel(id: string) {
|
||||
async function deleteChannel(id: string) {
|
||||
await adminApi.deleteChannel(id, '运营端删除通道');
|
||||
setChannels((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
async function copyChannel(channel: SmsChannel) {
|
||||
const copied = await adminApi.copyChannel(channel.id);
|
||||
setChannels((items) => [mapApiChannel(copied), ...items]);
|
||||
}
|
||||
|
||||
async function openLinkLogs(channel: SmsChannel) {
|
||||
setLogState({ channel });
|
||||
const data = await adminApi.listChannelLinkLogs(channel.id);
|
||||
setLogState({ channel, data });
|
||||
}
|
||||
|
||||
function submitConfirmAction() {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'toggle') {
|
||||
void toggleChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'delete') {
|
||||
void deleteChannel(confirmAction.channel.id);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'copy') {
|
||||
void copyChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
const confirmTitle = confirmAction?.type === 'delete'
|
||||
? '确认删除通道'
|
||||
: confirmAction?.type === 'copy'
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
|
||||
const confirmDescription = confirmAction?.type === 'delete'
|
||||
? '删除后该通道将从列表移除,副本通道的本地记录也会同步清理。'
|
||||
: confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入链接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
|
||||
return (
|
||||
<section className="page-stack sms-channel-page">
|
||||
<div className="page-heading">
|
||||
@@ -429,7 +528,12 @@ export function AdminChannelsPage() {
|
||||
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
|
||||
<strong>{channel.unitPrice.toFixed(1)} 分</strong>
|
||||
</div>
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<div className="sms-channel-status-cell">
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<button onClick={() => void openLinkLogs(channel)} type="button">
|
||||
<FileText size={14} />链接日志
|
||||
</button>
|
||||
</div>
|
||||
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
||||
<div className="sms-channel-quality">
|
||||
<RateBlock count={channel.successCount} label="成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
|
||||
@@ -438,14 +542,15 @@ export function AdminChannelsPage() {
|
||||
</div>
|
||||
<div className="sms-channel-actions">
|
||||
<button className="sms-channel-report-entry" onClick={() => navigate(`/admin/channels/${channel.id}/reports`)} type="button">
|
||||
<Eye size={15} />通道报备详情
|
||||
<Eye size={15} />报备详情
|
||||
</button>
|
||||
<button onClick={() => setModal({ mode: 'edit', channel })} type="button"><Pencil size={15} />编辑</button>
|
||||
<button onClick={() => setConfirmAction({ type: 'copy', channel })} type="button"><Copy size={15} />复制通道</button>
|
||||
<button onClick={() => setTestChannel(channel)} type="button"><Send size={15} />发送测试</button>
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => toggleChannel(channel.id)} type="button">
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => setConfirmAction({ type: 'toggle', channel })} type="button">
|
||||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||||
</button>
|
||||
<button className="is-danger" onClick={() => deleteChannel(channel.id)} type="button"><Trash2 size={15} />删除</button>
|
||||
<button className="is-danger" onClick={() => setConfirmAction({ type: 'delete', channel })} type="button"><Trash2 size={15} />删除</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -472,6 +577,53 @@ export function AdminChannelsPage() {
|
||||
onClose={() => setTestChannel(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={submitConfirmAction} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setConfirmAction(null)}
|
||||
open
|
||||
title={confirmTitle}
|
||||
>
|
||||
<div className="channel-confirm">
|
||||
<strong>{confirmAction.channel.name}</strong>
|
||||
<span>通道 ID:{confirmAction.channel.id}</span>
|
||||
<p>{confirmDescription}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{logState ? (
|
||||
<Modal
|
||||
footer={<Button onClick={() => setLogState(null)} variant="ghost">关闭</Button>}
|
||||
onClose={() => setLogState(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>链接日志</h2><p>{logState.channel.name}</p></div>}
|
||||
>
|
||||
<div className="channel-log-list">
|
||||
{(logState.data?.logs ?? []).map((log) => (
|
||||
<article className="channel-log-item" key={log.id}>
|
||||
<div>
|
||||
<strong>{log.event}</strong>
|
||||
<span>{new Date(log.time).toLocaleString('zh-CN', { hour12: false })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{log.resourceId}</span>
|
||||
<p>{typeof log.detail === 'string' ? log.detail : JSON.stringify(log.detail ?? {})}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{logState.data && logState.data.logs.length === 0 ? <p className="muted">暂无链接日志</p> : null}
|
||||
{!logState.data ? <p className="muted">正在加载链接日志...</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,13 +90,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
onClick={() => navigate(`${basePath}/${record.id}`)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(`${basePath}/${record.id}/edit`)}
|
||||
size="sm"
|
||||
|
||||
@@ -148,11 +148,11 @@ export function AdminDrainageFieldsPage() {
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="admin-drainage-actions">
|
||||
<Button icon={<Pencil size={17} />} iconOnly onClick={() => setEditingField(record)} variant="ghost">编辑</Button>
|
||||
<Button icon={<Pencil size={17} />} onClick={() => setEditingField(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={17} />}
|
||||
iconOnly
|
||||
onClick={() => setFields((current) => current.filter((item) => item.id !== record.id))}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
删除
|
||||
@@ -166,8 +166,8 @@ export function AdminDrainageFieldsPage() {
|
||||
<section className="page-stack admin-system-page admin-drainage-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['基础配置', '引流信息报备字段库']} />
|
||||
<h1>引流信息报备字段库</h1>
|
||||
<Breadcrumb items={['基础配置', '报备字段库']} />
|
||||
<h1>报备字段库</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Edit3, Plus, Search, Trash2 } from 'lucide-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';
|
||||
|
||||
@@ -13,18 +13,74 @@ type SmsApp = {
|
||||
deliveryRate: number;
|
||||
unitPrice: number;
|
||||
cmppStatus: 'connected' | 'disconnected' | 'inactive';
|
||||
cmppConnections: CmppConnection[];
|
||||
cmppParams: CmppParams;
|
||||
};
|
||||
|
||||
type MmsApp = Omit<SmsApp, 'cmppStatus'> & {
|
||||
type CmppParams = {
|
||||
host: string;
|
||||
port: number;
|
||||
enterpriseCode: string;
|
||||
account: string;
|
||||
password: string;
|
||||
accessNumber: string;
|
||||
maxConnections: number;
|
||||
heartbeatSeconds: number;
|
||||
windowSize: number;
|
||||
protocolVersion: string;
|
||||
};
|
||||
|
||||
type CmppConnection = {
|
||||
id: string;
|
||||
state: 'open' | 'closed' | 'reconnecting';
|
||||
bindType: 'transceiver' | 'submitter' | 'receiver';
|
||||
clientIp: string;
|
||||
sourceAddr: string;
|
||||
establishedAt: string;
|
||||
lastHeartbeatAt: string;
|
||||
lastSubmitAt: string;
|
||||
pendingWindow: number;
|
||||
};
|
||||
|
||||
type MmsApp = Omit<SmsApp, 'cmppStatus' | 'cmppConnections' | 'cmppParams'> & {
|
||||
pointPrice: number;
|
||||
};
|
||||
|
||||
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' },
|
||||
{ id: 'app-2', name: '客户服务系统', enterprise: '重庆进载数智', appId: 'AK_2024010987654321', enabled: true, sentToday: 800, deliveryRate: 90, unitPrice: 0.06, cmppStatus: 'disconnected' },
|
||||
{ id: 'app-3', name: '验证码服务', enterprise: '超感世纪互三网', appId: 'AK_2024010811223344', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.04, cmppStatus: 'inactive' },
|
||||
{
|
||||
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[] = [
|
||||
@@ -55,11 +111,136 @@ function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: strin
|
||||
);
|
||||
}
|
||||
|
||||
const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone: 'success' | 'warning' | 'neutral' }> = {
|
||||
open: { label: '已连接', tone: 'success' },
|
||||
closed: { label: '已断开', tone: 'neutral' },
|
||||
reconnecting: { label: '重连中', tone: 'warning' },
|
||||
};
|
||||
|
||||
function formatCmppParams(app: SmsApp) {
|
||||
const { cmppParams } = app;
|
||||
return [
|
||||
`应用名称: ${app.name}`,
|
||||
`企业名称: ${app.enterprise}`,
|
||||
`AppID: ${app.appId}`,
|
||||
`CMPP网关地址: ${cmppParams.host}`,
|
||||
`CMPP网关端口: ${cmppParams.port}`,
|
||||
`企业代码: ${cmppParams.enterpriseCode}`,
|
||||
`接口账号: ${cmppParams.account}`,
|
||||
`接口密码: ${cmppParams.password}`,
|
||||
`接入号: ${cmppParams.accessNumber}`,
|
||||
`最大连接数: ${cmppParams.maxConnections}`,
|
||||
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
||||
`提交窗口: ${cmppParams.windowSize}`,
|
||||
`协议版本: ${cmppParams.protocolVersion}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function CmppParamsModal({ app, onClose }: { app: SmsApp; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const paramsText = formatCmppParams(app);
|
||||
|
||||
async function copyParams() {
|
||||
await navigator.clipboard.writeText(paramsText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
<Button icon={<Copy size={15} />} onClick={copyParams}>{copied ? '已复制' : '一键复制'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<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>
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CmppConnectionModal({
|
||||
app,
|
||||
onClose,
|
||||
onDeleteConnection,
|
||||
}: {
|
||||
app: SmsApp;
|
||||
onClose: () => void;
|
||||
onDeleteConnection: (connectionId: string) => void;
|
||||
}) {
|
||||
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>CMPP连接详情</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-connection-detail">
|
||||
<div className="cmpp-connection-summary">
|
||||
<div><span>当前连接数</span><strong>{activeConnections}</strong></div>
|
||||
<div><span>配置连接数</span><strong>{Math.max(activeConnections, app.cmppConnections.length)}</strong></div>
|
||||
<div><span>AppID</span><strong>{app.appId}</strong></div>
|
||||
<div><span>连接状态</span><Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}</Tag></div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'id', title: '连接ID', width: '150px', render: (record: CmppConnection) => <strong>{record.id}</strong> },
|
||||
{ key: 'state', title: '状态', width: '100px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
|
||||
{ key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType },
|
||||
{ key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp },
|
||||
{ key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr },
|
||||
{ key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt },
|
||||
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
|
||||
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
|
||||
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '100px', render: (record: CmppConnection) => record.pendingWindow },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
render: (record: CmppConnection) => (
|
||||
<Button icon={<Trash2 size={14} />} onClick={() => onDeleteConnection(record.id)} size="sm" variant="danger">删除</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={app.cmppConnections}
|
||||
emptyText="暂无CMPP连接"
|
||||
rowKey="id"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEnterpriseApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [smsApps, setSmsApps] = useState(initialSmsApps);
|
||||
const [mmsApps, setMmsApps] = useState(initialMmsApps);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
|
||||
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<
|
||||
| { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean }
|
||||
| { action: 'delete'; kind: AppKind; id: string; name: string }
|
||||
@@ -95,6 +276,26 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
function deleteConnection(appId: string, connectionId: string) {
|
||||
let nextConnectionApp: SmsApp | null = null;
|
||||
setSmsApps((current) => current.map((app) => {
|
||||
if (app.id !== appId) {
|
||||
return app;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const filteredSmsApps = useMemo(
|
||||
() => smsApps.filter((item) => !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword)),
|
||||
[enterpriseKeyword, smsApps],
|
||||
@@ -115,11 +316,20 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
{
|
||||
key: 'cmppStatus',
|
||||
title: 'CMPP状态',
|
||||
width: '130px',
|
||||
width: '230px',
|
||||
render: (record) => (
|
||||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||
</Tag>
|
||||
<div className="cmpp-status-cell">
|
||||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||
</Tag>
|
||||
<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">
|
||||
<Settings2 size={13} />
|
||||
参数
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) },
|
||||
@@ -206,6 +416,14 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
onConfirm={runConfirmedAction}
|
||||
/>
|
||||
) : null}
|
||||
{connectionApp ? (
|
||||
<CmppConnectionModal
|
||||
app={connectionApp}
|
||||
onClose={() => setConnectionApp(null)}
|
||||
onDeleteConnection={(connectionId) => deleteConnection(connectionApp.id, connectionId)}
|
||||
/>
|
||||
) : null}
|
||||
{paramsApp ? <CmppParamsModal app={paramsApp} onClose={() => setParamsApp(null)} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, FileSearch, Search, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
@@ -8,9 +9,18 @@ type EnterpriseAuditRecord = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
creditCode: string;
|
||||
legalPerson: string;
|
||||
registeredAddress: string;
|
||||
businessLicense: string;
|
||||
bankAccountName: string;
|
||||
bankName: string;
|
||||
bankAccountNo: string;
|
||||
verificationAmount: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
submittedAt: string;
|
||||
reviewRemark: string;
|
||||
status: EnterpriseAuditStatus;
|
||||
};
|
||||
|
||||
@@ -34,16 +44,45 @@ const statusToneMap: Record<EnterpriseAuditStatus, 'warning' | 'success' | 'dang
|
||||
};
|
||||
|
||||
const initialEnterpriseAudits: EnterpriseAuditRecord[] = [
|
||||
{ id: 'ENT-20260319-001', companyName: '北京星云科技有限公司', creditCode: '91110000X12345678A', contactName: '张伟', contactPhone: '13800138000', submittedAt: '2026-03-19 10:23:45', status: 'pending' },
|
||||
{ id: 'ENT-20260319-002', companyName: '上海蓝海科技有限公司', creditCode: '91310000X87654321B', contactName: '李娜', contactPhone: '13900139000', submittedAt: '2026-03-18 15:45:12', status: 'pending' },
|
||||
{ id: 'ENT-20260318-001', companyName: '广州飞跃文化传媒有限公司', creditCode: '91440100X11223344C', contactName: '王强', contactPhone: '13700137000', submittedAt: '2026-03-17 09:12:30', status: 'approved' },
|
||||
{ id: 'ENT-20260317-001', companyName: '深圳前海贸易有限公司', creditCode: '91440300X55667788D', contactName: '陈杰', contactPhone: '13600136000', submittedAt: '2026-03-16 11:30:22', status: 'rejected' },
|
||||
{ id: 'ENT-20260319-001', companyName: '北京星云科技有限公司', creditCode: '91110000X12345678A', legalPerson: '赵明', registeredAddress: '北京市朝阳区望京东路 88 号', businessLicense: 'business-license-20260319-001.pdf', bankAccountName: '北京星云科技有限公司', bankName: '招商银行北京望京支行', bankAccountNo: '6214 **** **** 1028', verificationAmount: '0.23 元', contactName: '张伟', contactPhone: '13800138000', contactEmail: 'zhangwei@nebula.example.com', submittedAt: '2026-03-19 10:23:45', reviewRemark: '待核验营业执照与对公打款流水。', status: 'pending' },
|
||||
{ id: 'ENT-20260319-002', companyName: '上海蓝海科技有限公司', creditCode: '91310000X87654321B', legalPerson: '周海', registeredAddress: '上海市浦东新区张江路 66 号', businessLicense: 'business-license-20260319-002.pdf', bankAccountName: '上海蓝海科技有限公司', bankName: '建设银行上海张江支行', bankAccountNo: '6227 **** **** 3319', verificationAmount: '0.18 元', contactName: '李娜', contactPhone: '13900139000', contactEmail: 'lina@blueocean.example.com', submittedAt: '2026-03-18 15:45:12', reviewRemark: '联系人授权书已上传,等待人工复核。', status: 'pending' },
|
||||
{ id: 'ENT-20260318-001', companyName: '广州飞跃文化传媒有限公司', creditCode: '91440100X11223344C', legalPerson: '黄杰', registeredAddress: '广州市天河区体育西路 118 号', businessLicense: 'business-license-20260318-001.pdf', bankAccountName: '广州飞跃文化传媒有限公司', bankName: '工商银行广州天河支行', bankAccountNo: '6202 **** **** 7750', verificationAmount: '0.31 元', contactName: '王强', contactPhone: '13700137000', contactEmail: 'wangqiang@feiyue.example.com', submittedAt: '2026-03-17 09:12:30', reviewRemark: '资料一致,对公验证通过。', status: 'approved' },
|
||||
{ id: 'ENT-20260317-001', companyName: '深圳前海贸易有限公司', creditCode: '91440300X55667788D', legalPerson: '林越', registeredAddress: '深圳市前海深港合作区梦海大道 1 号', businessLicense: 'business-license-20260317-001.pdf', bankAccountName: '深圳前海贸易有限公司', bankName: '中国银行深圳前海支行', bankAccountNo: '6216 **** **** 8901', verificationAmount: '0.12 元', contactName: '陈杰', contactPhone: '13600136000', contactEmail: 'chenjie@qianhai.example.com', submittedAt: '2026-03-16 11:30:22', reviewRemark: '营业执照主体与对公账户户名不一致,请重新提交。', status: 'rejected' },
|
||||
];
|
||||
|
||||
function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecord {
|
||||
const materials = record.materials ?? {};
|
||||
return {
|
||||
id: record.id,
|
||||
companyName: record.companyName,
|
||||
creditCode: record.licenseNo ?? '',
|
||||
legalPerson: String(materials.legalPerson ?? ''),
|
||||
registeredAddress: String(materials.registeredAddress ?? ''),
|
||||
businessLicense: String(materials.businessLicense ?? ''),
|
||||
bankAccountName: String(materials.bankAccountName ?? record.companyName),
|
||||
bankName: String(materials.bankName ?? ''),
|
||||
bankAccountNo: String(materials.bankAccountNo ?? ''),
|
||||
verificationAmount: String(materials.verificationAmount ?? ''),
|
||||
contactName: record.contactName ?? '',
|
||||
contactPhone: record.contactPhone ?? '',
|
||||
contactEmail: String(materials.contactEmail ?? ''),
|
||||
submittedAt: new Date(record.submittedAt).toLocaleString('zh-CN', { hour12: false }),
|
||||
reviewRemark: record.rejectReason ?? String(materials.reviewRemark ?? ''),
|
||||
status: record.status as EnterpriseAuditStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminEnterpriseAuditPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [records, setRecords] = useState(initialEnterpriseAudits);
|
||||
const [detailRecord, setDetailRecord] = useState<EnterpriseAuditRecord | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listEnterpriseCertifications({ keyword, status })
|
||||
.then((items) => setRecords(items.map(mapCertification)))
|
||||
.catch(() => undefined);
|
||||
}, [keyword, status]);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
@@ -54,8 +93,13 @@ export function AdminEnterpriseAuditPage() {
|
||||
[keyword, records, status],
|
||||
);
|
||||
|
||||
function updateStatus(id: string, nextStatus: EnterpriseAuditStatus) {
|
||||
setRecords((items) => items.map((item) => (item.id === id ? { ...item, status: nextStatus } : item)));
|
||||
async function updateStatus(id: string, nextStatus: EnterpriseAuditStatus) {
|
||||
const updated = nextStatus === 'approved'
|
||||
? await adminApi.approveEnterpriseCertification(id)
|
||||
: await adminApi.rejectEnterpriseCertification(id);
|
||||
const mapped = mapCertification(updated);
|
||||
setRecords((items) => items.map((item) => (item.id === id ? mapped : item)));
|
||||
setDetailRecord((current) => (current?.id === id ? mapped : current));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<EnterpriseAuditRecord>> = [
|
||||
@@ -78,11 +122,11 @@ export function AdminEnterpriseAuditPage() {
|
||||
<div className="audit-actions">
|
||||
{record.status === 'pending' ? (
|
||||
<>
|
||||
<button className="audit-link audit-link--success" onClick={() => updateStatus(record.id, 'approved')} type="button">通过</button>
|
||||
<button className="audit-link audit-link--danger" onClick={() => updateStatus(record.id, 'rejected')} type="button">拒绝</button>
|
||||
<button className="audit-link audit-link--success" onClick={() => void updateStatus(record.id, 'approved')} type="button">通过</button>
|
||||
<button className="audit-link audit-link--danger" onClick={() => void updateStatus(record.id, 'rejected')} type="button">拒绝</button>
|
||||
</>
|
||||
) : null}
|
||||
<button className="audit-link" type="button">详情</button>
|
||||
<button className="audit-link" onClick={() => setDetailRecord(record)} type="button">详情</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -110,6 +154,53 @@ export function AdminEnterpriseAuditPage() {
|
||||
<Button disabled icon={<X size={15} />} size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailRecord ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setDetailRecord(null)} variant="ghost">关闭</Button>
|
||||
{detailRecord.status === 'pending' ? (
|
||||
<>
|
||||
<Button onClick={() => void updateStatus(detailRecord.id, 'rejected')} variant="danger">驳回认证</Button>
|
||||
<Button onClick={() => void updateStatus(detailRecord.id, 'approved')}>审核通过</Button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
onClose={() => setDetailRecord(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>企业认证详情</h2><p>{detailRecord.id}</p></div>}
|
||||
>
|
||||
<div className="enterprise-audit-detail">
|
||||
<section>
|
||||
<h3>主体资料</h3>
|
||||
<div><span>企业名称</span><strong>{detailRecord.companyName}</strong></div>
|
||||
<div><span>统一社会信用代码</span><strong>{detailRecord.creditCode}</strong></div>
|
||||
<div><span>法定代表人</span><strong>{detailRecord.legalPerson}</strong></div>
|
||||
<div><span>注册地址</span><strong>{detailRecord.registeredAddress}</strong></div>
|
||||
<div><span>营业执照附件</span><strong>{detailRecord.businessLicense}</strong></div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>对公验证</h3>
|
||||
<div><span>账户户名</span><strong>{detailRecord.bankAccountName}</strong></div>
|
||||
<div><span>开户银行</span><strong>{detailRecord.bankName}</strong></div>
|
||||
<div><span>银行账号</span><strong>{detailRecord.bankAccountNo}</strong></div>
|
||||
<div><span>验证金额</span><strong>{detailRecord.verificationAmount}</strong></div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>联系人与审核</h3>
|
||||
<div><span>联系人</span><strong>{detailRecord.contactName}</strong></div>
|
||||
<div><span>联系电话</span><strong>{detailRecord.contactPhone}</strong></div>
|
||||
<div><span>联系邮箱</span><strong>{detailRecord.contactEmail}</strong></div>
|
||||
<div><span>提交时间</span><strong>{detailRecord.submittedAt}</strong></div>
|
||||
<div><span>当前状态</span><strong>{statusTextMap[detailRecord.status]}</strong></div>
|
||||
<div className="enterprise-audit-detail__remark"><span>审核备注</span><strong>{detailRecord.reviewRemark}</strong></div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, type TableColumn } from '@/components/ui';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type EnterpriseBlacklistItem = {
|
||||
id: string;
|
||||
@@ -19,8 +19,24 @@ const initialItems: EnterpriseBlacklistItem[] = [
|
||||
{ id: 'EBL20260630004', enterprise: '重庆香惠慧', application: '客服应用', phone: '18800000555', createdAt: '2026-06-24 18:01:10', reason: '敏感投诉号码', expiredAt: '2026-07-24 23:59:59' },
|
||||
];
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function AdminEnterpriseBlacklistPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [enterprise, setEnterprise] = useState('');
|
||||
const [application, setApplication] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [expiredAt, setExpiredAt] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const filteredItems = useMemo(() => items.filter((item) => {
|
||||
const text = [item.enterprise, item.application, item.phone, item.reason].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, keyword]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [
|
||||
{ key: 'enterprise', title: '企业名称', width: '180px', render: (record) => <strong>{record.enterprise}</strong> },
|
||||
@@ -42,6 +58,29 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
},
|
||||
], []);
|
||||
|
||||
function resetForm() {
|
||||
setEnterprise('');
|
||||
setApplication('');
|
||||
setPhone('');
|
||||
setReason('');
|
||||
setExpiredAt('');
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const nextItem: EnterpriseBlacklistItem = {
|
||||
id: `EBL${Date.now()}`,
|
||||
enterprise: enterprise || '未命名企业',
|
||||
application: application || '默认应用',
|
||||
phone: phone || '待补充号码',
|
||||
createdAt: nowText(),
|
||||
reason: reason || '运营手动加入',
|
||||
expiredAt: expiredAt || '永久有效',
|
||||
};
|
||||
setItems((current) => [nextItem, ...current]);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
@@ -49,11 +88,46 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
<Breadcrumb items={['安全控制', '企业黑名单']} />
|
||||
<h1>企业黑名单</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>添加黑名单</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-filter">
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索企业、应用、手机号或原因"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无企业黑名单记录" rowKey="id" />
|
||||
<Table columns={columns} data={filteredItems} emptyText="暂无企业黑名单记录" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button onClick={addItem}>确认添加</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="添加企业黑名单"
|
||||
>
|
||||
<div className="admin-security-form">
|
||||
<Input label="企业名称" onChange={(event) => setEnterprise(event.target.value)} placeholder="请输入企业名称" value={enterprise} />
|
||||
<Input label="应用名称" onChange={(event) => setApplication(event.target.value)} placeholder="请输入应用名称" value={application} />
|
||||
<Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />
|
||||
<Input label="过期时间" onChange={(event) => setExpiredAt(event.target.value)} placeholder="例如 2026-12-31 23:59:59" value={expiredAt} />
|
||||
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const [activeTab, setActiveTab] = useState<SignatureKind>('sms');
|
||||
const [smsSignatures, setSmsSignatures] = useState(initialSmsSignatures);
|
||||
const [mmsSignatures, setMmsSignatures] = useState(initialMmsSignatures);
|
||||
const [expandedSignatureId, setExpandedSignatureId] = useState('sig-1');
|
||||
const [expandedSignatureId, setExpandedSignatureId] = useState('');
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [signatureKeyword, setSignatureKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -491,7 +491,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<div className="signature-actions">
|
||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal({ kind: 'sms', item: signature })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', signatureKind: 'sms', id: signature.id, name: signature.name })} size="sm" variant="ghost">删除</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', signatureKind: 'sms', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -518,9 +518,9 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<StatusTag status={item.telecom} />
|
||||
<span className="muted">{item.submittedAt}</span>
|
||||
<span className="drainage-row-actions">
|
||||
<button onClick={() => setDrainageReport(item)} type="button">报备详情</button>
|
||||
<button onClick={() => setDrainageModal({ signatureId: signature.id, item })} type="button">编辑</button>
|
||||
<button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} type="button">删除</button>
|
||||
<Button onClick={() => setDrainageReport(item)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} size="sm" variant="danger">删除</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, type TableColumn } from '@/components/ui';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type GlobalBlacklistItem = {
|
||||
id: string;
|
||||
@@ -17,8 +17,22 @@ const initialItems: GlobalBlacklistItem[] = [
|
||||
{ id: 'GBL20260630004', phone: '15250668026', createdAt: '2026-06-25 14:12:18', reason: '高频退订', expiredAt: '2026-08-25 23:59:59' },
|
||||
];
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function AdminGlobalBlacklistPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [expiredAt, setExpiredAt] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const filteredItems = useMemo(() => items.filter((item) => {
|
||||
const text = [item.phone, item.reason, item.expiredAt].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, keyword]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<GlobalBlacklistItem>>>(() => [
|
||||
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
|
||||
@@ -38,6 +52,25 @@ export function AdminGlobalBlacklistPage() {
|
||||
},
|
||||
], []);
|
||||
|
||||
function resetForm() {
|
||||
setPhone('');
|
||||
setReason('');
|
||||
setExpiredAt('');
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const nextItem: GlobalBlacklistItem = {
|
||||
id: `GBL${Date.now()}`,
|
||||
phone: phone || '待补充号码',
|
||||
createdAt: nowText(),
|
||||
reason: reason || '运营手动加入',
|
||||
expiredAt: expiredAt || '永久有效',
|
||||
};
|
||||
setItems((current) => [nextItem, ...current]);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
@@ -45,11 +78,44 @@ export function AdminGlobalBlacklistPage() {
|
||||
<Breadcrumb items={['安全控制', '全局黑名单']} />
|
||||
<h1>全局黑名单</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>添加黑名单</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-filter">
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索手机号、原因或过期时间"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无全局黑名单记录" rowKey="id" />
|
||||
<Table columns={columns} data={filteredItems} emptyText="暂无全局黑名单记录" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button onClick={addItem}>确认添加</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="添加全局黑名单"
|
||||
>
|
||||
<div className="admin-security-form">
|
||||
<Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />
|
||||
<Input label="过期时间" onChange={(event) => setExpiredAt(event.target.value)} placeholder="例如 2026-12-31 23:59:59" value={expiredAt} />
|
||||
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,14 @@ function formatAmount(value?: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function RemarkCell({ value }: { value?: string }) {
|
||||
return (
|
||||
<div className={['admin-remark-cell', value ? '' : 'admin-remark-cell--empty'].filter(Boolean).join(' ')}>
|
||||
{value || '暂无备注'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminRechargeRecordsPage() {
|
||||
const [records, setRecords] = useState(rechargeRecordsSeed);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
@@ -116,13 +124,13 @@ export function AdminRechargeRecordsPage() {
|
||||
<table className="ui-table admin-recharge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>企业名称</th>
|
||||
<th>充值时间</th>
|
||||
<th>充值金额</th>
|
||||
<th>充值后余额</th>
|
||||
<th>充值类型</th>
|
||||
<th>操作人</th>
|
||||
<th>备注</th>
|
||||
<th style={{ width: '240px' }}>企业名称</th>
|
||||
<th style={{ width: '180px' }}>充值时间</th>
|
||||
<th style={{ width: '130px' }}>充值金额</th>
|
||||
<th style={{ width: '140px' }}>充值后余额</th>
|
||||
<th style={{ width: '120px' }}>充值类型</th>
|
||||
<th style={{ width: '110px' }}>操作人</th>
|
||||
<th style={{ width: '300px' }}>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -134,7 +142,7 @@ export function AdminRechargeRecordsPage() {
|
||||
<td>{formatAmount(record.balance)}</td>
|
||||
<td><Tag tone={record.type === 'manual' ? 'warning' : 'info'}>{record.type === 'manual' ? '人工充值' : '套餐充值'}</Tag></td>
|
||||
<td>{record.operator}</td>
|
||||
<td>{record.remark ?? '-'}</td>
|
||||
<td><RemarkCell value={record.remark} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -52,6 +52,14 @@ const initialRecords: ReportRecord[] = [
|
||||
{ id: 'RPT-REC-004', taskId: 'RPT-TASK-20260630-001', channel: '行北-集市三甲医院-39', enterprise: '上海XXXXX科技有限公司', type: '签名', content: '【科技公司】', carrier: '移动', status: 'unreported', submittedAt: '2026-06-30 09:12:00', updatedAt: '2026-06-30 09:12:00' },
|
||||
];
|
||||
|
||||
function RemarkCell({ value }: { value?: string }) {
|
||||
return (
|
||||
<div className={['admin-remark-cell', value ? '' : 'admin-remark-cell--empty'].filter(Boolean).join(' ')}>
|
||||
{value || '暂无备注'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose: () => void }) {
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>报备记录详情</h2><p>{record.id}</p></div>}>
|
||||
@@ -102,8 +110,8 @@ export function AdminReportRecordsPage() {
|
||||
{ key: 'carrier', title: '运营商', width: '90px', render: (record) => <Tag tone="info">{record.carrier}</Tag> },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusMeta[record.status].tone}>{statusMeta[record.status].label}</Tag> },
|
||||
{ key: 'time', title: '时间', width: '190px', render: (record) => <div className="report-task-time"><span>提交 {record.submittedAt}</span><span>回执 {record.reportedAt ?? '-'}</span></div> },
|
||||
{ key: 'reason', title: '备注', render: (record) => record.reason ?? '-' },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: '110px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
{ key: 'reason', title: '备注', width: '280px', render: (record) => <RemarkCell value={record.reason} /> },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -93,6 +93,14 @@ const initialTasks: ReportTask[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function RemarkCell({ value }: { value?: string }) {
|
||||
return (
|
||||
<div className={['admin-remark-cell', value ? '' : 'admin-remark-cell--empty'].filter(Boolean).join(' ')}>
|
||||
{value || '暂无备注'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: ReportTaskStatus }) {
|
||||
return <Tag tone={statusMeta[status].tone}>{statusMeta[status].label}</Tag>;
|
||||
}
|
||||
@@ -252,11 +260,12 @@ export function AdminReportTasksPage() {
|
||||
{ key: 'counts', title: '资料数量', width: '190px', render: (record) => <div className="admin-task-counts"><span>签名 {record.signatureCount}</span><span>引流 {record.drainageCount}</span><strong>缺 {record.missingCount}</strong></div> },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <StatusTag status={record.status} /> },
|
||||
{ key: 'time', title: '流转时间', width: '210px', render: (record) => <div className="report-task-time"><span>创建 {record.createdAt}</span><span>导出 {record.exportedAt ?? '-'}</span><span>回执 {record.receiptAt ?? '-'}</span></div> },
|
||||
{ key: 'remark', title: '备注', width: '300px', render: (record) => <RemarkCell value={record.remark} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '260px',
|
||||
width: '280px',
|
||||
render: (record) => (
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type SensitiveLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
@@ -32,8 +32,28 @@ const initialItems: SensitiveWordItem[] = [
|
||||
{ id: 'SW20260630004', word: '免费领取', category: '普通营销', level: 'low', createdAt: '2026-06-17 17:06:51', updatedAt: '2026-06-21 09:05:14' },
|
||||
];
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '中', value: 'medium' },
|
||||
{ label: '高', value: 'high' },
|
||||
];
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function AdminSensitiveWordsPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [word, setWord] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [level, setLevel] = useState<SensitiveLevel>('medium');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const filteredItems = useMemo(() => items.filter((item) => {
|
||||
const text = [item.word, item.category, levelLabelMap[item.level]].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, keyword]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
|
||||
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
|
||||
@@ -54,6 +74,27 @@ export function AdminSensitiveWordsPage() {
|
||||
},
|
||||
], []);
|
||||
|
||||
function resetForm() {
|
||||
setWord('');
|
||||
setCategory('');
|
||||
setLevel('medium');
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const time = nowText();
|
||||
const nextItem: SensitiveWordItem = {
|
||||
id: `SW${Date.now()}`,
|
||||
word: word || '待补充敏感词',
|
||||
category: category || '未分类',
|
||||
level,
|
||||
createdAt: time,
|
||||
updatedAt: time,
|
||||
};
|
||||
setItems((current) => [nextItem, ...current]);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
@@ -61,11 +102,49 @@ export function AdminSensitiveWordsPage() {
|
||||
<Breadcrumb items={['安全控制', '敏感词管理']} />
|
||||
<h1>敏感词管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>添加敏感词</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-filter">
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索敏感词、分类或级别"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无敏感词记录" rowKey="id" />
|
||||
<Table columns={columns} data={filteredItems} emptyText="暂无敏感词记录" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button onClick={addItem}>确认添加</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="添加敏感词"
|
||||
>
|
||||
<div className="admin-security-form">
|
||||
<Input label="敏感词" onChange={(event) => setWord(event.target.value)} placeholder="请输入敏感词" value={word} />
|
||||
<Input label="分类" onChange={(event) => setCategory(event.target.value)} placeholder="请输入分类" value={category} />
|
||||
<Select
|
||||
label="风险级别"
|
||||
onChange={(event) => setLevel(event.target.value as SensitiveLevel)}
|
||||
options={levelOptions}
|
||||
value={level}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
function goBack() {
|
||||
navigate(`/admin/customers/${enterpriseId ?? ''}`);
|
||||
navigate('/admin/enterprise-applications');
|
||||
}
|
||||
|
||||
function submit() {
|
||||
@@ -81,7 +81,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
<p>{enterprise?.name ?? '当前企业'} 的短信应用配置。</p>
|
||||
</div>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
|
||||
返回企业详情
|
||||
返回企业应用管理
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -408,6 +408,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
|
||||
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasks.map((item) => item.enterprise)));
|
||||
@@ -443,6 +444,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
setTasks((current) => current.map((task) => (
|
||||
task.id === taskId ? { ...task, status: 'terminated' } : task
|
||||
)));
|
||||
setTerminateTarget(null);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -485,7 +487,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
<th style={{ width: '150px' }}>发送方式</th>
|
||||
<th style={{ width: '190px' }}>进度</th>
|
||||
<th style={{ width: '100px' }}>状态</th>
|
||||
<th style={{ textAlign: 'right', width: '120px' }}>操作</th>
|
||||
<th style={{ textAlign: 'right', width: '170px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -542,12 +544,11 @@ export function AdminSmsTaskProgressPage() {
|
||||
<td><Tag tone={statusTones[record.status]}>{statusLabels[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={15} />} iconOnly onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button icon={<Eye size={15} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'sending'}
|
||||
icon={<StopCircle size={15} />}
|
||||
iconOnly
|
||||
onClick={() => terminateTask(record.id)}
|
||||
onClick={() => setTerminateTarget(record)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -577,6 +578,23 @@ export function AdminSmsTaskProgressPage() {
|
||||
</div>
|
||||
|
||||
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
|
||||
{terminateTarget ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setTerminateTarget(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={() => terminateTask(terminateTarget.id)} variant="danger">确认终止</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setTerminateTarget(null)}
|
||||
open
|
||||
title="确认终止短信任务"
|
||||
>
|
||||
<div className="admin-confirm-text">
|
||||
确认终止任务 <strong>{terminateTarget.id}</strong> 吗?终止后将停止继续提交未发送号码,已提交部分仍以运营商回执为准。
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
@@ -44,6 +44,8 @@ export function AdminSystemLogsPage() {
|
||||
const [level, setLevel] = useState('all');
|
||||
const [module, setModule] = useState('all');
|
||||
const [range, setRange] = useState('today');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 5;
|
||||
|
||||
const moduleOptions = useMemo(() => {
|
||||
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
|
||||
@@ -57,6 +59,9 @@ export function AdminSystemLogsPage() {
|
||||
const matchesModule = module === 'all' || item.module === module;
|
||||
return matchesKeyword && matchesLevel && matchesModule;
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / 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> },
|
||||
@@ -66,7 +71,18 @@ export function AdminSystemLogsPage() {
|
||||
{ key: 'operator', title: '操作人', width: '120px', render: (record) => <strong>{record.operator}</strong> },
|
||||
{ key: 'action', title: '动作', width: '140px', render: (record) => record.action },
|
||||
{ key: 'resourceId', title: '资源ID', width: '150px', render: (record) => <span className="muted">{record.resourceId}</span> },
|
||||
{ key: 'detail', title: '详情', render: (record) => <span className="system-log-detail">{record.detail}</span> },
|
||||
{
|
||||
key: 'detail',
|
||||
title: '详情',
|
||||
width: '320px',
|
||||
render: (record) => (
|
||||
<div className="system-log-detail-card">
|
||||
<strong>{record.action}</strong>
|
||||
<span>{record.detail}</span>
|
||||
<small>{record.resourceId}</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'ip', title: 'IP', width: '120px', render: (record) => <span className="muted">{record.ip}</span> },
|
||||
], []);
|
||||
|
||||
@@ -75,23 +91,20 @@ export function AdminSystemLogsPage() {
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||
<div>
|
||||
<Breadcrumb items={['系统管理', '系统日志']} />
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<Button icon={<Download size={17} />} variant="secondary">导出日志</Button>
|
||||
</div>
|
||||
|
||||
<div className="system-log-filters">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
|
||||
placeholder="搜索企业、操作人、动作、资源ID或详情"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Select
|
||||
onChange={(event) => setLevel(event.target.value)}
|
||||
onChange={(event) => { setLevel(event.target.value); setPage(1); }}
|
||||
options={[
|
||||
{ label: '全部级别', value: 'all' },
|
||||
{ label: '信息', value: 'info' },
|
||||
@@ -101,7 +114,7 @@ export function AdminSystemLogsPage() {
|
||||
]}
|
||||
value={level}
|
||||
/>
|
||||
<Select onChange={(event) => setModule(event.target.value)} options={moduleOptions} value={module} />
|
||||
<Select onChange={(event) => { setModule(event.target.value); setPage(1); }} options={moduleOptions} value={module} />
|
||||
</div>
|
||||
|
||||
<div className="system-log-range">
|
||||
@@ -124,9 +137,16 @@ export function AdminSystemLogsPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={filteredLogs} emptyText="暂无系统日志" rowKey="id" />
|
||||
<Table columns={columns} data={pagedLogs} emptyText="暂无系统日志" 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}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminService } from '@/mock';
|
||||
import type { AuditItem, AuditStatus } from '@/mock';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Search, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
||||
|
||||
const applicationMap: Record<string, string> = {
|
||||
'AUD-2401': '验证码服务',
|
||||
'AUD-2403': '营销推广平台',
|
||||
};
|
||||
|
||||
const auditStatusLabelMap: Record<AuditStatus, string> = {
|
||||
const auditStatusLabelMap: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
draft: '草稿',
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
];
|
||||
|
||||
export function AdminTemplateAuditPage() {
|
||||
const [audits, setAudits] = useState(() => adminService.getAudits());
|
||||
const columns = useMemo<Array<TableColumn<AuditItem>>>(
|
||||
const [audits, setAudits] = useState<SmsTemplateAudit[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listTemplateAudits({ keyword, status })
|
||||
.then(setAudits)
|
||||
.catch(() => setAudits([]));
|
||||
}, [keyword, status]);
|
||||
|
||||
async function reviewTemplate(id: string, nextStatus: 'approved' | 'rejected') {
|
||||
const updated = nextStatus === 'approved'
|
||||
? await adminApi.approveTemplate(id)
|
||||
: await adminApi.rejectTemplate(id);
|
||||
setAudits((items) => items.map((item) => (item.id === id ? updated : item)));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SmsTemplateAudit>>>(
|
||||
() => [
|
||||
{ key: 'id', title: '审核编号', render: (record) => record.id },
|
||||
{ key: 'customer', title: '客户', render: (record) => record.customer },
|
||||
{ key: 'application', title: '短信应用', render: (record) => applicationMap[record.id] ?? '客户通知服务' },
|
||||
{ key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId },
|
||||
{ key: 'content', title: '短信模板内容', render: (record) => record.content },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => record.submittedAt },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'approved' ? 'success' : record.status === 'rejected' ? 'danger' : 'info'}>
|
||||
{auditStatusLabelMap[record.status]}
|
||||
<Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>
|
||||
{auditStatusLabelMap[record.auditStatus] ?? record.auditStatus}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -40,18 +58,18 @@ export function AdminTemplateAuditPage() {
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
disabled={record.status !== 'pending'}
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<Check size={15} />}
|
||||
onClick={() => setAudits(adminService.updateAuditStatus(record.id, 'approved'))}
|
||||
onClick={() => void reviewTemplate(record.id, 'approved')}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'pending'}
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setAudits(adminService.updateAuditStatus(record.id, 'rejected'))}
|
||||
onClick={() => void reviewTemplate(record.id, 'rejected')}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -63,15 +81,25 @@ export function AdminTemplateAuditPage() {
|
||||
],
|
||||
[],
|
||||
);
|
||||
const templateAudits = audits.filter((item) => item.type === '模板');
|
||||
const templateAudits = audits;
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<section className="page-stack admin-template-audit-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['短信模板审核']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--template">
|
||||
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用、模板内容或审核编号" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={templateAudits} rowKey="id" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user