feat: add HTTP API and complete client workflows
This commit is contained in:
+83
-8
@@ -46,7 +46,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
|
||||
if (response.status === 401 && session && !isLoginAttempt) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
@@ -318,6 +319,7 @@ export type ClientSmsApplication = {
|
||||
deliveryRate?: number;
|
||||
cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive';
|
||||
cmppConnections?: CmppDownstreamConnection[];
|
||||
httpConfig?: HttpApiConfig | null;
|
||||
};
|
||||
|
||||
export type ClientSmsSignature = {
|
||||
@@ -342,6 +344,32 @@ export type ClientSmsSignature = {
|
||||
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
|
||||
};
|
||||
|
||||
export type ClientSmsSignatureView = Pick<ClientSmsSignature,
|
||||
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials'
|
||||
> & {
|
||||
application?: Pick<ClientSmsApplication, 'id' | 'name' | 'status'> | null;
|
||||
submittedMaterialCount: number;
|
||||
reportValues: Record<string, unknown>;
|
||||
drainageInfo: { links: Array<{
|
||||
id: string;
|
||||
siteName: string;
|
||||
url: string;
|
||||
remark?: string | null;
|
||||
reportValues: Record<string, unknown>;
|
||||
auditStatus: string;
|
||||
rejectReason?: string | null;
|
||||
submittedAt: string;
|
||||
reviewedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}> };
|
||||
};
|
||||
|
||||
export type ClientSignatureWorkspace = {
|
||||
items: ClientSmsSignatureView[];
|
||||
summary: { total: number; pending: number; approved: number; rejected: number; draft: number };
|
||||
};
|
||||
|
||||
export type SmsDrainageInfo = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -557,6 +585,36 @@ export type SmsUplinkMatchCandidate = {
|
||||
messageRecord?: SmsMessageRecord | null;
|
||||
};
|
||||
|
||||
export type HttpApiConfig = {
|
||||
enabled: boolean;
|
||||
sendEnabled: boolean;
|
||||
messageQueryEnabled: boolean;
|
||||
receiptWebhookEnabled: boolean;
|
||||
uplinkWebhookEnabled: boolean;
|
||||
uplinkQueryEnabled: boolean;
|
||||
credentialSelfServiceEnabled: boolean;
|
||||
qpsLimit: number;
|
||||
timestampToleranceSeconds: number;
|
||||
maxCredentialCount: number;
|
||||
uplinkRetentionDays: number;
|
||||
maxQueryRangeDays: number;
|
||||
maxPageSize: number;
|
||||
receiptDeliveryMode: 'cmpp' | 'http' | 'both' | 'none';
|
||||
uplinkDeliveryMode: 'cmpp' | 'http' | 'both' | 'none';
|
||||
webhookRetryEnabled: boolean;
|
||||
webhookMaxAttempts: number;
|
||||
webhookTimeoutSeconds: number;
|
||||
requireHttps: boolean;
|
||||
allowClientManualRetry: boolean;
|
||||
allowClientTest: boolean;
|
||||
};
|
||||
|
||||
export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; config: HttpApiConfig | null; ipAllowlist: string[] };
|
||||
export type HttpApiCredential = { id: string; name: string; accessKey: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; expiresAt?: string | null; lastUsedAt?: string | null; lastUsedIp?: string | null; createdAt: string };
|
||||
export type HttpWebhookEndpoint = { id: string; eventType: 'receipt' | 'uplink'; url: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; lastTestAt?: string | null; lastTestStatus?: string | null; updatedAt: string };
|
||||
export type HttpApiRequestLog = { id: string; requestId: string; clientMessageId?: string | null; sourceIp?: string | null; httpStatus?: number | null; businessCode?: string | null; status: string; durationMs?: number | null; createdAt: string; completedAt?: string | null };
|
||||
export type HttpWebhookDelivery = { id: string; status: string; attemptCount: number; lastHttpStatus?: number | null; lastError?: string | null; createdAt: string; event: { eventId: string; eventType: string; messageId?: string | null }; endpoint: { eventType: string; url: string } };
|
||||
|
||||
export type DictionaryItem = Record<string, unknown> & {
|
||||
id: string;
|
||||
status?: string;
|
||||
@@ -655,6 +713,8 @@ export type ApplicationReportField = {
|
||||
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both'; source?: 'common' | 'channel' | 'both' }>;
|
||||
};
|
||||
|
||||
export type ClientApplicationReportField = Omit<ApplicationReportField, 'channels' | 'commonReportTypes'>;
|
||||
|
||||
export type CommonReportField = DictionaryItem & {
|
||||
drainageFieldId: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
@@ -1167,6 +1227,8 @@ export const adminApi = {
|
||||
request<ApplicationReportField[]>(withQuery('/admin/report-fields/common', { reportType })),
|
||||
getApplicationCmppParams: (applicationId: string) =>
|
||||
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
|
||||
getApplicationHttpApiConfig: (applicationId: string) => request<HttpApiConfigResponse>(`/admin/enterprise-applications/${applicationId}/http-api`),
|
||||
updateApplicationHttpApiConfig: (applicationId: string, body: Partial<HttpApiConfig> & { ipAllowlist?: string[] }) => request<HttpApiConfigResponse>(`/admin/enterprise-applications/${applicationId}/http-api`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
||||
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
|
||||
@@ -1442,18 +1504,31 @@ export const clientApi = {
|
||||
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
|
||||
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
|
||||
getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`, { tenantId }),
|
||||
listHttpApiCredentials: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`, { tenantId }),
|
||||
createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential>(`/client/applications/${applicationId}/http-api/credentials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
revokeHttpApiCredential: (applicationId: string, credentialId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
listHttpWebhooks: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookEndpoint[]>(`/client/applications/${applicationId}/http-api/webhooks`, { tenantId }),
|
||||
saveHttpWebhook: (applicationId: string, eventType: 'receipt' | 'uplink', body: { url: string; rotateSecret?: boolean; status?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
listHttpApiRequests: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`, { tenantId }),
|
||||
listHttpWebhookDeliveries: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookDelivery[]>(`/client/applications/${applicationId}/http-api/webhook-deliveries`, { tenantId }),
|
||||
retryHttpWebhookDelivery: (applicationId: string, deliveryId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
|
||||
request<ClientApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
|
||||
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
|
||||
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
|
||||
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignature[]>('/client/signatures', { tenantId }),
|
||||
request<ClientSmsSignatureView[]>('/client/signatures', { tenantId }),
|
||||
getSignatureWorkspace: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSignatureWorkspace>('/client/signatures-workspace', { tenantId }),
|
||||
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignature>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignature>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignature>(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||
request<ClientSmsSignatureView>(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||
createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -1486,7 +1561,7 @@ export const clientApi = {
|
||||
request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`, { tenantId }),
|
||||
listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsMessageRecord[]>(withQuery('/client/operations/messages', query), { tenantId }),
|
||||
listUplinkMessages: (query: { channelId?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
|
||||
createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<FileObject>('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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-Id、X-Event-Type、X-Timestamp、X-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>;
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MessageSquareText,
|
||||
PenLine,
|
||||
ReceiptText,
|
||||
Cable,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
@@ -48,6 +49,7 @@ export function ClientLayout() {
|
||||
{ label: '短信应用', to: '/client/applications', icon: FileText },
|
||||
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
|
||||
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
||||
{ label: '接口对接', to: '/client/http-api', icon: Cable },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,6 +43,7 @@ import { ClientBatchTasksPage } from '@/apps/client/ClientBatchTasksPage';
|
||||
import { ClientBillingPage } from '@/apps/client/ClientBillingPage';
|
||||
import { ClientEnterpriseAuthPage } from '@/apps/client/ClientEnterpriseAuthPage';
|
||||
import { ClientHome } from '@/apps/client/ClientHome';
|
||||
import { ClientHttpApiPage } from '@/apps/client/ClientHttpApiPage';
|
||||
import { ClientSendDetailPage } from '@/apps/client/ClientSendDetailPage';
|
||||
import { ClientSendPage } from '@/apps/client/ClientSendPage';
|
||||
import { ClientSignaturesPage } from '@/apps/client/ClientSignaturesPage';
|
||||
@@ -68,6 +69,7 @@ export function AppRoutes() {
|
||||
<Route path="send-detail" element={<ClientSendDetailPage />} />
|
||||
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
|
||||
<Route path="applications" element={<ClientApplicationsPage />} />
|
||||
<Route path="http-api" element={<ClientHttpApiPage />} />
|
||||
<Route path="templates" element={<ClientTemplatesPage />} />
|
||||
<Route path="signatures" element={<ClientSignaturesPage />} />
|
||||
<Route path="mms-signatures" element={<PagePlaceholder />} />
|
||||
|
||||
+324
-1
@@ -2815,6 +2815,10 @@ h3 {
|
||||
background: var(--color-selected);
|
||||
}
|
||||
|
||||
.signature-card--amber::before {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.signature-card--red::before {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
@@ -9436,6 +9440,10 @@ h3 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.phone-segment-overview--single {
|
||||
grid-template-columns: minmax(260px, 420px);
|
||||
}
|
||||
|
||||
.phone-segment-overview section {
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
@@ -9556,13 +9564,24 @@ h3 {
|
||||
|
||||
.phone-segment-workbench__tabs {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.phone-segment-workbench__tabs .ui-tabs__list {
|
||||
padding: 0 var(--space-5);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.phone-segment-tab-content {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
.phone-segment-tab-content > .phone-segment-overview,
|
||||
.phone-segment-tab-content > .phone-segment-query {
|
||||
margin-inline: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-system-table-card {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -9718,6 +9737,310 @@ h3 {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.client-signature-page {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.client-signature-heading,
|
||||
.client-signature-title,
|
||||
.client-drainage-panel__head {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.client-signature-title {
|
||||
justify-content: flex-start;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.client-signature-title__icon {
|
||||
align-items: center;
|
||||
background: #e9f6f0;
|
||||
border: 1px solid #d3ebe1;
|
||||
border-radius: 12px;
|
||||
color: #187352;
|
||||
display: inline-flex;
|
||||
height: 46px;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
}
|
||||
|
||||
.client-signature-title h1,
|
||||
.client-signature-title p,
|
||||
.client-drainage-panel h3,
|
||||
.client-drainage-panel p,
|
||||
.client-signature-form-section h3,
|
||||
.client-signature-form-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.client-signature-title h1 {
|
||||
color: var(--text-strong);
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.client-signature-title p,
|
||||
.client-drainage-panel p,
|
||||
.client-signature-form-section p {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.client-signature-overview {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.client-signature-overview > div {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 18px 22px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.client-signature-overview > div + div::before {
|
||||
background: var(--border);
|
||||
content: '';
|
||||
height: 34px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.client-signature-overview span {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.client-signature-overview strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.client-signature-toolbar {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(240px, 1fr) 190px 160px auto;
|
||||
}
|
||||
|
||||
.client-signature-list-shell {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.client-signature-list-head,
|
||||
.client-signature-list-row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 24px minmax(120px, 1.1fr) minmax(110px, 1fr) minmax(120px, 1fr) 104px 76px 142px minmax(144px, auto);
|
||||
min-width: 1050px;
|
||||
}
|
||||
|
||||
.client-signature-list-head {
|
||||
background: #f7f9f8;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.client-signature-row-wrap {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 62px;
|
||||
}
|
||||
|
||||
.client-signature-row-wrap + .client-signature-row-wrap {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.client-signature-list-row {
|
||||
min-height: 62px;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
.client-signature-list-row > span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-signature-list-row > strong {
|
||||
color: var(--text-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-signature-expand {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.client-signature-expand:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.client-signature-inline-reason,
|
||||
.client-signature-reason,
|
||||
.client-drainage-reason {
|
||||
background: #fff8f0;
|
||||
color: #9a4d12;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.client-signature-inline-reason {
|
||||
border-top: 1px solid #f5dfc8;
|
||||
padding: 9px 56px;
|
||||
}
|
||||
|
||||
.client-signature-reason {
|
||||
border: 1px solid #f5dfc8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.client-drainage-panel {
|
||||
background: #f8fbfa;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 18px 56px 22px;
|
||||
}
|
||||
|
||||
.client-drainage-panel__head {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.client-drainage-panel h3 {
|
||||
align-items: center;
|
||||
color: var(--text-strong);
|
||||
display: flex;
|
||||
font-size: 15px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.client-drainage-table {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.client-drainage-table__head,
|
||||
.client-drainage-table__row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(130px, 1fr) minmax(240px, 1.8fr) 104px 142px minmax(144px, auto);
|
||||
min-width: 820px;
|
||||
}
|
||||
|
||||
.client-drainage-table__head {
|
||||
background: #f5f8f7;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.client-drainage-table__row {
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 56px;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.client-drainage-table__row > a {
|
||||
color: var(--primary);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-drainage-reason {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 -14px -9px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.client-signature-list-empty,
|
||||
.client-signature-empty-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-signature-form-section {
|
||||
background: #f8fbfa;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.client-signature-form-section .client-signature-empty-hint {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.client-signature-list-shell {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.client-signature-heading,
|
||||
.client-drainage-panel__head {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.client-signature-overview {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.client-signature-overview > div:nth-child(3)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.client-signature-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.client-drainage-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user