import { useEffect, useMemo, useState } from 'react'; import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all'; type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed'; type SmsChannel = { id: string; name: string; carrier: Carrier; sendRegion: string; unitPrice: number; status: ChannelStatus; total: number; successRate: number; successCount: number; unknownRate: number; unknownCount: number; failureRate: number; failureCount: number; gatewayHost: string; gatewayPort: string; corpCode: string; account: string; accessNo: string; cmppVersion: '2.0' | '3.0'; desiredConnections: number; windowSize: number; extensionDigits: number; rateLimitPerSecond: number; passwordCipher?: string; }; type ChannelModalState = { mode: 'create' | 'edit'; channel?: SmsChannel; }; type ChannelConfirmAction = { type: 'toggle' | 'delete' | 'copy'; channel: SmsChannel; }; type ChannelLogState = { channel: SmsChannel; data?: ChannelConnectionLogResponse; }; const connectionStatusLabelMap: Record = { connected: '已连接', connecting: '连接中', reconnecting: '重连中', disconnected: '已断开', failed: '连接失败', auth_failed: '鉴权失败', heartbeat_timeout: '心跳超时', }; function formatLogDetail(detail?: unknown) { if (!detail) return '无附加信息'; if (typeof detail === 'string') return detail; return JSON.stringify(detail, null, 2); } const carrierOptions = [ { label: '全部运营商', value: 'all' }, { label: '移动', value: 'mobile' }, { label: '联通', value: 'unicom' }, { label: '电信', value: 'telecom' }, { label: '三网', value: 'all' }, ]; const statusOptions = [ { label: '全部状态', value: 'all' }, { label: '连接正常', value: 'normal' }, { label: '已停用', value: 'stopped' }, { label: '连接中', value: 'connecting' }, { label: '连接失败', value: 'failed' }, ]; const protocolOptions = [ { label: 'CMPP', value: 'CMPP' }, { label: 'HTTP', value: 'HTTP' }, { label: 'SGIP', value: 'SGIP' }, ]; const cmppVersionOptions = [ { label: 'CMPP 2.0', value: '2.0' }, { label: 'CMPP 3.0', value: '3.0' }, ]; const regionOptions = [ { label: '全国', value: '全国' }, ...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })), ]; const carrierLabelMap: Record = { mobile: '移动', unicom: '联通', telecom: '电信', all: '三网', }; const carrierToneMap: Record = { mobile: 'info', unicom: 'danger', telecom: 'success', all: 'neutral', }; const statusLabelMap: Record = { normal: '连接正常', stopped: '已停用', connecting: '连接中', failed: '连接失败', }; const statusToneMap: Record = { normal: 'success', stopped: 'neutral', connecting: 'info', failed: 'danger', }; 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: resolveChannelStatus(channel, connections), 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, cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0', desiredConnections: Number(channel.config?.desiredConnections ?? 1), windowSize: Number(channel.config?.windowSize ?? 16), extensionDigits: Number(channel.config?.extensionDigits ?? 0), rateLimitPerSecond: channel.rateLimitPerSecond, }; } function mapUiStatusToApi(channel: SmsChannel) { return channel.status === 'stopped' ? 'active' : 'disabled'; } function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) { return { name: channel.name, carrier: channel.carrier, sendRegion: channel.sendRegion, gatewayHost: channel.gatewayHost, gatewayPort: Number(channel.gatewayPort), enterpriseCode: channel.corpCode, account: channel.account, passwordCipher: passwordCipher || undefined, srcId: channel.accessNo, cmppVersion: channel.cmppVersion, rateLimitPerSecond: channel.rateLimitPerSecond, unitPrice: Math.round(channel.unitPrice), desiredConnections: channel.desiredConnections, windowSize: channel.windowSize, config: { extensionDigits: channel.extensionDigits }, }; } function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) { return (
{label} {rate}% {count.toLocaleString('zh-CN')}
); } function ChannelFormModal({ modal, onClose, onSubmit, }: { modal: ChannelModalState; onClose: () => void; onSubmit: (channel: SmsChannel) => void; }) { const channel = modal.channel; const [name, setName] = useState(channel?.name ?? ''); const [carrier, setCarrier] = useState(channel?.carrier ?? 'mobile'); const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300'); const [unitPriceError, setUnitPriceError] = useState(''); const [region, setRegion] = useState(channel?.sendRegion ?? '全国'); const [protocol, setProtocol] = useState('CMPP'); const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? ''); const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '17890'); const [corpCode, setCorpCode] = useState(channel?.corpCode ?? ''); const [account, setAccount] = useState(channel?.account ?? ''); const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0'); const [password, setPassword] = useState(''); const [accessNo, setAccessNo] = useState(channel?.accessNo ?? ''); const [extensionDigits, setExtensionDigits] = useState(String(channel?.extensionDigits ?? 0)); const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100)); const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1)); const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16)); function submit() { if (!isValidMoneyInput(unitPrice)) { setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位'); return; } onSubmit({ id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)), name: name || '新建短信通道', carrier, sendRegion: region, unitPrice: yuanToMoneyUnits(unitPrice), status: channel?.status ?? 'connecting', total: channel?.total ?? 0, successRate: channel?.successRate ?? 0, successCount: channel?.successCount ?? 0, unknownRate: channel?.unknownRate ?? 0, unknownCount: channel?.unknownCount ?? 0, failureRate: channel?.failureRate ?? 0, failureCount: channel?.failureCount ?? 0, gatewayHost, gatewayPort, corpCode, account, accessNo, cmppVersion, desiredConnections: Number(desiredConnections) || 1, windowSize: Number(windowSize) || 16, extensionDigits: Number(extensionDigits), rateLimitPerSecond: Number(flowLimit), passwordCipher: password || undefined, }); } return ( )} onClose={onClose} open size="xl" title={

{modal.mode === 'edit' ? '编辑通道' : '创建通道'}

} >

业务信息

setName(event.target.value)} placeholder="请输入通道名称" value={name} />
* 运营商 {(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => ( ))}
{ setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} /> setProtocol(event.target.value)} options={protocolOptions} value={protocol} />
setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} /> setGatewayPort(event.target.value)} value={gatewayPort} />
setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} /> setAccount(event.target.value)} placeholder="请输入网关账号" value={account} /> setPassword(event.target.value)} placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'} required={modal.mode === 'create'} type="password" value={password} />
setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} /> setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} /> setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} /> setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
); } function SmsTestModal({ channel, onClose, onOpenRecords, }: { channel: SmsChannel; onClose: () => void; onOpenRecords: () => void; }) { const [phones, setPhones] = useState(''); const [content, setContent] = useState(''); const [accessNo, setAccessNo] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(null); const billingCount = Math.max(1, Math.ceil(content.length / 67)); async function submitTestSms() { if (!phones.trim()) { setError('请输入测试手机号'); return; } if (!content.trim()) { setError('请输入测试短信内容'); return; } setSubmitting(true); setError(''); setResult(null); try { const response = await adminApi.testChannel(channel.id, { phones, content, accessNo: accessNo.trim() || undefined, }); setResult(response); } catch (failure) { setError(failure instanceof Error ? failure.message : '测试短信发送失败'); } finally { setSubmitting(false); } } return ( {result ? : null} )} onClose={onClose} open size="xl" title={(

短信测试

向指定手机号发送测试短信

)} >
测试通道 {channel.name}