feat: add HTTP API and complete client workflows

This commit is contained in:
hectorzhao
2026-07-16 11:34:06 +08:00
parent 4f07b331e5
commit dcb6162dcf
40 changed files with 2548 additions and 365 deletions
@@ -45,25 +45,8 @@ type SignatureFormState = {
reportValues: ReportValues;
};
const statusLabelMap: Record<CarrierStatus, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
filing: '待报备',
};
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger' | 'neutral'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
filing: 'neutral',
};
function StatusTag({ status }: { status: CarrierStatus }) {
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
}
type CarrierReportSummary = { status: string; approved: number; total: number };
type SignatureCardTone = 'green' | 'blue' | 'amber' | 'red' | 'gray';
function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral"></Tag>;
@@ -73,10 +56,26 @@ function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
else if (summary.status === 'failed' || summary.status === 'rejected') { label = '报备失败'; tone = 'danger'; }
else if (summary.status === 'waiting_material') { label = '资料待补充'; tone = 'warning'; }
else if (summary.approved > 0) { label = '部分通过'; tone = 'info'; }
else if (summary.status === 'reporting' || summary.status === 'exporting') { label = '报备中'; tone = 'info'; }
else if (summary.status === 'reporting' || summary.status === 'exporting') { label = '报备中'; tone = 'warning'; }
return <span className="carrier-report-summary"><Tag tone={tone}>{label}</Tag><small>{summary.approved}/{summary.total}</small></span>;
}
function signatureCardVisual(auditStatus: string, summaries?: Record<string, CarrierReportSummary>) {
if (auditStatus === 'rejected') return { label: '签名审核已驳回', tone: 'red' as SignatureCardTone };
if (auditStatus === 'pending') return { label: '签名待审核', tone: 'amber' as SignatureCardTone };
if (auditStatus !== 'approved') return { label: '签名尚未提交审核', tone: 'gray' as SignatureCardTone };
const values = Object.values(summaries ?? {});
const applicable = values.filter((summary) => summary.total > 0 && summary.status !== 'not_applicable');
if (applicable.some((summary) => ['failed', 'rejected'].includes(summary.status))) return { label: '存在报备失败', tone: 'red' as SignatureCardTone };
if (applicable.some((summary) => summary.approved > 0 && summary.approved < summary.total)) return { label: '部分通道报备通过', tone: 'blue' as SignatureCardTone };
if (applicable.some((summary) => summary.status === 'waiting_material')) return { label: '报备资料待补充', tone: 'amber' as SignatureCardTone };
if (applicable.some((summary) => ['reporting', 'exporting'].includes(summary.status))) return { label: '通道报备处理中', tone: 'amber' as SignatureCardTone };
if (applicable.length > 0 && applicable.every((summary) => summary.status === 'approved')) return { label: '所有目标通道报备通过', tone: 'green' as SignatureCardTone };
if (applicable.some((summary) => summary.approved > 0)) return { label: '部分运营商报备通过', tone: 'blue' as SignatureCardTone };
return { label: applicable.length > 0 ? '目标通道尚未报备' : '没有适用的目标通道', tone: 'gray' as SignatureCardTone };
}
function AuditStatusTag({ status }: { status: string }) {
const meta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
draft: { label: '草稿', tone: 'neutral' }, pending: { label: '待审核', tone: 'info' }, approved: { label: '已通过', tone: 'success' }, rejected: { label: '已驳回', tone: 'danger' },
@@ -151,14 +150,6 @@ function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filin
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
}
function signatureCardTone(statuses: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }) {
const values = Object.values(statuses);
if (values.includes('rejected')) return 'red';
if (values.every((status) => status === 'approved')) return 'green';
if (values.includes('pending')) return 'blue';
return 'gray';
}
function formatDate(value?: string) {
return formatDateTime(value);
}
@@ -697,11 +688,10 @@ export function AdminEnterpriseSignaturesPage() {
const visibleDrainageLinks = appliedDrainageKeyword
? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword))
: payload.links;
const summaryStatuses = Object.values(signature.carrierReportSummary ?? {}).map((summary) => summary.status);
const cardTone = summaryStatuses.includes('failed') ? 'red' : summaryStatuses.length > 0 && summaryStatuses.every((status) => status === 'approved' || status === 'not_applicable') ? 'green' : summaryStatuses.some((status) => status === 'reporting') ? 'blue' : 'gray';
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
return (
<article className={`signature-card signature-card--${cardTone}`} key={signature.id}>
<article aria-label={`签名总体状态:${cardVisual.label}`} className={`signature-card signature-card--${cardVisual.tone}`} key={signature.id} title={`总体状态:${cardVisual.label}`}>
<div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
+28 -23
View File
@@ -136,6 +136,16 @@ export function AdminPhoneSegmentsPage() {
{ key: 'remark', title: '备注', render: (record) => record.remark ?? '-' },
], []);
const queryPanel = (
<div className="phone-segment-query">
<Input label="关键词" onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '手机号段、运营商、省份或城市' : '运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<div className="phone-segment-query__actions">
<Button icon={<Search size={16} />} onClick={query}></Button>
<Button icon={<RotateCcw size={16} />} onClick={reset} variant="ghost"></Button>
</div>
</div>
);
return (
<section className="page-stack admin-system-page phone-segment-workbench">
<div className="page-heading">
@@ -149,25 +159,6 @@ export function AdminPhoneSegmentsPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="phone-segment-overview" aria-label="号段数据概览">
<section>
<span><Database size={20} /></span>
<div><strong>{segmentTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
<section>
<span><ListFilter size={20} /></span>
<div><strong>{ruleTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
</div>
<div className="surface phone-segment-query">
<Input label="关键词" onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '手机号段、运营商、省份或城市' : '运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<div className="phone-segment-query__actions">
<Button icon={<Search size={16} />} onClick={query}></Button>
<Button icon={<RotateCcw size={16} />} onClick={reset} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-system-table-card">
<Tabs
className="phone-segment-workbench__tabs"
@@ -180,7 +171,14 @@ export function AdminPhoneSegmentsPage() {
label: '手机号段',
value: 'segments',
content: (
<>
<div className="phone-segment-tab-content">
<div className="phone-segment-overview phone-segment-overview--single" aria-label="手机号段统计">
<section>
<span><Database size={20} /></span>
<div><strong>{segmentTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
</div>
{queryPanel}
<Table columns={columns} data={segments} emptyText={loading ? '加载中...' : '暂无手机号段'} pagination={false} rowKey="id" />
<Pagination
page={page}
@@ -192,14 +190,21 @@ export function AdminPhoneSegmentsPage() {
total={segmentTotal}
totalPages={segmentTotalPages}
/>
</>
</div>
),
},
{
label: '运营商区分规则',
value: 'rules',
content: (
<>
<div className="phone-segment-tab-content">
<div className="phone-segment-overview phone-segment-overview--single" aria-label="运营商区分规则统计">
<section>
<span><ListFilter size={20} /></span>
<div><strong>{ruleTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
</div>
{queryPanel}
<Table columns={ruleColumns} data={rules} emptyText={loading ? '加载中...' : '暂无运营商区分规则'} pagination={false} rowKey="id" />
<Pagination
page={rulePage}
@@ -211,7 +216,7 @@ export function AdminPhoneSegmentsPage() {
onNext={() => setRulePage((current) => Math.min(ruleTotalPages, current + 1))}
onPageChange={setRulePage}
/>
</>
</div>
),
},
]}
+65 -8
View File
@@ -1,7 +1,7 @@
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 { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom';
@@ -14,6 +14,13 @@ const carrierMeta: Record<Carrier, { label: string; description: string }> = {
telecom: { label: '电信', description: '电信号码只会进入电信通道组' },
};
const deliveryModeOptions = [
{ label: '仅 CMPP', value: 'cmpp' },
{ label: '仅 HTTP', value: 'http' },
{ label: 'CMPP + HTTP 双投', value: 'both' },
{ label: '不投递', value: 'none' },
];
export function AdminSmsApplicationFormPage() {
const navigate = useNavigate();
const { enterpriseId, appId } = useParams();
@@ -36,6 +43,15 @@ export function AdminSmsApplicationFormPage() {
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
const [ipAddress, setIpAddress] = useState('');
const [httpConfig, setHttpConfig] = useState<HttpApiConfig>({
enabled: false, sendEnabled: false, messageQueryEnabled: false, receiptWebhookEnabled: false,
uplinkWebhookEnabled: false, uplinkQueryEnabled: false, credentialSelfServiceEnabled: false,
qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90,
maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'cmpp', uplinkDeliveryMode: 'cmpp',
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('');
@@ -76,6 +92,19 @@ export function AdminSmsApplicationFormPage() {
};
}, [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');
}
@@ -178,6 +207,7 @@ export function AdminSmsApplicationFormPage() {
status: 'active',
})),
});
await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) });
goBack();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '短信应用保存失败');
@@ -252,29 +282,56 @@ export function AdminSmsApplicationFormPage() {
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<h3></h3>
<p> CMPP </p>
<p>CMPP HTTP CMPPHTTP</p>
</div>
<div className="admin-app-form-grid">
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<span>CMPP </span>
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
<span />
{interfaceEnabled ? '开通' : '关闭'}
</button>
</div>
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<span>CMPP </span>
<div className="radio-row">
<label>
<input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />
CMPP2.0
</label>
<label className="is-disabled">
<input disabled type="radio" />
HTTP接口
</label>
</div>
</div>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>HTTP </span>
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => ({ ...current, enabled: !current.enabled }))} type="button"><span />{httpConfig.enabled ? '开通' : '关闭'}</button>
<div className="admin-app-form-tip"><Info size={17} /><span>/访</span></div>
</div>
{httpConfig.enabled ? (
<>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>HTTP </span>
<div className="radio-row">
{([
['sendEnabled', '单条发送'], ['messageQueryEnabled', '状态查询'], ['receiptWebhookEnabled', '回执回调'],
['uplinkQueryEnabled', '上行查询'], ['uplinkWebhookEnabled', '上行回调'], ['credentialSelfServiceEnabled', '客户端自助密钥'],
] as Array<[keyof HttpApiConfig, string]>).map(([key, label]) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
</div>
</div>
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="独立于CMPP;多个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></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>
</>
) : null}
<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