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
+4 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { ClipboardCopy, FileText } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
import { formatCents } from '@/utils/currency';
import { Button, Modal, Pagination, Tag } from '@/components/ui';
@@ -56,6 +57,7 @@ function mapParams(params: ApplicationCmppParams): ParamRow[] {
}
export function ClientApplicationsPage() {
const navigate = useNavigate();
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [selectedApp, setSelectedApp] = useState<ClientSmsApplication | null>(null);
const [params, setParams] = useState<ApplicationCmppParams | null>(null);
@@ -158,8 +160,9 @@ export function ClientApplicationsPage() {
<dt>CMPP连接状态</dt>
<dd><Tag tone={statusToneMap[linkStatus]}>{statusLabelMap[linkStatus]}</Tag></dd>
</div>
<div><dt>HTTP接口</dt><dd><Tag tone={application.httpConfig?.enabled ? 'success' : 'info'}>{application.httpConfig?.enabled ? '已开通' : '未开通'}</Tag></dd></div>
</dl>
<Button onClick={() => openParams(application)} variant="ghost"></Button>
<div className="table-actions"><Button onClick={() => openParams(application)} variant="ghost">CMPP参数</Button><Button disabled={!application.httpConfig?.enabled} onClick={() => navigate('/client/http-api')} variant="ghost">HTTP接口对接</Button></div>
</article>
);
})}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { BookOpen, Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
import { clientApi, type ClientSmsApplication, type HttpApiConfigResponse, type HttpApiCredential, type HttpApiRequestLog, type HttpWebhookDelivery, type HttpWebhookEndpoint } from '@/api/adminApi';
import { Button, Input, Select, Tabs, Tag } from '@/components/ui';
export function ClientHttpApiPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [applicationId, setApplicationId] = useState('');
const [config, setConfig] = useState<HttpApiConfigResponse | null>(null);
const [credentials, setCredentials] = useState<HttpApiCredential[]>([]);
const [webhooks, setWebhooks] = useState<HttpWebhookEndpoint[]>([]);
const [requests, setRequests] = useState<HttpApiRequestLog[]>([]);
const [deliveries, setDeliveries] = useState<HttpWebhookDelivery[]>([]);
const [receiptUrl, setReceiptUrl] = useState('');
const [uplinkUrl, setUplinkUrl] = useState('');
const [revealedSecret, setRevealedSecret] = useState<{ title: string; accessKey?: string; secret: string } | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
clientApi.listApplications().then((items) => {
const active = items.filter((item) => item.status !== 'deleted');
setApplications(active);
setApplicationId((current) => current || active[0]?.id || '');
}).catch((failure: Error) => setError(failure.message)).finally(() => setLoading(false));
}, []);
async function loadApplication(id: string) {
if (!id) return;
setLoading(true);
setError('');
try {
const [nextConfig, nextCredentials, nextWebhooks, nextRequests, nextDeliveries] = await Promise.all([
clientApi.getApplicationHttpApiConfig(id), clientApi.listHttpApiCredentials(id), clientApi.listHttpWebhooks(id),
clientApi.listHttpApiRequests(id), clientApi.listHttpWebhookDeliveries(id),
]);
setConfig(nextConfig);
setCredentials(nextCredentials);
setWebhooks(nextWebhooks);
setRequests(nextRequests);
setDeliveries(nextDeliveries);
setReceiptUrl(nextWebhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
setUplinkUrl(nextWebhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
} catch (failure) { setError(failure instanceof Error ? failure.message : 'HTTP接口资料加载失败'); }
finally { setLoading(false); }
}
useEffect(() => { void loadApplication(applicationId); }, [applicationId]);
async function createCredential() {
try {
const created = await clientApi.createHttpApiCredential(applicationId, { name: `客户端凭据 ${credentials.length + 1}` });
setRevealedSecret({ title: '访问凭据仅展示一次,请立即保存', accessKey: created.accessKey, secret: created.secret ?? '' });
await loadApplication(applicationId);
} catch (failure) { setError(failure instanceof Error ? failure.message : '创建凭据失败'); }
}
async function saveWebhook(eventType: 'receipt' | 'uplink', rotateSecret = false) {
const url = eventType === 'receipt' ? receiptUrl : uplinkUrl;
try {
const saved = await clientApi.saveHttpWebhook(applicationId, eventType, { url, rotateSecret });
if (saved.secret) setRevealedSecret({ title: `${eventType === 'receipt' ? '回执' : '上行'}回调签名密钥仅展示一次`, secret: saved.secret });
await loadApplication(applicationId);
} catch (failure) { setError(failure instanceof Error ? failure.message : '保存Webhook失败'); }
}
const api = config?.config;
const overview = <div className="page-stack">
{!api?.enabled ? <p className="form-error"> HTTP </p> : null}
<div className="surface" style={{ padding: 18 }}><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">{window.location.origin}/api/openapi/v1</p><div className="table-actions">
<Tag tone={api?.sendEnabled ? 'success' : 'info'}> {api?.sendEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.messageQueryEnabled ? 'success' : 'info'}> {api?.messageQueryEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.uplinkQueryEnabled ? 'success' : 'info'}> {api?.uplinkQueryEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.receiptWebhookEnabled ? 'success' : 'info'}> {api?.receiptWebhookEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.uplinkWebhookEnabled ? 'success' : 'info'}> {api?.uplinkWebhookEnabled ? '已开通' : '未开通'}</Tag>
</div></div>
<div className="surface" style={{ padding: 18 }}><h3></h3><p>QPS{api?.qpsLimit ?? '-'} · {api?.timestampToleranceSeconds ?? '-'} · {api?.maxQueryRangeDays ?? '-'} · {api?.maxPageSize ?? '-'}</p><p className="muted">HTTP IP {config?.ipAllowlist.join('、') || '未限制'}</p></div>
</div>;
const credentialPanel = <div className="page-stack"><div className="section-heading"><div><h3><KeyRound size={17} />访</h3><p className="muted"></p></div><Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void createCredential()}></Button></div>
{credentials.map((item) => <div className="surface" key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr 100px 1fr auto', gap: 12, padding: 14, alignItems: 'center' }}><strong>{item.name}</strong><code>{item.accessKey}</code><span>****{item.secretLast4}</span><span className="muted">使{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}</span>{item.status === 'active' ? <Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void clientApi.revokeHttpApiCredential(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="danger"></Button> : <Tag tone="info"></Tag>}</div>)}
{credentials.length === 0 ? <p className="muted">访</p> : null}
</div>;
const callbackPanel = <div className="page-stack"><div className="surface" style={{ padding: 18 }}><h3><Webhook size={17} /> </h3><Input label="回调 URL" onChange={(event) => setReceiptUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/receipt" value={receiptUrl} /><div className="table-actions" style={{ marginTop: 12 }}><Button onClick={() => void saveWebhook('receipt')}></Button>{webhooks.some((item) => item.eventType === 'receipt') ? <Button onClick={() => void saveWebhook('receipt', true)} variant="ghost"></Button> : null}</div></div>
<div className="surface" style={{ padding: 18 }}><h3><Webhook size={17} /> </h3><Input label="回调 URL" onChange={(event) => setUplinkUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/uplink" value={uplinkUrl} /><div className="table-actions" style={{ marginTop: 12 }}><Button onClick={() => void saveWebhook('uplink')}></Button>{webhooks.some((item) => item.eventType === 'uplink') ? <Button onClick={() => void saveWebhook('uplink', true)} variant="ghost"></Button> : null}</div></div></div>;
const docsPanel = <div className="page-stack"><div className="surface" style={{ padding: 18 }}><h3><BookOpen size={17} /> </h3><p> <code>X-App-Key</code><code>X-Timestamp</code><code>X-Nonce</code><code>X-Signature</code></p><pre>{`METHOD
/api/openapi/v1/...
TIMESTAMP
NONCE
SHA256(rawBody)`}</pre><p>使访 HMAC-SHA256 <code>Idempotency-Key</code></p></div>
<div className="surface" style={{ padding: 18 }}><h3></h3><pre>{`POST /api/openapi/v1/sms/messages\nGET /api/openapi/v1/sms/messages/{messageId}\nGET /api/openapi/v1/sms/uplinks\nGET /api/openapi/v1/sms/uplinks/{uplinkId}`}</pre><p className="muted"> OpenAPI <a href="/api/client-docs" rel="noreferrer" target="_blank">/api/client-docs</a></p></div>
<div className="surface" style={{ padding: 18 }}><h3></h3><p> X-Event-IdX-Event-TypeX-TimestampX-Signature <code>TIMESTAMP + '\\n' + rawBody</code>使 HMAC-SHA256 X-Event-Id </p></div></div>;
const logsPanel = <div className="page-stack"><div className="surface" style={{ padding: 16 }}><h3></h3>{requests.map((item) => <p key={item.id}><code>{item.requestId}</code> · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} · {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}</p>)}{requests.length === 0 ? <p className="muted"></p> : null}</div>
<div className="surface" style={{ padding: 16 }}><h3></h3>{deliveries.map((item) => <div className="section-heading" key={item.id}><p><code>{item.event.eventId}</code> · {item.endpoint.eventType} · {item.status} · {item.attemptCount} {item.lastError ? ` · ${item.lastError}` : ''}</p>{item.status !== 'delivered' && api?.allowClientManualRetry ? <Button icon={<RefreshCw size={14} />} onClick={() => void clientApi.retryHttpWebhookDelivery(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="ghost"></Button> : null}</div>)}{deliveries.length === 0 ? <p className="muted"></p> : null}</div></div>;
const tabs = [
{ label: '接口概览', value: 'overview', content: overview }, { label: '访问凭据', value: 'credentials', content: credentialPanel },
{ label: '回调配置', value: 'callbacks', content: callbackPanel }, { label: '接口文档', value: 'docs', content: docsPanel },
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
];
return <section className="page-stack"><div className="page-heading"><div><h1></h1><p> HTTP 访</p></div><Select label="企业应用" onChange={(event) => setApplicationId(event.target.value)} options={applications.map((item) => ({ label: item.name, value: item.id }))} value={applicationId} /></div>
{loading ? <p className="muted">...</p> : null}{error ? <p className="form-error">{error}</p> : null}
{revealedSecret ? <div className="surface" style={{ border: '1px solid #f59e0b', padding: 16 }}><strong>{revealedSecret.title}</strong>{revealedSecret.accessKey ? <p>Access Key<code>{revealedSecret.accessKey}</code></p> : null}<p>Secret<code>{revealedSecret.secret}</code></p><Button icon={<Copy size={14} />} onClick={() => void navigator.clipboard.writeText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n'))} size="sm"></Button></div> : null}
{!loading && applicationId ? <Tabs items={tabs} /> : null}
</section>;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
import { formatCents } from '@/utils/currency';
type Recipient = {
@@ -15,7 +15,7 @@ type ReceiverMode = 'manual' | 'import';
export function ClientSendPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
const [error, setError] = useState('');
const [taskName, setTaskName] = useState('');
const [applicationId, setApplicationId] = useState('');
+294 -236
View File
@@ -1,111 +1,231 @@
import { useEffect, useMemo, useState } from 'react';
import { Edit3, FilePenLine, Globe2, Plus, Search, Trash2, Upload } from 'lucide-react';
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
import { Button, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
import {
clientApi,
type ClientApplicationReportField,
type ClientSignatureWorkspace,
type ClientSmsApplication,
type ClientSmsSignatureView,
type FileRef,
} from '@/api/adminApi';
const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
items: [],
summary: { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 },
};
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
draft: 'warning',
approved: 'success', pending: 'info', rejected: 'danger', draft: 'warning',
};
const statusLabel: Record<string, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
draft: '草稿',
disabled: '已禁用',
approved: '审核通过', pending: '资料审核中', rejected: '需修改', draft: '待提交',
};
function materialToFileRef(material: Record<string, unknown>): FileRef | null {
const fileObjectId = String(material.fileObjectId ?? '');
if (!fileObjectId) return null;
const fileName = String(material.title ?? material.fileName ?? '签名材料');
const contentType = typeof material.contentType === 'string' ? material.contentType : undefined;
return { contentType, fileName, fileObjectId };
}
type ClientDrainageInfo = {
id: string;
siteName: string;
url: string;
remark: string;
reportValues: Record<string, unknown>;
auditStatus: string;
rejectReason?: string | null;
submittedAt?: string;
};
function drainageItems(signature: ClientSmsSignature): ClientDrainageInfo[] {
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
return links.map((item) => ({
id: String(item.id ?? ''),
siteName: String(item.siteName ?? ''),
url: String(item.url ?? ''),
remark: String(item.remark ?? ''),
reportValues: item.reportValues && typeof item.reportValues === 'object' ? item.reportValues as Record<string, unknown> : {},
auditStatus: String(item.auditStatus ?? 'pending'),
rejectReason: item.rejectReason ? String(item.rejectReason) : null,
submittedAt: item.submittedAt ? String(item.submittedAt) : undefined,
}));
}
type ClientDrainageInfo = ClientSmsSignatureView['drainageInfo']['links'][number];
function reportFileRef(value: unknown): FileRef | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const item = value as Record<string, unknown>;
const fileObjectId = String(item.fileObjectId ?? '');
const fileName = String(item.fileName ?? '');
return fileObjectId && fileName ? { fileObjectId, fileName, contentType: item.contentType ? String(item.contentType) : undefined } : null;
return fileObjectId && fileName
? { fileObjectId, fileName, contentType: item.contentType ? String(item.contentType) : undefined }
: null;
}
function ClientDrainageModal({ item, onClose, onSaved, signature }: { item?: ClientDrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
const [fields, setFields] = useState<ApplicationReportField[]>([]);
const [siteName, setSiteName] = useState(item?.siteName ?? '');
const [url, setUrl] = useState(item?.url ?? '');
const [remark, setRemark] = useState(item?.remark ?? '');
const [values, setValues] = useState<Record<string, unknown>>(item?.reportValues ?? {});
const [saving, setSaving] = useState(false);
function formatDate(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('zh-CN', { hour12: false });
}
function ReviewFields({
fields,
values,
uploadingCode,
onChange,
onUpload,
}: {
fields: ClientApplicationReportField[];
values: Record<string, unknown>;
uploadingCode: string;
onChange: (code: string, value: unknown) => void;
onUpload: (field: ClientApplicationReportField, file?: File) => void;
}) {
if (!fields.length) return <p className="client-signature-empty-hint"></p>;
return <div className="signature-form-grid">
{fields.map((field) => field.fieldType === 'string'
? <Input
key={field.id}
label={`${field.required ? '* ' : ''}${field.name}`}
onChange={(event) => onChange(field.code, event.target.value)}
value={String(values[field.code] ?? '')}
/>
: <label className="signature-upload" key={field.id}>
<Upload size={26} />
<strong>{reportFileRef(values[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong>
<small>{uploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small>
<FileActions file={reportFileRef(values[field.code])} />
<input
accept={field.fieldType === 'image' ? 'image/*' : undefined}
onChange={(event) => onUpload(field, event.target.files?.[0])}
style={{ display: 'none' }}
type="file"
/>
</label>)}
</div>;
}
function SignatureModal({
applications,
signature,
onClose,
onSaved,
}: {
applications: ClientSmsApplication[];
signature?: ClientSmsSignatureView;
onClose: () => void;
onSaved: () => void;
}) {
const [applicationId, setApplicationId] = useState(signature?.applicationId ?? '');
const [name, setName] = useState(signature?.name ?? '');
const [purpose, setPurpose] = useState(signature?.purpose ?? '');
const [fields, setFields] = useState<ClientApplicationReportField[]>([]);
const [values, setValues] = useState<Record<string, unknown>>(signature?.reportValues ?? {});
const [uploadingCode, setUploadingCode] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const request = signature.applicationId
? clientApi.listApplicationReportFields(signature.applicationId, 'drainage')
: clientApi.listCommonApplicationReportFields('drainage');
request.then(setFields).catch((failure: Error) => setError(failure.message || '引流报备字段加载失败'));
}, [signature.applicationId]);
const request = applicationId
? clientApi.listApplicationReportFields(applicationId, 'signature')
: clientApi.listCommonApplicationReportFields('signature');
request.then(setFields).catch((failure: Error) => setError(failure.message || '审核资料加载失败'));
}, [applicationId]);
async function upload(field: ApplicationReportField, file?: File) {
async function upload(field: ClientApplicationReportField, file?: File) {
if (!file) return;
setUploadingCode(field.code);
setError('');
try {
const uploaded = await clientApi.uploadFileObject(file, { purpose: 'drainage_report_material', prefix: `drainage-materials/${signature.id}` });
const uploaded = await clientApi.uploadFileObject(file, { purpose: 'signature_report_material', prefix: 'signature-materials' });
setValues((current) => ({ ...current, [field.code]: { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType } }));
} catch (failure) { setError(failure instanceof Error ? failure.message : '文件上传失败'); } finally { setUploadingCode(''); }
} catch (failure) {
setError(failure instanceof Error ? failure.message : '资料上传失败');
} finally {
setUploadingCode('');
}
}
async function save() {
setSaving(true);
setError('');
try {
const body = { siteName, url, remark, reportValues: values };
if (item) await clientApi.updateDrainageInfo(item.id, body);
else await clientApi.createDrainageInfo(signature.id, body);
const body = { applicationId: applicationId || undefined, name: name.trim(), purpose: purpose.trim(), drainageInfo: { signatureReportValues: values } };
if (signature) await clientApi.updateSignature(signature.id, body);
else {
const created = await clientApi.createSignature(body);
await clientApi.submitSignature(created.id);
}
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息提交审核失败'); } finally { setSaving(false); }
} catch (failure) {
setError(failure instanceof Error ? failure.message : '签名资料提交失败');
} finally {
setSaving(false);
}
}
const missingRequired = fields.some((field) => field.required && !values[field.code]);
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>} onClose={onClose} open size="xl" title={item ? '修改引流信息' : '新增引流信息'}>
<div className="signature-form drainage-edit-form">
<Input label="引流信息" onChange={(event) => setSiteName(event.target.value)} required value={siteName} />
<Input label="引流地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
<Textarea label="备注" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark} />
<section className="surface" style={{ padding: 16 }}><h3> + </h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
{fields.map((field) => field.fieldType === 'string' ? <Input key={field.id} label={`${field.required ? '* ' : ''}${field.name}`} onChange={(event) => setValues((current) => ({ ...current, [field.code]: event.target.value }))} value={String(values[field.code] ?? '')} /> : <label className="signature-upload" key={field.id}><Upload size={28} /><strong>{reportFileRef(values[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong><small>{uploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small><FileActions file={reportFileRef(values[field.code])} /><input accept={field.fieldType === 'image' ? 'image/*' : undefined} onChange={(event) => void upload(field, event.target.files?.[0])} style={{ display: 'none' }} type="file" /></label>)}
</div></section>
return <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!name.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
onClose={onClose}
open
size="xl"
title={signature ? '修改签名资料' : '新增签名'}
>
<div className="signature-form">
{signature?.rejectReason ? <div className="client-signature-reason"><strong></strong><span>{signature.rejectReason}</span></div> : null}
<Select
label="所属应用"
onChange={(event) => { setApplicationId(event.target.value); setValues({}); }}
options={[{ label: '不绑定应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
value={applicationId}
/>
<Input label="短信签名" onChange={(event) => setName(event.target.value)} placeholder="例如:某某科技(无需填写【】)" required value={name} />
<Input label="使用场景" onChange={(event) => setPurpose(event.target.value)} placeholder="例如:验证码、订单通知" value={purpose ?? ''} />
<section className="client-signature-form-section">
<div><h3></h3><p></p></div>
<ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />
</section>
{error ? <p className="form-error">{error}</p> : null}
</div>
</Modal>;
}
function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDrainageInfo; signature: ClientSmsSignatureView; onClose: () => void; onSaved: () => void }) {
const [fields, setFields] = useState<ClientApplicationReportField[]>([]);
const [siteName, setSiteName] = useState(item?.siteName ?? '');
const [url, setUrl] = useState(item?.url ?? '');
const [remark, setRemark] = useState(item?.remark ?? '');
const [values, setValues] = useState<Record<string, unknown>>(item?.reportValues ?? {});
const [uploadingCode, setUploadingCode] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const request = signature.applicationId
? clientApi.listApplicationReportFields(signature.applicationId, 'drainage')
: clientApi.listCommonApplicationReportFields('drainage');
request.then(setFields).catch((failure: Error) => setError(failure.message || '审核资料加载失败'));
}, [signature.applicationId]);
async function upload(field: ClientApplicationReportField, file?: File) {
if (!file) return;
setUploadingCode(field.code);
try {
const uploaded = await clientApi.uploadFileObject(file, { purpose: 'drainage_report_material', prefix: `drainage-materials/${signature.id}` });
setValues((current) => ({ ...current, [field.code]: { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType } }));
} catch (failure) {
setError(failure instanceof Error ? failure.message : '资料上传失败');
} finally {
setUploadingCode('');
}
}
async function save() {
setSaving(true);
setError('');
try {
const body = { siteName: siteName.trim(), url: url.trim(), remark: remark?.trim(), reportValues: values };
if (item) await clientApi.updateDrainageInfo(item.id, body);
else await clientApi.createDrainageInfo(signature.id, body);
onSaved();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '引流信息提交失败');
} finally {
setSaving(false);
}
}
const missingRequired = fields.some((field) => field.required && !values[field.code]);
return <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
onClose={onClose}
open
size="xl"
title={item ? '修改引流信息' : '新增引流信息'}
>
<div className="signature-form">
{item?.rejectReason ? <div className="client-signature-reason"><strong></strong><span>{item.rejectReason}</span></div> : null}
<Input label="名称" onChange={(event) => setSiteName(event.target.value)} placeholder="例如:品牌官网" required value={siteName} />
<Input label="访问地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
<Textarea label="说明" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark ?? ''} />
<section className="client-signature-form-section">
<div><h3></h3><p></p></div>
<ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />
</section>
{error ? <p className="form-error">{error}</p> : null}
</div>
</Modal>;
@@ -113,193 +233,131 @@ function ClientDrainageModal({ item, onClose, onSaved, signature }: { item?: Cli
export function ClientSignaturesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [workspace, setWorkspace] = useState<ClientSignatureWorkspace>(EMPTY_WORKSPACE);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [applicationId, setApplicationId] = useState('');
const [name, setName] = useState('');
const [purpose, setPurpose] = useState('');
const [signatureFields, setSignatureFields] = useState<ApplicationReportField[]>([]);
const [signatureValues, setSignatureValues] = useState<Record<string, unknown>>({});
const [signatureUploadingCode, setSignatureUploadingCode] = useState('');
const [applicationFilter, setApplicationFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [signatureModal, setSignatureModal] = useState<ClientSmsSignatureView | 'new'>();
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignatureView; item?: ClientDrainageInfo }>();
const [deleting, setDeleting] = useState<{ type: 'signature' | 'drainage'; id: string; name: string }>();
const [page, setPage] = useState(1);
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignature; item?: ClientDrainageInfo }>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
function loadData() {
setLoading(true);
Promise.all([clientApi.listApplications(), clientApi.listSignatures()])
.then(([applicationItems, signatureItems]) => {
Promise.all([clientApi.listApplications(), clientApi.getSignatureWorkspace()])
.then(([applicationItems, signatureWorkspace]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setSignatures(signatureItems.filter((item) => item.auditStatus !== 'disabled' && item.auditStatus !== 'deleted'));
setWorkspace(signatureWorkspace);
setError('');
})
.catch((reason: Error) => setError(reason.message || '签名数据加载失败'))
.catch((failure: Error) => setError(failure.message || '签名与引流信息加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadData();
}, []);
useEffect(loadData, []);
useEffect(() => {
if (!modalOpen) return;
const request = applicationId
? clientApi.listApplicationReportFields(applicationId, 'signature')
: clientApi.listCommonApplicationReportFields('signature');
request.then(setSignatureFields).catch((reason: Error) => setError(reason.message || '签名报备字段加载失败'));
}, [applicationId, modalOpen]);
const filteredItems = useMemo(() => workspace.items.filter((item) => {
const matchesKeyword = !keyword.trim() || [item.name, item.purpose, item.application?.name].join(' ').toLowerCase().includes(keyword.trim().toLowerCase());
return matchesKeyword && (!applicationFilter || item.applicationId === applicationFilter) && (!statusFilter || item.auditStatus === statusFilter);
}), [applicationFilter, keyword, statusFilter, workspace.items]);
const filteredSignatures = useMemo(() => signatures.filter((item) => (
!keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword)
)), [keyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const totalPages = Math.max(1, Math.ceil(filteredItems.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const visibleItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [filteredSignatures.length, keyword]);
useEffect(() => setPage(1), [applicationFilter, keyword, statusFilter]);
async function createSignature() {
function toggleExpanded(id: string) {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function confirmDelete() {
if (!deleting) return;
try {
const signature = await clientApi.createSignature({ applicationId: applicationId || undefined, name, purpose, drainageInfo: { signatureReportValues: signatureValues } });
await clientApi.submitSignature(signature.id);
setModalOpen(false);
setApplicationId('');
setName('');
setPurpose('');
setSignatureFields([]);
setSignatureValues({});
if (deleting.type === 'signature') await clientApi.changeSignatureStatus(deleting.id, 'disabled');
else await clientApi.changeDrainageInfoStatus(deleting.id, 'deleted');
setDeleting(undefined);
loadData();
} catch (reason) {
setError(reason instanceof Error ? reason.message : '签名提交失败');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '删除失败');
}
}
async function uploadSignatureField(field: ApplicationReportField, file?: File) {
if (!file) return;
setSignatureUploadingCode(field.code);
try {
const uploaded = await clientApi.uploadFileObject(file, { purpose: 'signature_report_material', prefix: 'signature-materials' });
setSignatureValues((current) => ({ ...current, [field.code]: { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType } }));
} catch (reason) {
setError(reason instanceof Error ? reason.message : '签名报备资料上传失败');
} finally {
setSignatureUploadingCode('');
}
}
function disableSignature(id: string) {
clientApi.changeSignatureStatus(id, 'disabled')
.then(loadData)
.catch((reason: Error) => setError(reason.message || '签名禁用失败'));
}
function deleteDrainage(id: string) {
clientApi.changeDrainageInfoStatus(id, 'deleted').then(loadData).catch((reason: Error) => setError(reason.message || '引流信息删除失败'));
}
return (
<section className="page-stack">
<div className="signature-page-header">
<div className="sms-send-title">
<span className="sms-send-title__icon">
<FilePenLine size={22} />
</span>
<h1></h1>
</div>
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}></Button>
const resetFilters = () => { setKeyword(''); setApplicationFilter(''); setStatusFilter(''); };
return <section className="page-stack client-signature-page">
<header className="client-signature-heading">
<div className="client-signature-title">
<span className="client-signature-title__icon"><FileCheck2 size={23} /></span>
<div><h1></h1><p>使</p></div>
</div>
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal('new')}></Button>
</header>
<div className="signature-search-row">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索签名名称、用途或应用"
prefix={<Search size={17} />}
value={keyword}
/>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="signature-list">
{visibleSignatures.map((signature) => (
<article className="signature-card signature-card--green" key={signature.id}>
<div className="signature-summary">
<div>
<span></span>
<strong>{signature.name}</strong>
</div>
<div>
<span></span>
<strong>{signature.purpose ?? '-'}</strong>
</div>
<div>
<span></span>
<Tag tone={statusTone[signature.auditStatus] ?? 'info'}>{statusLabel[signature.auditStatus] ?? signature.auditStatus}</Tag>
</div>
<div>
<span></span>
<strong>{signature.materials?.length ?? 0} </strong>
{signature.materials?.map((material) => (
<FileActions file={materialToFileRef(material)} key={String(material.id ?? material.fileObjectId)} />
))}
</div>
<div className="signature-actions">
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger"></Button>
</div>
</div>
<div className="drainage-panel">
<div className="section-heading"><div><h2><Globe2 size={17} /> </h2><p className="muted"></p></div><Button disabled={signature.auditStatus !== 'approved'} icon={<Plus size={15} />} onClick={() => setDrainageModal({ signature })} size="sm" variant="ghost"></Button></div>
{drainageItems(signature).length ? drainageItems(signature).map((item) => <div className="surface" key={item.id} style={{ display: 'grid', gap: 12, gridTemplateColumns: '1fr 1.5fr 120px auto', marginTop: 10, padding: 12 }}><strong>{item.siteName}</strong><span className="drainage-table__url">{item.url}</span><Tag tone={statusTone[item.auditStatus] ?? 'info'}>{statusLabel[item.auditStatus] ?? item.auditStatus}</Tag><div className="table-actions"><Button icon={<Edit3 size={14} />} onClick={() => setDrainageModal({ signature, item })} size="sm" variant="ghost"></Button><Button icon={<Trash2 size={14} />} onClick={() => deleteDrainage(item.id)} size="sm" variant="danger"></Button></div>{item.rejectReason ? <p className="form-error" style={{ gridColumn: '1 / -1' }}>{item.rejectReason}</p> : null}</div>) : <p className="muted"></p>}
</div>
</article>
))}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredSignatures.length}
/>
{!loading && !error && filteredSignatures.length === 0 ? <p className="muted"></p> : null}
<Modal
footer={(
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!name || signatureFields.some((field) => field.required && !signatureValues[field.code]) || Boolean(signatureUploadingCode)} onClick={createSignature}></Button>
</>
)}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title="添加签名"
>
<div className="signature-form">
<Select
label="短信应用"
onChange={(event) => setApplicationId(event.target.value)}
options={[{ label: '不绑定应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
value={applicationId}
/>
<Input label="短信签名" onChange={(event) => setName(event.target.value)} placeholder="请输入短信签名,如【某某科技】" value={name} />
<Input label="用途" onChange={(event) => setPurpose(event.target.value)} placeholder="请输入签名用途" value={purpose} />
<section className="surface" style={{ padding: 16 }}><h3></h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
{signatureFields.map((field) => field.fieldType === 'string'
? <Input key={field.id} label={`${field.required ? '* ' : ''}${field.name}`} onChange={(event) => setSignatureValues((current) => ({ ...current, [field.code]: event.target.value }))} value={String(signatureValues[field.code] ?? '')} />
: <label className="signature-upload" key={field.id}><Upload size={28} /><strong>{reportFileRef(signatureValues[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong><small>{signatureUploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small><FileActions file={reportFileRef(signatureValues[field.code])} /><input accept={field.fieldType === 'image' ? 'image/*' : undefined} onChange={(event) => void uploadSignatureField(field, event.target.files?.[0])} style={{ display: 'none' }} type="file" /></label>)}
</div></section>
</div>
</Modal>
{drainageModal ? <ClientDrainageModal item={drainageModal.item} onClose={() => setDrainageModal(undefined)} onSaved={() => { setDrainageModal(undefined); loadData(); }} signature={drainageModal.signature} /> : null}
<section className="client-signature-overview" aria-label="签名审核概览">
<div><span></span><strong>{workspace.summary.total}</strong></div>
<div><span></span><strong>{workspace.summary.pending}</strong></div>
<div><span></span><strong>{workspace.summary.approved}</strong></div>
<div><span></span><strong>{workspace.summary.rejected}</strong></div>
</section>
);
<div className="client-signature-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索签名、用途或应用" prefix={<Search size={17} />} value={keyword} />
<Select onChange={(event) => setApplicationFilter(event.target.value)} options={[{ label: '全部应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationFilter} />
<Select onChange={(event) => setStatusFilter(event.target.value)} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} />
<Button icon={<RotateCcw size={15} />} onClick={resetFilters} variant="ghost"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<section className="client-signature-list-shell">
<div className="client-signature-list-head"><span /><span></span><span></span><span>使</span><span></span><span></span><span></span><span></span></div>
{loading ? <p className="client-signature-list-empty">...</p> : null}
{!loading && !visibleItems.length ? <p className="client-signature-list-empty"></p> : null}
{visibleItems.map((signature) => {
const expanded = expandedIds.has(signature.id);
const links = signature.drainageInfo.links;
const editable = signature.auditStatus !== 'pending';
return <article className="client-signature-row-wrap" key={signature.id}>
<div className="client-signature-list-row">
<button aria-label={expanded ? '收起引流信息' : '展开引流信息'} className="client-signature-expand" onClick={() => toggleExpanded(signature.id)} type="button">{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button>
<strong>{signature.name}</strong>
<span>{signature.application?.name ?? '未绑定'}</span>
<span>{signature.purpose || '-'}</span>
<Tag tone={statusTone[signature.auditStatus] ?? 'info'}>{statusLabel[signature.auditStatus] ?? signature.auditStatus}</Tag>
<span>{signature.submittedMaterialCount} </span>
<span>{formatDate(signature.updatedAt)}</span>
<div className="table-actions">
<Button disabled={!editable} icon={<Edit3 size={14} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={14} />} onClick={() => setDeleting({ type: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger"></Button>
</div>
</div>
{signature.rejectReason ? <div className="client-signature-inline-reason"><strong></strong>{signature.rejectReason}</div> : null}
{expanded ? <div className="client-drainage-panel">
<div className="client-drainage-panel__head"><div><h3><Globe2 size={17} /> </h3><p>使</p></div><Button disabled={signature.auditStatus !== 'approved'} icon={<Plus size={15} />} onClick={() => setDrainageModal({ signature })} size="sm" variant="ghost"></Button></div>
{links.length ? <div className="client-drainage-table">
<div className="client-drainage-table__head"><span></span><span>访</span><span></span><span></span><span></span></div>
{links.map((item) => <div className="client-drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong><a href={item.url} rel="noreferrer" target="_blank">{item.url}</a><Tag tone={statusTone[item.auditStatus] ?? 'info'}>{statusLabel[item.auditStatus] ?? item.auditStatus}</Tag><span>{formatDate(item.updatedAt)}</span>
<div className="table-actions"><Button disabled={item.auditStatus === 'pending'} icon={<Edit3 size={14} />} onClick={() => setDrainageModal({ signature, item })} size="sm" variant="ghost"></Button><Button icon={<Trash2 size={14} />} onClick={() => setDeleting({ type: 'drainage', id: item.id, name: item.siteName })} size="sm" variant="danger"></Button></div>
{item.rejectReason ? <p className="client-drainage-reason"><strong></strong>{item.rejectReason}</p> : null}
</div>)}
</div> : <p className="client-signature-empty-hint"></p>}
</div> : null}
</article>;
})}
</section>
<Pagination nextDisabled={currentPage >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} previousDisabled={currentPage <= 1} total={filteredItems.length} totalPages={totalPages} />
{signatureModal ? <SignatureModal applications={applications} onClose={() => setSignatureModal(undefined)} onSaved={() => { setSignatureModal(undefined); loadData(); }} signature={signatureModal === 'new' ? undefined : signatureModal} /> : null}
{drainageModal ? <DrainageModal item={drainageModal.item} onClose={() => setDrainageModal(undefined)} onSaved={() => { setDrainageModal(undefined); loadData(); }} signature={drainageModal.signature} /> : null}
{deleting ? <Modal footer={<><Button onClick={() => setDeleting(undefined)} variant="ghost"></Button><Button onClick={() => void confirmDelete()} variant="danger"></Button></>} onClose={() => setDeleting(undefined)} open title="确认删除"><p>{deleting.name}</p></Modal> : null}
</section>;
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
@@ -69,7 +69,7 @@ function TemplateModal({
item?: ClientSmsTemplate;
onClose: () => void;
onSubmit: (state: TemplateFormState) => void;
signatures: ClientSmsSignature[];
signatures: ClientSmsSignatureView[];
}) {
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
@@ -208,7 +208,7 @@ function TemplateModal({
export function ClientTemplatesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
+6 -14
View File
@@ -36,7 +36,7 @@ export function ClientUplinkMessagesPage() {
function loadData() {
setLoading(true);
clientApi.listUplinkMessages()
clientApi.listUplinkMessages({ phoneNumber: phoneKeyword || undefined, keyword: contentKeyword || undefined, startTime: dateRange.start ? `${dateRange.start}T00:00:00+08:00` : undefined, endTime: dateRange.end ? `${dateRange.end}T23:59:59+08:00` : undefined })
.then((items) => {
setMessages(items);
setError('');
@@ -62,17 +62,9 @@ export function ClientUplinkMessagesPage() {
}
useEffect(() => {
loadData();
}, []);
const filteredMessages = messages.filter((item) => {
const receivedDate = getDate(item.receivedAt);
const matchesPhone = !phoneKeyword || item.phoneNumber.includes(phoneKeyword);
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
return matchesPhone && matchesContent && matchesStartDate && matchesEndDate;
});
const timer = window.setTimeout(loadData, 300);
return () => window.clearTimeout(timer);
}, [phoneKeyword, contentKeyword, dateRange.start, dateRange.end]);
const columns = useMemo<Array<TableColumn<SmsUplinkMessage>>>(() => [
{ key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
@@ -100,7 +92,7 @@ export function ClientUplinkMessagesPage() {
<h1></h1>
</div>
<QueryPanel title="查询条件" summary={<> <strong>{filteredMessages.length}</strong> </>}>
<QueryPanel title="查询条件" summary={<> <strong>{messages.length}</strong> </>}>
<Input
label="手机号码"
onChange={(event) => setPhoneKeyword(event.target.value)}
@@ -121,7 +113,7 @@ export function ClientUplinkMessagesPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="surface uplink-table-card">
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行记录...' : '暂无上行记录'} rowKey="id" />
<Table columns={columns} data={loading ? [] : messages} emptyText={loading ? '正在加载真实上行记录...' : '暂无上行记录'} rowKey="id" />
</div>
<Modal