feat: add access number routing and admin list improvements

This commit is contained in:
hectorzhao
2026-07-15 15:45:27 +08:00
parent 27c464246b
commit 3e114ee729
19 changed files with 547 additions and 508 deletions
+11 -2
View File
@@ -780,6 +780,10 @@ export type EnterpriseApplication = {
downstreamUplinkRetryEnabled?: boolean | null;
cmppAccount?: string | null;
cmppEnterpriseCode?: string | null;
cmppApplicationExtension?: string | null;
cmppAccessNumberFillEnabled?: boolean | null;
cmppAccessNumberFillPrefix?: string | null;
cmppClientSrcId?: string | null;
interfaceEnabled?: boolean | null;
interfaceType?: 'cmpp20' | string | null;
cmppMaxConnections?: number | null;
@@ -846,6 +850,9 @@ export type ApplicationCmppParams = {
account: string;
passwordCipher: string;
srcId: string;
applicationExtension?: string | null;
accessNumberFillEnabled?: boolean;
accessNumberFillPrefix?: string | null;
interfaceEnabled?: boolean;
interfaceType?: string;
maxConnections: number;
@@ -1028,9 +1035,9 @@ export const adminApi = {
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
getEnterpriseApplication: (id: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeApplicationStatus: (id: string, status: string, reason?: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
@@ -1195,6 +1202,8 @@ export const adminApi = {
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }),
rejectRiskReviewTask: (id: string, reason?: string) =>
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }),
rejectRiskReviewTasks: (ids: string[], reason: string) =>
request<RiskReviewTask[]>('/admin/risk-review/tasks/batch/reject', { method: 'POST', body: JSON.stringify({ ids, reason }) }),
listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/sensitive-words', query)),
createSensitiveWord: (body: { word: string; level?: string; status?: string }) =>
request<DictionaryItem>('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }),
+1 -1
View File
@@ -621,7 +621,7 @@ export function AdminChannelsPage() {
</div>
<div className="sms-channel-carrier-price">
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
<strong>{channel.unitPrice.toFixed(3)} </strong>
<strong>{channel.unitPrice.toFixed(2)} </strong>
</div>
<div className="sms-channel-status-cell">
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
@@ -24,6 +24,9 @@ export function AdminSmsApplicationFormPage() {
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');
@@ -84,6 +87,9 @@ export function AdminSmsApplicationFormPage() {
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3));
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');
@@ -119,6 +125,24 @@ export function AdminSmsApplicationFormPage() {
setError('请至少配置一个运营商通道组');
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,
@@ -126,6 +150,9 @@ export function AdminSmsApplicationFormPage() {
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
queuePriority,
cmppAccount: cmppAccount.trim() || undefined,
cmppApplicationExtension: normalizedExtension,
cmppAccessNumberFillEnabled: accessNumberFillEnabled,
cmppAccessNumberFillPrefix: accessNumberFillEnabled ? normalizedFillPrefix : '',
passwordCipher: passwordCipher.trim() || undefined,
interfaceEnabled,
interfaceType,
@@ -164,6 +191,7 @@ export function AdminSmsApplicationFormPage() {
...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 },
@@ -249,6 +277,35 @@ export function AdminSmsApplicationFormPage() {
</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="接口密码"
+15 -4
View File
@@ -26,7 +26,7 @@ export function AdminSmsAuditPage() {
const [date, setDate] = useState('');
const [error, setError] = useState('');
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | null>(null);
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
@@ -67,13 +67,22 @@ export function AdminSmsAuditPage() {
}
async function rejectRecord() {
if (!rejectTarget) return;
if (!rejectTarget || rejectTarget === 'batch') return;
await adminApi.rejectRiskReviewTask(rejectTarget.id, rejectReason || '运营审核驳回');
setRejectTarget(null);
setRejectReason('');
loadData();
}
async function rejectBatch() {
if (!rejectReason.trim()) return;
await adminApi.rejectRiskReviewTasks(selectedIds, rejectReason.trim());
setRejectTarget(null);
setRejectReason('');
setSelectedIds([]);
loadData();
}
const selectableIds = filteredRecords.filter((item) => item.status === 'pending_review').map((item) => item.id);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
const columns: Array<TableColumn<RiskReviewTask>> = [
@@ -137,6 +146,7 @@ export function AdminSmsAuditPage() {
<div className="sms-bulk-actions">
<span> {selectedIds.length} </span>
<Button disabled={selectedIds.length === 0} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button>
<Button disabled={selectedIds.length === 0} icon={<X size={16} />} onClick={() => setRejectTarget('batch')} variant="danger"></Button>
</div>
</div>
@@ -162,13 +172,14 @@ export function AdminSmsAuditPage() {
footer={(
<>
<Button onClick={() => setRejectTarget(null)} variant="ghost"></Button>
<Button disabled={!rejectReason.trim()} onClick={() => void rejectRecord()} variant="danger"></Button>
<Button disabled={!rejectReason.trim()} onClick={() => rejectTarget === 'batch' ? void rejectBatch() : void rejectRecord()} variant="danger"></Button>
</>
)}
onClose={() => setRejectTarget(null)}
open={Boolean(rejectTarget)}
title="确认驳回"
title={rejectTarget === 'batch' ? '批量驳回' : '确认驳回'}
>
{rejectTarget === 'batch' ? <p> {selectedIds.length} </p> : null}
<Textarea label="驳回原因" onChange={(event) => setRejectReason(event.target.value)} rows={4} value={rejectReason} />
</Modal>
</section>