fix: harden real backend workflows and channel connections

This commit is contained in:
hectorzhao
2026-07-06 17:54:53 +08:00
parent 8cca361441
commit b5132d7f4e
47 changed files with 2530 additions and 314 deletions
+51 -34
View File
@@ -1,7 +1,7 @@
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 { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
@@ -41,7 +41,7 @@ type ChannelConfirmAction = {
type ChannelLogState = {
channel: SmsChannel;
data?: ChannelLinkLogResponse;
data?: ChannelConnectionLogResponse;
};
const carrierOptions = [
@@ -54,10 +54,10 @@ const carrierOptions = [
const statusOptions = [
{ label: '全部状态', value: 'all' },
{ label: '接正常', value: 'normal' },
{ label: '接正常', value: 'normal' },
{ label: '已停用', value: 'stopped' },
{ label: '接中', value: 'connecting' },
{ label: '接失败', value: 'failed' },
{ label: '接中', value: 'connecting' },
{ label: '接失败', value: 'failed' },
];
const protocolOptions = [
@@ -93,10 +93,10 @@ const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'>
};
const statusLabelMap: Record<ChannelStatus, string> = {
normal: '接正常',
normal: '接正常',
stopped: '已停用',
connecting: '接中',
failed: '接失败',
connecting: '接中',
failed: '接失败',
};
const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
@@ -106,21 +106,31 @@ const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'dan
failed: 'danger',
};
function mapApiChannel(channel: AdminChannel): SmsChannel {
const statusMap: Record<string, ChannelStatus> = {
active: 'normal',
disabled: 'stopped',
deleted: 'stopped',
connecting: 'connecting',
failed: 'failed',
};
function resolveChannelStatus(channel: AdminChannel, connections: CmppConnectionState[] = []): ChannelStatus {
if (channel.status !== 'active') {
return 'stopped';
}
if (connections.some((connection) =>
connection.status === 'connected'
&& connection.currentConnections > 0
&& connection.desiredConnections > 0,
)) {
return 'normal';
}
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(connection.status) || connection.lastError)) {
return 'failed';
}
return 'connecting';
}
function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[] = channel.connectionStates ?? []): SmsChannel {
return {
id: channel.id,
name: channel.name,
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile',
sendRegion: channel.sendRegion ?? '全国',
unitPrice: channel.unitPrice,
status: statusMap[channel.status] ?? 'normal',
status: resolveChannelStatus(channel, connections),
total: 0,
successRate: 0,
successCount: 0,
@@ -353,13 +363,21 @@ export function AdminChannelsPage() {
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
const [logState, setLogState] = useState<ChannelLogState | null>(null);
useEffect(() => {
function loadChannels() {
adminApi.listChannels()
.then((items) => {
setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel));
.then(async (items) => {
const visibleChannels = items.filter((item) => item.status !== 'deleted');
const connections = await Promise.all(visibleChannels.map((channel) =>
adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]),
));
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index])));
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
}
useEffect(() => {
loadChannels();
}, []);
const filteredChannels = useMemo(
@@ -375,16 +393,15 @@ export function AdminChannelsPage() {
async function upsertChannel(nextChannel: SmsChannel) {
try {
if (modal?.mode === 'edit' && modal.channel) {
const updated = await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
setChannels((items) => items.map((item) => (item.id === updated.id ? mapApiChannel(updated) : item)));
await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
} else {
const created = await adminApi.createChannel({
await adminApi.createChannel({
code: `CH-${Date.now()}`,
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
status: 'active',
});
setChannels((items) => [mapApiChannel(created), ...items]);
}
loadChannels();
setModal(null);
setError('');
} catch (failure) {
@@ -393,8 +410,8 @@ export function AdminChannelsPage() {
}
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)));
await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
loadChannels();
}
async function deleteChannel(id: string) {
@@ -403,13 +420,13 @@ export function AdminChannelsPage() {
}
async function copyChannel(channel: SmsChannel) {
const copied = await adminApi.copyChannel(channel.id);
setChannels((items) => [mapApiChannel(copied), ...items]);
await adminApi.copyChannel(channel.id);
loadChannels();
}
async function openLinkLogs(channel: SmsChannel) {
setLogState({ channel });
const data = await adminApi.listChannelLinkLogs(channel.id);
const data = await adminApi.listChannelConnectionLogs(channel.id);
setLogState({ channel, data });
}
@@ -446,7 +463,7 @@ export function AdminChannelsPage() {
: confirmAction?.type === 'copy'
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
: confirmAction?.channel.status === 'stopped'
? '启用后通道会进入接中状态,后续可继续观察网关连接。'
? '启用后通道会进入接中状态,后续可继续观察网关连接。'
: '停用后该通道将不再承接新的发送任务。';
return (
@@ -491,7 +508,7 @@ export function AdminChannelsPage() {
<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} />
<FileText size={14} />
</button>
</div>
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
@@ -564,7 +581,7 @@ export function AdminChannelsPage() {
onClose={() => setLogState(null)}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{logState.channel.name}</p></div>}
title={<div className="template-modal-title"><h2></h2><p>{logState.channel.name}</p></div>}
>
<div className="channel-log-list">
{(logState.data?.logs ?? []).map((log) => (
@@ -579,8 +596,8 @@ export function AdminChannelsPage() {
</div>
</article>
))}
{logState.data && logState.data.logs.length === 0 ? <p className="muted"></p> : null}
{!logState.data ? <p className="muted">...</p> : null}
{logState.data && logState.data.logs.length === 0 ? <p className="muted"></p> : null}
{!logState.data ? <p className="muted">...</p> : null}
</div>
</Modal>
) : null}