458 lines
27 KiB
TypeScript
458 lines
27 KiB
TypeScript
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('');
|
||
}
|