feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
@@ -0,0 +1,467 @@
.sms-channel-page .page-heading {
align-items: center;
}
.sms-channel-filter {
padding: var(--space-6);
}
.sms-channel-filter-grid {
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) minmax(220px, 1fr) auto;
}
.sms-channel-table {
overflow-x: auto;
padding: 0;
}
.sms-channel-table__head,
.sms-channel-table__row {
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(280px, 1.6fr) 190px;
min-width: 940px;
}
.sms-channel-table__head {
background: var(--color-surface-subtle);
border-bottom: 1px solid var(--color-border);
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
padding: var(--space-4) var(--space-5);
}
.sms-channel-table__row {
align-items: center;
border-bottom: 1px solid var(--color-border);
min-height: 126px;
padding: var(--space-5);
}
.sms-channel-table__row:nth-child(odd) {
background: #f8fafc;
}
.sms-channel-total {
font-size: var(--font-size-xl);
}
.sms-channel-identity {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.sms-channel-identity strong {
line-height: 1.45;
overflow-wrap: anywhere;
}
.sms-channel-identity span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.sms-channel-carrier-price {
align-items: flex-start;
display: grid;
gap: var(--space-3);
justify-items: start;
}
.sms-channel-status-cell {
align-items: flex-start;
display: grid;
gap: var(--space-2);
justify-items: start;
}
.sms-channel-status-cell button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
gap: 4px;
padding: 0;
}
.sms-channel-status-cell button:hover {
text-decoration: underline;
}
.sms-channel-quality {
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
padding: var(--space-3);
}
.sms-channel-rate {
display: grid;
gap: 3px;
min-width: 0;
}
.sms-channel-rate small,
.sms-channel-rate strong {
color: var(--color-text-muted);
}
.sms-channel-rate--success strong {
color: var(--color-success);
}
.sms-channel-rate--warning strong {
color: #f59e0b;
}
.sms-channel-rate--danger strong {
color: var(--color-danger);
}
.sms-channel-rate span {
color: var(--color-text-strong);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
}
.sms-channel-actions {
display: grid;
gap: var(--space-2);
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 190px;
}
.sms-channel-actions button {
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
gap: var(--space-1);
justify-content: center;
min-height: 34px;
padding: var(--space-1);
white-space: nowrap;
}
.sms-channel-actions button:hover {
background: var(--color-accent-soft);
border-color: var(--color-selected);
}
.sms-channel-actions button.is-warning {
color: #ea580c;
}
.sms-channel-actions button.is-success {
color: var(--color-success);
}
.sms-channel-actions button.is-danger {
color: var(--color-danger);
}
.channel-log-modal,
.channel-log-list,
.channel-connection-summary {
display: grid;
gap: var(--space-3);
}
.channel-connection-summary article {
align-items: center;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(130px, 1fr) auto minmax(110px, auto) minmax(180px, 1.2fr);
padding: var(--space-4);
}
.channel-connection-summary span {
color: var(--color-text-muted);
display: block;
font-size: var(--font-size-sm);
margin-bottom: var(--space-1);
}
.channel-connection-summary strong {
color: var(--color-text-strong);
overflow-wrap: anywhere;
}
.channel-connection-summary p {
color: var(--color-danger);
grid-column: 1 / -1;
margin: 0;
}
.channel-log-item {
align-items: start;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4);
grid-template-columns: 150px minmax(0, 1fr);
padding: var(--space-4);
}
.channel-log-item strong {
color: var(--color-text-strong);
}
.channel-log-item span {
color: var(--color-text-muted);
display: block;
font-size: var(--font-size-sm);
margin-top: 3px;
}
.channel-log-item pre {
color: var(--color-text);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
line-height: 1.65;
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.sms-channel-pagination {
align-items: center;
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-3);
justify-content: flex-end;
padding: var(--space-4) var(--space-5);
position: sticky;
right: 0;
}
.sms-channel-pagination > label {
width: 150px;
}
.sms-channel-form {
display: grid;
gap: var(--space-8);
}
.sms-channel-form section {
border-bottom: 1px solid var(--color-border);
display: grid;
gap: var(--space-5);
padding-bottom: var(--space-7);
}
.sms-channel-form section:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.sms-channel-form h3 {
border-left: 5px solid var(--color-selected);
padding-left: var(--space-3);
}
.sms-channel-form-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(170px, 180px) minmax(0, 1fr);
}
.sms-channel-form-grid > .ui-field,
.sms-channel-radio-row,
.sms-channel-inline-field {
grid-column: 2;
}
.sms-channel-form-grid .ui-field__label,
.sms-channel-radio-row > span {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
}
.sms-channel-radio-row {
align-items: center;
display: flex;
gap: var(--space-8);
min-height: 42px;
}
.sms-channel-radio-row label {
align-items: center;
display: inline-flex;
gap: var(--space-2);
}
.sms-channel-inline-field {
align-items: end;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(0, 1fr) 160px;
}
.sms-test-title {
align-items: center;
display: flex;
gap: var(--space-4);
}
.sms-test-title span {
align-items: center;
background: var(--color-selected-soft);
border-radius: var(--radius-md);
color: var(--color-selected);
display: inline-flex;
height: 58px;
justify-content: center;
width: 58px;
}
.sms-test-title h2,
.sms-test-title p {
color: var(--color-text-strong);
margin: 0;
}
.sms-test-title p {
margin-top: var(--space-1);
opacity: 0.86;
}
.ui-modal__title:has(.sms-test-title) {
margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-7));
padding: var(--space-6) var(--space-7);
width: calc(100% + (var(--space-7) * 2));
}
.ui-modal__header:has(.sms-test-title) {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.ui-modal__header:has(.sms-test-title) .ui-button {
color: var(--color-text-muted);
}
.sms-test-modal {
display: grid;
gap: var(--space-5);
}
.sms-test-channel {
align-items: center;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: flex;
gap: var(--space-4);
padding: var(--space-5);
}
.sms-test-channel span {
border-right: 1px solid var(--color-border);
color: var(--color-text-muted);
font-weight: var(--font-weight-semibold);
padding-right: var(--space-4);
}
.sms-test-channel strong {
font-size: var(--font-size-xl);
}
.sms-test-counter {
align-items: center;
color: var(--color-text-muted);
display: flex;
justify-content: space-between;
}
.sms-test-counter strong {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
}
.sms-test-counter i {
border-left: 1px solid var(--color-border);
display: inline-block;
height: 18px;
margin: 0 var(--space-3);
vertical-align: middle;
}
.sms-test-note {
margin-top: var(--space-2);
}
.sms-test-result {
background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-success) 35%, var(--color-border));
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.sms-test-result__summary {
align-items: center;
color: var(--color-success);
display: flex;
gap: var(--space-3);
}
.sms-test-result__summary strong,
.sms-test-result__summary span {
display: block;
}
.sms-test-result__summary span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin-top: var(--space-1);
}
.sms-test-result__records {
border-top: 1px solid color-mix(in srgb, var(--color-success) 25%, var(--color-border));
display: grid;
gap: var(--space-2);
padding-top: var(--space-3);
}
.sms-test-result__records div {
align-items: center;
display: flex;
font-size: var(--font-size-sm);
gap: var(--space-3);
justify-content: space-between;
}
.sms-test-result__records code {
color: var(--color-text-muted);
overflow-wrap: anywhere;
}
@media (max-width: 780px) {
.channel-connection-summary article {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,144 @@
import { useState } from 'react';
import { Button, Input, Modal, Select } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
import { carrierLabelMap, cmppVersionOptions, regionOptions } from './channelModel';
import type { Carrier, ChannelModalState, SmsChannel } from './channelTypes';
export 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>
);
}
@@ -0,0 +1,81 @@
import { Button, Input, Modal, Tag } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { connectionStatusLabelMap, formatLogDetail } from './channelModel';
import type { ChannelLogState } from './channelTypes';
export function ChannelLogModal({
logState,
keyword,
onKeywordChange,
onClose,
}: {
logState: ChannelLogState;
keyword: string;
onKeywordChange: (keyword: string) => void;
onClose: () => void;
}) {
const normalizedKeyword = keyword.trim().toLowerCase();
const filteredLogs = (logState.data?.logs ?? []).filter((log) =>
!normalizedKeyword
|| `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(normalizedKeyword),
);
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
onClose={onClose}
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) => onKeywordChange(event.target.value)} placeholder="事件、资源或详情关键词" value={keyword} />
<div className="channel-log-list">
{filteredLogs.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 && filteredLogs.length === 0 ? <p className="muted"></p> : null}
{!logState.data ? <p className="muted">...</p> : null}
</div>
</div>
</Modal>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Copy, Eye, FileText, Pencil, Power, Send } from 'lucide-react';
import { DeleteRiskAction, Pagination, Tag } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { carrierLabelMap, carrierToneMap, statusLabelMap, statusToneMap } from './channelModel';
import type { ChannelConfirmAction, ChannelModalState, SmsChannel } from './channelTypes';
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>
);
}
export function ChannelTable({
channels,
currentPage,
total,
totalPages,
onPageChange,
onOpenLogs,
onOpenReports,
onEdit,
onConfirm,
onTest,
onDeleted,
}: {
channels: SmsChannel[];
currentPage: number;
total: number;
totalPages: number;
onPageChange: (page: number | ((value: number) => number)) => void;
onOpenLogs: (channel: SmsChannel) => void;
onOpenReports: (channel: SmsChannel) => void;
onEdit: (modal: ChannelModalState) => void;
onConfirm: (action: ChannelConfirmAction) => void;
onTest: (channel: SmsChannel) => void;
onDeleted: () => void;
}) {
return (
<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>
{channels.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={() => onOpenLogs(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={() => onOpenReports(channel)} type="button">
<Eye size={15} />
</button>
<button onClick={() => onEdit({ mode: 'edit', channel })} type="button"><Pencil size={15} /></button>
<button onClick={() => onConfirm({ type: 'copy', channel })} type="button"><Copy size={15} /></button>
<button onClick={() => onTest(channel)} type="button"><Send size={15} /></button>
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => onConfirm({ type: 'toggle', channel })} type="button">
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
</button>
<DeleteRiskAction onCompleted={onDeleted} portal="admin" targetId={channel.id} targetType="channel" />
</div>
</article>
))}
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => onPageChange((value) => Math.min(totalPages, value + 1))}
onPrevious={() => onPageChange((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
previousDisabled={currentPage <= 1}
total={total}
/>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useState } from 'react';
import { CheckCircle2, ExternalLink, Info, Send } from 'lucide-react';
import { adminApi, type ChannelTestResponse } from '@/api/adminApi';
import { Button, Input, Modal, Textarea } from '@/components/ui';
import type { SmsChannel } from './channelTypes';
export 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>
);
}
+153
View File
@@ -0,0 +1,153 @@
import type { AdminChannel, ChannelQualityStat, CmppConnectionState } from '@/api/adminApi';
import type { Carrier, ChannelStatus, SmsChannel } from './channelTypes';
export const connectionStatusLabelMap: Record<string, string> = {
connected: '已连接',
connecting: '连接中',
reconnecting: '重连中',
disconnected: '已断开',
failed: '连接失败',
auth_failed: '鉴权失败',
heartbeat_timeout: '心跳超时',
};
export const carrierOptions = [
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
{ label: '三网', value: 'all' },
];
export const statusOptions = [
{ label: '全部状态', value: 'all' },
{ label: '连接正常', value: 'normal' },
{ label: '已停用', value: 'stopped' },
{ label: '连接中', value: 'connecting' },
{ label: '连接失败', value: 'failed' },
];
export const cmppVersionOptions = [
{ label: 'CMPP 2.0', value: '2.0' },
{ label: 'CMPP 3.0', value: '3.0' },
];
export const regionOptions = [
{ label: '全国', value: '全国' },
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
];
export const carrierLabelMap: Record<Carrier, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
all: '三网',
};
export const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'> = {
mobile: 'info',
unicom: 'danger',
telecom: 'success',
all: 'neutral',
};
export const statusLabelMap: Record<ChannelStatus, string> = {
normal: '连接正常',
stopped: '已停用',
connecting: '连接中',
failed: '连接失败',
};
export const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
normal: 'success',
stopped: 'neutral',
connecting: 'info',
failed: 'danger',
};
export function formatLogDetail(detail?: unknown) {
if (!detail) return '无附加信息';
if (typeof detail === 'string') return detail;
return JSON.stringify(detail, null, 2);
}
export 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';
}
export 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,
};
}
export function mapUiStatusToApi(channel: SmsChannel) {
return channel.status === 'stopped' ? 'active' : 'disabled';
}
export 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 },
};
}
+51
View File
@@ -0,0 +1,51 @@
import type { ChannelConnectionLogResponse } from '@/api/adminApi';
export type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
export type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
export 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;
};
export type ChannelModalState = {
mode: 'create' | 'edit';
channel?: SmsChannel;
};
export type ChannelConfirmAction = {
type: 'toggle' | 'copy';
channel: SmsChannel;
};
export type ChannelLogState = {
channel: SmsChannel;
data?: ChannelConnectionLogResponse;
};