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

458 lines
27 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, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
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: '电信号码只会进入电信通道组' },
};
const deliveryModeOptions = [
{ label: '仅 CMPP', value: 'cmpp' },
{ label: '仅 HTTP', value: 'http' },
{ label: 'CMPP + HTTP 双投', value: 'both' },
{ label: '不投递', value: 'none' },
];
const httpCapabilityOptions: Array<{ key: keyof HttpApiConfig; label: string }> = [
{ key: 'sendEnabled', label: '单条发送' },
{ key: 'messageQueryEnabled', label: '状态查询' },
{ key: 'receiptWebhookEnabled', label: '回执回调' },
{ key: 'uplinkQueryEnabled', label: '上行查询' },
{ key: 'uplinkWebhookEnabled', label: '上行回调' },
{ key: 'credentialSelfServiceEnabled', label: '客户端自助密钥' },
];
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 [applicationExtension, setApplicationExtension] = useState('');
const [accessNumberFillEnabled, setAccessNumberFillEnabled] = useState(false);
const [accessNumberFillPrefix, setAccessNumberFillPrefix] = 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 [httpConfig, setHttpConfig] = useState<HttpApiConfig>({
enabled: false, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true,
uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true,
qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90,
maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http',
webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true,
allowClientManualRetry: true, allowClientTest: true,
});
const [httpIpAddress, setHttpIpAddress] = 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]);
useEffect(() => {
if (!appId) return;
let cancelled = false;
adminApi.getApplicationHttpApiConfig(appId).then((result) => {
if (cancelled) return;
if (result.config) setHttpConfig(result.config);
setHttpIpAddress(result.ipAllowlist.join('\n'));
}).catch((failure: Error) => {
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
});
return () => { cancelled = true; };
}, [appId]);
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(moneyUnitsToYuan(application.customerUnitPrice).toFixed(4));
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
setCmppAccount(application.cmppAccount ?? '');
setApplicationExtension(application.cmppApplicationExtension ?? '');
setAccessNumberFillEnabled(application.cmppAccessNumberFillEnabled === true);
setAccessNumberFillPrefix(application.cmppAccessNumberFillPrefix ?? '');
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;
}
if (!isValidMoneyInput(customerUnitPrice)) {
setError('客户单价必须是非负金额,且最多保留小数点后 4 位');
return;
}
const normalizedExtension = applicationExtension.trim();
const normalizedFillPrefix = accessNumberFillPrefix.trim();
if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) {
setError('应用扩展码只能填写数字');
return;
}
if (accessNumberFillEnabled && !normalizedExtension) {
setError('开启接入号填充时必须填写应用扩展码');
return;
}
if (accessNumberFillEnabled && !/^\d+$/.test(normalizedFillPrefix)) {
setError('开启接入号填充时必须填写数字格式的填充前缀');
return;
}
if (`${accessNumberFillEnabled ? normalizedFillPrefix : ''}${normalizedExtension}`.length > 21) {
setError('客户侧接入号不能超过 21 位');
return;
}
const payload = {
name: appName,
scene,
dailyLimit: Number(dailyLimit) || undefined,
customerUnitPrice: yuanToMoneyUnits(customerUnitPrice),
queuePriority,
cmppAccount: cmppAccount.trim() || undefined,
cmppApplicationExtension: normalizedExtension,
cmppAccessNumberFillEnabled: accessNumberFillEnabled,
cmppAccessNumberFillPrefix: accessNumberFillEnabled ? normalizedFillPrefix : '',
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',
})),
});
await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) });
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 clientSrcIdPreview = `${accessNumberFillEnabled ? accessNumberFillPrefix.trim() : ''}${applicationExtension.trim()}`;
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 step="0.0001" type="number" 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 hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" 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 admin-app-protocol-section admin-app-protocol-section--cmpp">
<div className="ui-detail-section__header admin-app-protocol-header">
<div className="admin-app-protocol-heading">
<span className="admin-app-protocol-icon"><RadioTower size={19} /></span>
<div><h3>CMPP 接入配置</h3><p>管理客户端长连接、账号、接入号与下游回执投递。</p></div>
</div>
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
<span />
{interfaceEnabled ? '已开通' : '未开通'}
</button>
</div>
{interfaceEnabled ? (
<div className="admin-app-form-grid admin-app-protocol-body">
<div className="admin-app-form-row admin-app-form-row--wide">
<span>CMPP 协议</span>
<div className="radio-row"><label><input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />CMPP2.0</label></div>
</div>
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
<Input
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
label="应用扩展码"
onChange={(event) => setApplicationExtension(event.target.value)}
placeholder="例如 0001"
value={applicationExtension}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>客户接入号填充</span>
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button"><span />{accessNumberFillEnabled ? '开启' : '关闭'}</button>
<div className="admin-app-form-tip"><Info size={17} /><span>填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id 时去掉开头前缀,上游发送时只拼接真实应用扩展码。</span></div>
</div>
{accessNumberFillEnabled ? <Input hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。" label="填充前缀" onChange={(event) => setAccessNumberFillPrefix(event.target.value)} placeholder="例如 00" required value={accessNumberFillPrefix} /> : null}
<Input disabled hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。" label="客户侧接入号" placeholder="根据填充前缀和应用扩展码自动生成" value={clientSrcIdPreview} />
<Input
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
label="CMPP 接口密码"
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="CMPP IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span>CMPP 下游投递策略</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>
) : <div className="admin-app-protocol-empty">CMPP 接口未开通,账号、接入号和长连接参数已收起。</div>}
</section>
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--http">
<div className="ui-detail-section__header admin-app-protocol-header">
<div className="admin-app-protocol-heading">
<span className="admin-app-protocol-icon"><Globe2 size={19} /></span>
<div><h3>HTTP 接口配置</h3><p>管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。</p></div>
</div>
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => current.enabled ? { ...current, enabled: false } : {
...current,
enabled: true,
sendEnabled: true,
messageQueryEnabled: true,
receiptWebhookEnabled: true,
uplinkWebhookEnabled: true,
uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true,
receiptDeliveryMode: 'http',
uplinkDeliveryMode: 'http',
})} type="button"><span />{httpConfig.enabled ? '已开通' : '未开通'}</button>
</div>
{httpConfig.enabled ? (
<div className="admin-app-form-grid admin-app-protocol-body">
<div className="admin-app-form-row admin-app-form-row--wide">
<span>HTTP 能力</span>
<div className="radio-row">
{httpCapabilityOptions.map(({ key, label }) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
</div>
<div className="admin-app-form-tip"><Info size={17} /><span>访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。</span></div>
</div>
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" value={httpIpAddress} />
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
<Select label="回执投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, receiptDeliveryMode: event.target.value as HttpApiConfig['receiptDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.receiptDeliveryMode} />
<Select label="上行投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} />
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
<div className="admin-app-form-row admin-app-form-row--wide"><span>HTTP 安全与重试</span><div className="radio-row">
<label><input checked={httpConfig.requireHttps} onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))} type="checkbox" />生产回调强制 HTTPS</label>
<label><input checked={httpConfig.webhookRetryEnabled} onChange={() => setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))} type="checkbox" />Webhook 自动重试</label>
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" />允许客户端手工重投</label>
</div></div>
</div>
) : <div className="admin-app-protocol-empty">HTTP 接口未开通,能力、鉴权和 Webhook 参数已收起。</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('');
}