Initial CMPP frontend prototype

This commit is contained in:
hectorzhao
2026-06-30 16:09:46 +08:00
commit 2f3c274a30
98 changed files with 25255 additions and 0 deletions
+477
View File
@@ -0,0 +1,477 @@
import { useMemo, useState } from 'react';
import { Eye, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom';
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
type SmsChannel = {
id: string;
name: string;
carrier: Carrier;
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;
};
type ChannelModalState = {
mode: 'create' | 'edit';
channel?: SmsChannel;
};
const carrierOptions = [
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
];
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: '全国' },
{ label: '华东', value: '华东' },
{ label: '华南', value: '华南' },
{ label: '华北', value: '华北' },
];
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: '电信',
};
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success'> = {
mobile: 'info',
unicom: 'danger',
telecom: 'success',
};
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',
};
const initialChannels: SmsChannel[] = [
{
id: '88827',
name: '行北-集市三甲医院-39',
carrier: 'mobile',
unitPrice: 3.9,
status: 'normal',
total: 1587451,
successRate: 88.2,
successCount: 1281035,
unknownRate: 29.1,
unknownCount: 31648,
failureRate: 8,
failureCount: 189,
gatewayHost: '10.10.39.8',
gatewayPort: '7890',
corpCode: 'CM88827',
account: 'acct88827',
accessNo: '10690088',
},
{
id: '77',
name: '移动-行北-上海甲医院-38',
carrier: 'mobile',
unitPrice: 3.8,
status: 'stopped',
total: 12867,
successRate: 68.2,
successCount: 9982,
unknownRate: 20.5,
unknownCount: 2671,
failureRate: 16.2,
failureCount: 189,
gatewayHost: '10.10.38.8',
gatewayPort: '7890',
corpCode: 'CM00077',
account: 'acct00077',
accessNo: '10690077',
},
{
id: '78',
name: '联通-行政-杭州甲医院-37',
carrier: 'unicom',
unitPrice: 3.7,
status: 'connecting',
total: 8123,
successRate: 78.2,
successCount: 0,
unknownRate: 1.2,
unknownCount: 12,
failureRate: 5.8,
failureCount: 0,
gatewayHost: '10.10.37.8',
gatewayPort: '7890',
corpCode: 'CU00078',
account: 'acct00078',
accessNo: '10690078',
},
{
id: '67',
name: '联通-行政-上海甲医院-34',
carrier: 'telecom',
unitPrice: 16.2,
status: 'failed',
total: 154,
successRate: 0,
successCount: 0,
unknownRate: 0,
unknownCount: 0,
failureRate: 100,
failureCount: 154,
gatewayHost: '10.10.34.8',
gatewayPort: '7890',
corpCode: 'CT00067',
account: 'acct00067',
accessNo: '10690067',
},
];
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('全国');
const [protocol, setProtocol] = useState('CMPP');
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890');
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,
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,
});
}
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'] 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(initialChannels);
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 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],
);
function upsertChannel(nextChannel: SmsChannel) {
setChannels((items) => {
const exists = items.some((item) => item.id === nextChannel.id);
return exists ? items.map((item) => (item.id === nextChannel.id ? nextChannel : item)) : [nextChannel, ...items];
});
setModal(null);
}
function toggleChannel(id: string) {
setChannels((items) => items.map((item) => (
item.id === id ? { ...item, status: item.status === 'stopped' ? 'connecting' : 'stopped' } : item
)));
}
function deleteChannel(id: string) {
setChannels((items) => items.filter((item) => item.id !== id));
}
return (
<section className="page-stack sms-channel-page">
<div className="page-heading">
<div className="breadcrumb-line"><strong></strong></div>
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}></Button>
</div>
<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>
{filteredChannels.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>
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
<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={() => setTestChannel(channel)} type="button"><Send size={15} /></button>
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => toggleChannel(channel.id)} type="button">
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
</button>
<button className="is-danger" onClick={() => deleteChannel(channel.id)} type="button"><Trash2 size={15} /></button>
</div>
</article>
))}
<div className="sms-channel-pagination">
<Select options={[{ label: '10 条/页', value: '10' }, { label: '20 条/页', value: '20' }]} value="10" />
<Button disabled size="sm" variant="ghost"></Button>
<Button size="sm" variant="secondary">1</Button>
<Button size="sm" variant="ghost">2</Button>
<Button size="sm" variant="ghost"></Button>
</div>
</div>
{modal ? (
<ChannelFormModal
modal={modal}
onClose={() => setModal(null)}
onSubmit={upsertChannel}
/>
) : null}
{testChannel ? (
<SmsTestModal
channel={testChannel}
onClose={() => setTestChannel(null)}
/>
) : null}
</section>
);
}