292 lines
14 KiB
TypeScript
292 lines
14 KiB
TypeScript
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';
|
||
|
||
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 [cmppMaxConnections, setCmppMaxConnections] = useState('1');
|
||
const [cmppWindowSize, setCmppWindowSize] = useState('16');
|
||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
||
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
||
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(4));
|
||
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
|
||
setCmppAccount(application.cmppAccount ?? '');
|
||
setCmppEnterpriseCode(application.cmppEnterpriseCode ?? application.tenant?.code ?? '');
|
||
setPasswordCipher('');
|
||
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
|
||
setCmppWindowSize(String(application.cmppWindowSize ?? 16));
|
||
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
|
||
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
|
||
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,
|
||
cmppMaxConnections: Number(cmppMaxConnections) || 1,
|
||
cmppWindowSize: Number(cmppWindowSize) || 16,
|
||
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
|
||
templateMismatchMode: mismatchPolicy,
|
||
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.0300" required value={customerUnitPrice} />
|
||
<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="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
|
||
<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}
|
||
/>
|
||
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
||
</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('');
|
||
}
|