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

347 lines
17 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, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Info, RadioTower, RefreshCw } from 'lucide-react';
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom';
type QueuePriority = 'normal' | 'priority';
type InterfaceType = 'cmpp20';
const carrierMeta: Record<Carrier, { label: string; description: string }> = {
mobile: { label: '移动', description: '移动号码只会进入移动通道组' },
unicom: { label: '联通', description: '联通号码只会进入联通通道组' },
telecom: { label: '电信', description: '电信号码只会进入电信通道组' },
};
export function AdminSmsApplicationFormPage() {
const navigate = useNavigate();
const { enterpriseId, appId } = useParams();
const isEdit = Boolean(appId);
const [appName, setAppName] = useState('');
const [scene, setScene] = useState('行业通知');
const [dailyLimit, setDailyLimit] = useState('100000');
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
const [cmppAccount, setCmppAccount] = useState('');
const [cmppEnterpriseCode, setCmppEnterpriseCode] = useState('');
const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword());
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
const [ipAddress, setIpAddress] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [mobileGroupId, setMobileGroupId] = useState('');
const [unicomGroupId, setUnicomGroupId] = useState('');
const [telecomGroupId, setTelecomGroupId] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
async function loadForm() {
try {
const [groupItems, application, routeRules] = await Promise.all([
adminApi.listChannelGroups(),
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
isEdit ? adminApi.listChannelRouteRules() : Promise.resolve<DictionaryItem[]>([]),
]);
if (cancelled) {
return;
}
setGroups(groupItems.filter((item) => item.status !== 'disabled' && item.status !== 'deleted'));
if (application) {
if (enterpriseId && application.tenantId !== enterpriseId) {
setError('应用不属于当前企业,已停止加载');
return;
}
hydrateApplication(application, routeRules);
}
} catch (failure) {
if (!cancelled) {
setError(failure instanceof Error ? failure.message : '短信应用加载失败');
}
}
}
void loadForm();
return () => {
cancelled = true;
};
}, [appId, enterpriseId, isEdit]);
function goBack() {
navigate('/admin/enterprise-applications');
}
function hydrateApplication(application: EnterpriseApplication, routeRules: DictionaryItem[]) {
setAppName(application.name);
setScene(application.scene ?? '');
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3));
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
setCmppAccount(application.cmppAccount ?? '');
setCmppEnterpriseCode(application.cmppEnterpriseCode ?? application.tenant?.code ?? '');
setPasswordCipher('');
setInterfaceEnabled(application.interfaceEnabled !== false);
setInterfaceType('cmpp20');
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
setDownstreamReceiptRetryEnabled(application.downstreamReceiptRetryEnabled !== false);
setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false);
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
const activeRules = routeRules.filter((rule) => (
rule.applicationId === application.id
&& rule.status !== 'deleted'
&& !rule.province
&& !rule.channelId
));
setMobileGroupId(getRouteGroupId(activeRules, 'mobile'));
setUnicomGroupId(getRouteGroupId(activeRules, 'unicom'));
setTelecomGroupId(getRouteGroupId(activeRules, 'telecom'));
}
async function submit() {
if (!enterpriseId) {
setError('缺少企业 ID');
return;
}
const selectedGroups = [
{ carrier: 'mobile' as Carrier, groupId: mobileGroupId },
{ carrier: 'unicom' as Carrier, groupId: unicomGroupId },
{ carrier: 'telecom' as Carrier, groupId: telecomGroupId },
].filter((item) => item.groupId);
if (selectedGroups.length === 0) {
setError('请至少配置一个运营商通道组');
return;
}
const payload = {
name: appName,
scene,
dailyLimit: Number(dailyLimit) || undefined,
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
queuePriority,
cmppAccount: cmppAccount.trim() || undefined,
cmppEnterpriseCode: cmppEnterpriseCode.trim() || undefined,
passwordCipher: passwordCipher.trim() || undefined,
interfaceEnabled,
interfaceType,
cmppMaxConnections: Number(cmppMaxConnections) || 1,
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
downstreamReceiptRetryEnabled,
downstreamUplinkRetryEnabled,
ipAllowlist: parseIpAllowlist(ipAddress),
};
setSaving(true);
setError('');
try {
const application = isEdit && appId
? await adminApi.updateEnterpriseApplication(appId, payload)
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
await adminApi.replaceApplicationRouteRules(application.id, {
routes: selectedGroups.map((item, index) => ({
carrier: item.carrier,
groupId: item.groupId,
priority: (index + 1) * 10,
status: 'active',
})),
});
goBack();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '短信应用保存失败');
} finally {
setSaving(false);
}
}
const groupOptionsByCarrier = (carrier: ChannelGroup['carrier']) => [
{ label: '不配置', value: '' },
...groups.filter((group) => group.carrier === carrier).map((group) => ({ label: group.name, value: group.id })),
];
const selectedGroupCount = [mobileGroupId, unicomGroupId, telecomGroupId].filter(Boolean).length;
const routeCards: Array<{ carrier: Carrier; groupId: string; onChange: (value: string) => void }> = [
{ carrier: 'mobile', groupId: mobileGroupId, onChange: setMobileGroupId },
{ carrier: 'unicom', groupId: unicomGroupId, onChange: setUnicomGroupId },
{ carrier: 'telecom', groupId: telecomGroupId, onChange: setTelecomGroupId },
];
return (
<section className="page-stack admin-app-form-page">
<div className="page-heading">
<div>
<Breadcrumb items={[isEdit ? '编辑短信应用' : '添加短信应用']} />
<p>短信应用和三网通道组配置写入真实后台接口。</p>
</div>
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">返回企业应用管理</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-app-form-card">
<section className="ui-detail-section">
<div className="ui-detail-section__header"><h3>业务信息</h3><p>先填写应用基础信息,保存后将生成真实企业应用。</p></div>
<div className="admin-app-form-grid">
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.030" required value={customerUnitPrice} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span>发送队列</span>
<div className="radio-row">
<label>
<input checked={queuePriority === 'priority'} onChange={() => setQueuePriority('priority')} type="radio" />
优先队列(行业短信)
</label>
<label>
<input checked={queuePriority === 'normal'} onChange={() => setQueuePriority('normal')} type="radio" />
普通队列(会员营销)
</label>
</div>
<div className="admin-app-form-tip">
<Info size={17} />
<span>优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。</span>
</div>
</div>
<Input label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
<Select
label="不符合模板的短信"
onChange={(event) => setMismatchPolicy(event.target.value)}
options={[
{ label: '拒绝发送', value: 'reject' },
{ label: '跳人工审核', value: 'manual_review' },
{ label: '直接发送', value: 'direct_send' },
]}
required
value={mismatchPolicy}
/>
</div>
</section>
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<h3>接口配置</h3>
<p>短信接口关闭后,客户 CMPP 鉴权和发送接口都会被真实后端拒绝。</p>
</div>
<div className="admin-app-form-grid">
<div className="admin-app-form-row admin-app-form-row--wide">
<span>短信接口</span>
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
<span />
{interfaceEnabled ? '开通' : '关闭'}
</button>
</div>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>接口类型</span>
<div className="radio-row">
<label>
<input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />
CMPP2.0
</label>
<label className="is-disabled">
<input disabled type="radio" />
HTTP接口(暂不可选)
</label>
</div>
</div>
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input label="企业代码" onChange={(event) => setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} />
<Input
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
label="接口密码"
onChange={(event) => setPasswordCipher(event.target.value)}
placeholder="16 位接口密码"
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
value={passwordCipher}
/>
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span>下游投递策略</span>
<div className="radio-row">
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button">
<span />
回执自动重试:{downstreamReceiptRetryEnabled ? '开启' : '关闭'}
</button>
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button">
<span />
上行自动重试:{downstreamUplinkRetryEnabled ? '开启' : '关闭'}
</button>
</div>
<div className="admin-app-form-tip">
<Info size={17} />
<span>首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP 的消息不会自动重发,仍可在下游投递记录中手工重投。</span>
</div>
</div>
</div>
</section>
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<div>
<h3>运营商通道组</h3>
<p>至少选择一个运营商通道组;每个运营商只能绑定同运营商通道组。</p>
</div>
<Tag tone={selectedGroupCount > 0 ? 'success' : 'warning'}>{selectedGroupCount}/3 已配置</Tag>
</div>
<div className="admin-app-route-grid">
{routeCards.map((card) => {
const available = groups.filter((group) => group.carrier === card.carrier);
const meta = carrierMeta[card.carrier];
return (
<div className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')} key={card.carrier}>
<header>
<span><RadioTower size={18} /></span>
<div>
<strong>{meta.label}通道组</strong>
<small>{meta.description}</small>
</div>
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>{card.groupId ? '已选择' : `${available.length} 个可选`}</Tag>
</header>
<Select
label={`${meta.label}通道组`}
onChange={(event) => card.onChange(event.target.value)}
options={groupOptionsByCarrier(card.carrier)}
value={card.groupId}
/>
{!available.length ? <p>暂无可用{meta.label}通道组,请先在通道组管理创建。</p> : null}
</div>
);
})}
</div>
</section>
<div className="enterprise-form-footer">
<Button disabled={!appName || selectedGroupCount === 0 || saving} onClick={() => { void submit(); }}>{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}</Button>
<Button onClick={goBack} variant="ghost">取消</Button>
</div>
</div>
</section>
);
}
function getRouteGroupId(routeRules: DictionaryItem[], carrier: Carrier) {
const rule = routeRules.find((item) => item.carrier === carrier);
return typeof rule?.groupId === 'string' ? rule.groupId : '';
}
function parseIpAllowlist(value: string) {
return value
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function generateApplicationPassword() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID().replace(/-/g, '').slice(0, 16);
}
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
}