feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -1,488 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelQualityStat, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, 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;
|
||||
submitFailureRate: number;
|
||||
submitFailureCount: number;
|
||||
successRate: number;
|
||||
successCount: number;
|
||||
unknownRate: number;
|
||||
unknownCount: number;
|
||||
failureRate: number;
|
||||
failureCount: number;
|
||||
gatewayHost: string;
|
||||
gatewayPort: string;
|
||||
businessCode: string;
|
||||
corpCode: string;
|
||||
account: string;
|
||||
accessNo: string;
|
||||
cmppVersion: '2.0' | '3.0';
|
||||
desiredConnections: number;
|
||||
windowSize: number;
|
||||
heartbeatIntervalSeconds: number;
|
||||
heartbeatMissThreshold: number;
|
||||
extensionDigits: number;
|
||||
rateLimitPerSecond: number;
|
||||
passwordCipher?: string;
|
||||
};
|
||||
|
||||
type ChannelModalState = {
|
||||
mode: 'create' | 'edit';
|
||||
channel?: SmsChannel;
|
||||
};
|
||||
|
||||
type ChannelConfirmAction = {
|
||||
type: 'toggle' | '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 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 ?? [],
|
||||
quality?: ChannelQualityStat,
|
||||
): 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: quality?.total ?? 0,
|
||||
submitFailureRate: quality?.submitFailureRate ?? 0,
|
||||
submitFailureCount: quality?.submitFailureCount ?? 0,
|
||||
successRate: quality?.successRate ?? 0,
|
||||
successCount: quality?.successCount ?? 0,
|
||||
unknownRate: quality?.unknownRate ?? 0,
|
||||
unknownCount: quality?.unknownCount ?? 0,
|
||||
failureRate: quality?.failureRate ?? 0,
|
||||
failureCount: quality?.failureCount ?? 0,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: String(channel.gatewayPort),
|
||||
businessCode: String(channel.config?.serviceId ?? 'SMS'),
|
||||
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),
|
||||
heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30),
|
||||
heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3),
|
||||
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),
|
||||
protocol: 'CMPP',
|
||||
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,
|
||||
heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds,
|
||||
heartbeatMissThreshold: channel.heartbeatMissThreshold,
|
||||
config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode },
|
||||
};
|
||||
}
|
||||
|
||||
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 [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
|
||||
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890');
|
||||
const [businessCode, setBusinessCode] = useState(channel?.businessCode ?? 'SMS');
|
||||
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));
|
||||
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
|
||||
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
|
||||
|
||||
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,
|
||||
submitFailureRate: channel?.submitFailureRate ?? 0,
|
||||
submitFailureCount: channel?.submitFailureCount ?? 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,
|
||||
businessCode: businessCode.trim() || 'SMS',
|
||||
corpCode,
|
||||
account,
|
||||
accessNo,
|
||||
cmppVersion,
|
||||
desiredConnections: Number(desiredConnections) || 1,
|
||||
windowSize: Number(windowSize) || 16,
|
||||
heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30,
|
||||
heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3,
|
||||
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">
|
||||
<Input disabled label="* 协议选择" value="CMPP" />
|
||||
<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 hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} />
|
||||
<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="网关密码"
|
||||
autoComplete="new-password"
|
||||
name="cmpp-gateway-password"
|
||||
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 autoComplete="off" label="* 接入号" name="cmpp-access-number" 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} />
|
||||
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
|
||||
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
|
||||
</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
|
||||
autoComplete="off"
|
||||
label="接入号(选填)"
|
||||
name="channel-test-access-number"
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { adminApi, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select } from '@/components/ui';
|
||||
import { ChannelFormModal } from './channels/ChannelFormModal';
|
||||
import { ChannelLogModal } from './channels/ChannelLogModal';
|
||||
import { ChannelTable } from './channels/ChannelTable';
|
||||
import { SmsTestModal } from './channels/SmsTestModal';
|
||||
import { buildChannelPayload, carrierOptions, mapApiChannel, mapUiStatusToApi, statusOptions } from './channels/channelModel';
|
||||
import type { ChannelConfirmAction, ChannelLogState, ChannelModalState, SmsChannel } from './channels/channelTypes';
|
||||
import './channels/AdminChannelsPage.css';
|
||||
|
||||
export function AdminChannelsPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -522,10 +49,8 @@ export function AdminChannelsPage() {
|
||||
loadChannels(page);
|
||||
}, [page]);
|
||||
|
||||
const filteredChannels = channels;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleChannels = channels;
|
||||
|
||||
async function upsertChannel(nextChannel: SmsChannel) {
|
||||
try {
|
||||
@@ -572,29 +97,26 @@ export function AdminChannelsPage() {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'toggle') {
|
||||
void toggleChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'copy') {
|
||||
void copyChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
const confirmTitle = confirmAction?.type === 'copy'
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
|
||||
const confirmDescription = confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
|
||||
return (
|
||||
<section className="page-stack sms-channel-page">
|
||||
@@ -616,72 +138,21 @@ export function AdminChannelsPage() {
|
||||
</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.submitFailureCount} label="提交失败" rate={channel.submitFailureRate} tone={channel.submitFailureCount > 0 ? 'danger' : 'neutral'} />
|
||||
<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>
|
||||
<DeleteRiskAction onCompleted={() => void loadChannels()} portal="admin" targetId={channel.id} targetType="channel" />
|
||||
</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={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{modal ? (
|
||||
<ChannelFormModal
|
||||
modal={modal}
|
||||
onClose={() => setModal(null)}
|
||||
onSubmit={upsertChannel}
|
||||
/>
|
||||
) : null}
|
||||
<ChannelTable
|
||||
channels={channels}
|
||||
currentPage={currentPage}
|
||||
onConfirm={setConfirmAction}
|
||||
onDeleted={() => void loadChannels()}
|
||||
onEdit={setModal}
|
||||
onOpenLogs={(channel) => void openLinkLogs(channel)}
|
||||
onOpenReports={(channel) => navigate(`/admin/channels/${channel.id}/reports`)}
|
||||
onPageChange={setPage}
|
||||
onTest={setTestChannel}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
|
||||
{modal ? <ChannelFormModal modal={modal} onClose={() => setModal(null)} onSubmit={upsertChannel} /> : null}
|
||||
{testChannel ? (
|
||||
<SmsTestModal
|
||||
channel={testChannel}
|
||||
@@ -714,68 +185,12 @@ export function AdminChannelsPage() {
|
||||
) : null}
|
||||
|
||||
{logState ? (
|
||||
<Modal
|
||||
footer={<Button onClick={() => setLogState(null)} variant="ghost">关闭</Button>}
|
||||
<ChannelLogModal
|
||||
keyword={logKeyword}
|
||||
logState={logState}
|
||||
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>
|
||||
<div>
|
||||
<span>自动重连</span>
|
||||
<strong>{connection.reconnectCount} 次 / {formatDateTime(connection.nextReconnectAt)}</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>
|
||||
onKeywordChange={setLogKeyword}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user