772 lines
30 KiB
TypeScript
772 lines
30 KiB
TypeScript
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<string, string> = {
|
||
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<Carrier, string> = {
|
||
mobile: '移动',
|
||
unicom: '联通',
|
||
telecom: '电信',
|
||
all: '三网',
|
||
};
|
||
|
||
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'> = {
|
||
mobile: 'info',
|
||
unicom: 'danger',
|
||
telecom: 'success',
|
||
all: 'neutral',
|
||
};
|
||
|
||
const statusLabelMap: Record<ChannelStatus, string> = {
|
||
normal: '连接正常',
|
||
stopped: '已停用',
|
||
connecting: '连接中',
|
||
failed: '连接失败',
|
||
};
|
||
|
||
const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
|
||
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 (
|
||
<div className={`sms-channel-rate sms-channel-rate--${tone}`}>
|
||
<small>{label}</small>
|
||
<strong>{rate}%</strong>
|
||
<span>{count.toLocaleString('zh-CN')}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<Carrier>(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 (
|
||
<Modal
|
||
footer={(
|
||
<>
|
||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||
<Button onClick={submit}>确认</Button>
|
||
</>
|
||
)}
|
||
onClose={onClose}
|
||
open
|
||
size="xl"
|
||
title={<div className="template-modal-title"><h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2></div>}
|
||
>
|
||
<div className="sms-channel-form">
|
||
<section>
|
||
<h3>业务信息</h3>
|
||
<div className="sms-channel-form-grid">
|
||
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
|
||
<div className="sms-channel-radio-row">
|
||
<span>* 运营商</span>
|
||
{(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => (
|
||
<label key={item}>
|
||
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
|
||
{carrierLabelMap[item]}
|
||
</label>
|
||
))}
|
||
</div>
|
||
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} />
|
||
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} />
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h3>参数配置</h3>
|
||
<div className="sms-channel-form-grid">
|
||
<Select label="* 协议选择" onChange={(event) => setProtocol(event.target.value)} options={protocolOptions} value={protocol} />
|
||
<div className="sms-channel-inline-field">
|
||
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
|
||
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
|
||
</div>
|
||
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
||
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
||
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
|
||
<Input
|
||
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
|
||
label="网关密码"
|
||
onChange={(event) => setPassword(event.target.value)}
|
||
placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'}
|
||
required={modal.mode === 'create'}
|
||
type="password"
|
||
value={password}
|
||
/>
|
||
<div className="sms-channel-inline-field">
|
||
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
||
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
|
||
</div>
|
||
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
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<ChannelTestResponse | null>(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 (
|
||
<Modal
|
||
footer={(
|
||
<>
|
||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||
{result ? <Button icon={<ExternalLink size={16} />} onClick={onOpenRecords} variant="ghost">查看短信记录</Button> : null}
|
||
<Button disabled={submitting || Boolean(result)} icon={<Send size={16} />} onClick={submitTestSms}>
|
||
{submitting ? '发送中...' : result ? '已提交' : '发送测试'}
|
||
</Button>
|
||
</>
|
||
)}
|
||
onClose={onClose}
|
||
open
|
||
size="xl"
|
||
title={(
|
||
<div className="sms-test-title">
|
||
<span><Send size={30} /></span>
|
||
<div>
|
||
<h2>短信测试</h2>
|
||
<p>向指定手机号发送测试短信</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
>
|
||
<div className="sms-test-modal">
|
||
<div className="sms-test-channel">
|
||
<span>测试通道</span>
|
||
<strong>{channel.name}</strong>
|
||
</div>
|
||
<Textarea
|
||
label="* 手机号码"
|
||
onChange={(event) => setPhones(event.target.value)}
|
||
placeholder="请输入手机号码,多个号码用逗号(,)隔开,最多允许10个号码"
|
||
rows={3}
|
||
value={phones}
|
||
/>
|
||
<p className="muted">支持多个号码,用中英文逗号分隔,最多10个</p>
|
||
<Textarea
|
||
label="* 短信内容"
|
||
onChange={(event) => setContent(event.target.value)}
|
||
placeholder="请输入短信内容"
|
||
rows={4}
|
||
value={content}
|
||
/>
|
||
<div className="sms-test-counter">
|
||
<span>按67字/条计费</span>
|
||
<strong>{content.length} 字符 <i /> 计费 {billingCount} 条</strong>
|
||
</div>
|
||
<Input
|
||
label="接入号(选填)"
|
||
onChange={(event) => setAccessNo(event.target.value)}
|
||
placeholder="请输入接入号"
|
||
value={accessNo}
|
||
/>
|
||
<div className="signature-alert sms-test-note">
|
||
<Info size={18} />
|
||
<span>如需测试接入号,可在通道接入号后上追加接入号进行测试</span>
|
||
</div>
|
||
{error ? <p className="form-error">{error}</p> : null}
|
||
{result ? (
|
||
<div className="sms-test-result">
|
||
<div className="sms-test-result__summary">
|
||
<CheckCircle2 size={20} />
|
||
<div>
|
||
<strong>已写入真实发送队列</strong>
|
||
<span>测试流水号:{result.testNo},共 {result.submitted} 条</span>
|
||
</div>
|
||
</div>
|
||
<div className="sms-test-result__records">
|
||
{result.messages.map((message) => (
|
||
<div key={message.messageRecordId}>
|
||
<span>{message.phoneNumber}</span>
|
||
<code>{message.submitId}</code>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function AdminChannelsPage() {
|
||
const navigate = useNavigate();
|
||
const [channels, setChannels] = useState<SmsChannel[]>([]);
|
||
const [error, setError] = useState('');
|
||
const [keyword, setKeyword] = useState('');
|
||
const [carrier, setCarrier] = useState('all');
|
||
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);
|
||
const [logKeyword, setLogKeyword] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const pageSize = 10;
|
||
|
||
function loadChannels() {
|
||
adminApi.listChannels()
|
||
.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(
|
||
() => channels.filter((channel) => {
|
||
const matchesKeyword = !keyword || channel.name.includes(keyword);
|
||
const matchesCarrier = carrier === 'all' || channel.carrier === carrier;
|
||
const matchesStatus = status === 'all' || channel.status === status;
|
||
return matchesKeyword && matchesCarrier && matchesStatus;
|
||
}),
|
||
[carrier, channels, keyword, status],
|
||
);
|
||
const totalPages = Math.max(1, Math.ceil(filteredChannels.length / pageSize));
|
||
const currentPage = Math.min(page, totalPages);
|
||
const visibleChannels = filteredChannels.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||
|
||
useEffect(() => {
|
||
setPage(1);
|
||
}, [carrier, channels.length, keyword, status]);
|
||
|
||
async function upsertChannel(nextChannel: SmsChannel) {
|
||
try {
|
||
if (modal?.mode === 'edit' && modal.channel) {
|
||
await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||
} else {
|
||
await adminApi.createChannel({
|
||
code: `CH-${Date.now()}`,
|
||
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
|
||
status: 'active',
|
||
});
|
||
}
|
||
loadChannels();
|
||
setModal(null);
|
||
setError('');
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '通道保存失败');
|
||
}
|
||
}
|
||
|
||
async function toggleChannel(channel: SmsChannel) {
|
||
await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
|
||
loadChannels();
|
||
}
|
||
|
||
async function deleteChannel(id: string) {
|
||
await adminApi.deleteChannel(id, '运营端删除通道');
|
||
setChannels((items) => items.filter((item) => item.id !== id));
|
||
}
|
||
|
||
async function copyChannel(channel: SmsChannel) {
|
||
await adminApi.copyChannel(channel.id);
|
||
loadChannels();
|
||
}
|
||
|
||
async function openLinkLogs(channel: SmsChannel) {
|
||
setLogState({ channel });
|
||
setLogKeyword('');
|
||
try {
|
||
const data = await adminApi.listChannelConnectionLogs(channel.id);
|
||
setLogState({ channel, data });
|
||
} catch (failure) {
|
||
setLogState(null);
|
||
setError(failure instanceof Error ? failure.message : '连接日志加载失败');
|
||
}
|
||
}
|
||
|
||
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">
|
||
<Breadcrumb items={['短信通道管理']} />
|
||
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>添加通道</Button>
|
||
</div>
|
||
{error ? <p className="form-error">{error}</p> : null}
|
||
|
||
<div className="surface sms-channel-filter">
|
||
<div className="sms-channel-filter-grid">
|
||
<Input label="通道名称" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入通道名称" value={keyword} />
|
||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
||
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||
<div className="audit-filter-actions">
|
||
<Button icon={<Search size={16} />}>查询</Button>
|
||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost">重置</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="surface sms-channel-table">
|
||
<div className="sms-channel-table__head">
|
||
<span>通道信息</span>
|
||
<span>运营商 / 成本</span>
|
||
<span>状态</span>
|
||
<span>今日总数</span>
|
||
<span>今日发送质量</span>
|
||
<span>操作</span>
|
||
</div>
|
||
{visibleChannels.map((channel) => (
|
||
<article className="sms-channel-table__row" key={channel.id}>
|
||
<div className="sms-channel-identity">
|
||
<strong>{channel.name}</strong>
|
||
<span>通道 ID:{channel.id}</span>
|
||
</div>
|
||
<div className="sms-channel-carrier-price">
|
||
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
|
||
<strong>{formatCents(channel.unitPrice)} 元</strong>
|
||
</div>
|
||
<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'} />
|
||
<RateBlock count={channel.unknownCount} label="未知" rate={channel.unknownRate} />
|
||
<RateBlock count={channel.failureCount} label="失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
|
||
</div>
|
||
<div className="sms-channel-actions">
|
||
<button className="sms-channel-report-entry" onClick={() => navigate(`/admin/channels/${channel.id}/reports`)} type="button">
|
||
<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={() => setConfirmAction({ type: 'toggle', channel })} type="button">
|
||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||
</button>
|
||
<button className="is-danger" onClick={() => setConfirmAction({ type: 'delete', channel })} type="button"><Trash2 size={15} />删除</button>
|
||
</div>
|
||
</article>
|
||
))}
|
||
<Pagination
|
||
nextDisabled={currentPage >= totalPages}
|
||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||
page={currentPage}
|
||
totalPages={totalPages}
|
||
onPageChange={setPage}
|
||
previousDisabled={currentPage <= 1}
|
||
total={filteredChannels.length}
|
||
/>
|
||
</div>
|
||
|
||
{modal ? (
|
||
<ChannelFormModal
|
||
modal={modal}
|
||
onClose={() => setModal(null)}
|
||
onSubmit={upsertChannel}
|
||
/>
|
||
) : null}
|
||
|
||
{testChannel ? (
|
||
<SmsTestModal
|
||
channel={testChannel}
|
||
onClose={() => setTestChannel(null)}
|
||
onOpenRecords={() => {
|
||
setTestChannel(null);
|
||
navigate('/admin/sms-records');
|
||
}}
|
||
/>
|
||
) : 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-modal">
|
||
{logState.data ? (
|
||
<div className="channel-connection-summary">
|
||
{logState.data.connectionStates.map((connection) => (
|
||
<article key={connection.id}>
|
||
<div>
|
||
<span>连接 ID</span>
|
||
<strong>{connection.connectionId}</strong>
|
||
</div>
|
||
<Tag tone={connection.status === 'connected' ? 'success' : connection.lastError ? 'danger' : 'info'}>
|
||
{connectionStatusLabelMap[connection.status] ?? connection.status}
|
||
</Tag>
|
||
<div>
|
||
<span>当前 / 期望</span>
|
||
<strong>{connection.currentConnections} / {connection.desiredConnections}</strong>
|
||
</div>
|
||
<div>
|
||
<span>最近心跳</span>
|
||
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
|
||
</div>
|
||
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
||
</article>
|
||
))}
|
||
{logState.data.connectionStates.length === 0 ? <p className="muted">暂无连接状态回写</p> : null}
|
||
</div>
|
||
) : null}
|
||
<Input label="筛选日志" onChange={(event) => setLogKeyword(event.target.value)} placeholder="事件、资源或详情关键词" value={logKeyword} />
|
||
<div className="channel-log-list">
|
||
{(logState.data?.logs ?? []).filter((log) => {
|
||
const keyword = logKeyword.trim().toLowerCase();
|
||
return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword);
|
||
}).map((log) => (
|
||
<article className="channel-log-item" key={log.id}>
|
||
<div>
|
||
<strong>{log.event}</strong>
|
||
<span>{formatDateTime(log.time)}</span>
|
||
</div>
|
||
<div>
|
||
<span>{log.resourceId}</span>
|
||
<pre>{formatLogDetail(log.detail)}</pre>
|
||
</div>
|
||
</article>
|
||
))}
|
||
{logState.data && logState.data.logs.filter((log) => {
|
||
const keyword = logKeyword.trim().toLowerCase();
|
||
return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword);
|
||
}).length === 0 ? <p className="muted">未找到匹配的连接日志</p> : null}
|
||
{!logState.data ? <p className="muted">正在加载连接日志...</p> : null}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|