feat: wire admin workflows to real APIs

This commit is contained in:
hectorzhao
2026-07-02 13:50:09 +08:00
parent f8c9b78c21
commit ab421cf8a7
42 changed files with 2596 additions and 250 deletions
+163 -11
View File
@@ -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>
);
}