Files
lislgosms/src/apps/admin/AdminChannelsPage.tsx
T

617 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
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;
passwordCipher?: string;
};
type ChannelModalState = {
mode: 'create' | 'edit';
channel?: SmsChannel;
};
type ChannelConfirmAction = {
type: 'toggle' | 'delete' | 'copy';
channel: SmsChannel;
};
type ChannelLogState = {
channel: SmsChannel;
data?: ChannelConnectionLogResponse;
};
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 regionOptions = [
{ label: '全国', value: '全国' },
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
];
const extensionOptions = [
{ label: '0', value: '0' },
{ label: '2', value: '2' },
{ label: '4', value: '4' },
{ label: '6', value: '6' },
];
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,
};
}
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,
rateLimitPerSecond: 100,
unitPrice: Math.round(channel.unitPrice),
};
}
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 ? String(channel.unitPrice / 100) : '0.0300');
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 [password, setPassword] = useState('');
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
const [extensionDigits, setExtensionDigits] = useState('0');
const [flowLimit, setFlowLimit] = useState('1-2000');
function submit() {
onSubmit({
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
name: name || '新建短信通道',
carrier,
sendRegion: region,
unitPrice: Number(unitPrice || 0) * 100,
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,
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 label="* 单价(元)" onChange={(event) => setUnitPrice(event.target.value)} 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} />
<Input label="* 网关密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入网关密码" type="password" value={password} />
<div className="sms-channel-inline-field">
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
</div>
<Input label="* 通道流速" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" value={flowLimit} />
</div>
</section>
</div>
</Modal>
);
}
function SmsTestModal({
channel,
onClose,
}: {
channel: SmsChannel;
onClose: () => void;
}) {
const [phones, setPhones] = useState('');
const [content, setContent] = useState('');
const [accessNo, setAccessNo] = useState('');
const billingCount = Math.max(1, Math.ceil(content.length / 67));
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost">取消</Button>
<Button icon={<Send size={16} />} onClick={onClose}>发送测试</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>
</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 [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 });
const data = await adminApi.listChannelConnectionLogs(channel.id);
setLogState({ channel, data });
}
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>{channel.unitPrice.toFixed(1)} </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}
previousDisabled={currentPage <= 1}
total={filteredChannels.length}
/>
</div>
{modal ? (
<ChannelFormModal
modal={modal}
onClose={() => setModal(null)}
onSubmit={upsertChannel}
/>
) : null}
{testChannel ? (
<SmsTestModal
channel={testChannel}
onClose={() => setTestChannel(null)}
/>
) : 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-list">
{(logState.data?.logs ?? []).map((log) => (
<article className="channel-log-item" key={log.id}>
<div>
<strong>{log.event}</strong>
<span>{new Date(log.time).toLocaleString('zh-CN', { hour12: false })}</span>
</div>
<div>
<span>{log.resourceId}</span>
<p>{typeof log.detail === 'string' ? log.detail : JSON.stringify(log.detail ?? {})}</p>
</div>
</article>
))}
{logState.data && logState.data.logs.length === 0 ? <p className="muted">暂无连接日志</p> : null}
{!logState.data ? <p className="muted">正在加载连接日志...</p> : null}
</div>
</Modal>
) : null}
</section>
);
}