Initial CMPP frontend prototype
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user