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

237 lines
11 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, RadioTower } 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';
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 [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));
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),
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="每任务最大号码数" 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);
}