feat: add HTTP API and complete client workflows
This commit is contained in:
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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 可独立开通;回执和上行可按 CMPP、HTTP、双投或不投递配置。</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
|
||||
|
||||
Reference in New Issue
Block a user