feat: add carrier-aware signature retirement alerts

This commit is contained in:
hectorzhao
2026-08-10 20:54:05 +08:00
parent 232d1c22a3
commit 55aa054005
52 changed files with 3074 additions and 152 deletions
+2 -2
View File
@@ -81,9 +81,9 @@ export const adminChannelsReportsApi = {
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<ReportTask>>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) =>
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) =>
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
+41
View File
@@ -0,0 +1,41 @@
import { request, withQuery } from '../core/httpClient';
import type {
LegacySignatureReportTask,
PagedResult,
SignatureRetirementHeatmapItem,
SignatureRetirementHeatmapDimension,
SignatureRetirementMessage,
SignatureRetirementRule,
SignatureRetirementRuleType,
SignatureRetirementSuppression,
SignatureRetirementWebhook,
UnreportedSignatureItem,
} from '../types';
export const adminSignatureRetirementApi = {
getSignatureRetirementConfiguration: () => request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>('/admin/signature-retirement/configuration'),
saveSignatureRetirementRule: (body: {
ruleType: SignatureRetirementRuleType; targetId?: string; enabled: boolean;
mobileWindowDays: number; mobileThreshold: number; unicomWindowDays: number; unicomThreshold: number;
telecomWindowDays: number; telecomThreshold: number; messageTemplate?: string;
}) => request<SignatureRetirementRule>('/admin/signature-retirement/rules', { method: 'PUT', body: JSON.stringify(body) }),
createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) =>
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }),
deleteSignatureRetirementWebhook: (id: string) => request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
listSignatureRetirementMessages: (query: { dateFrom?: string; dateTo?: string; dimensionType?: string; tenantId?: string; applicationId?: string; signatureKeyword?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'),
readSignatureRetirementMessage: (id: string) => request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
readAllSignatureRetirementMessagesToday: () => request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
suppressSignatureRetirementMessage: (id: string, body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, { method: 'POST', body: JSON.stringify(body) }),
listSignatureRetirementSuppressions: () => request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
cancelSignatureRetirementSuppression: (id: string, reason: string) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) }),
getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })),
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<UnreportedSignatureItem> & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)),
listLegacySignatureReportTasks: () => request<LegacySignatureReportTask[]>('/admin/signature-retirement/legacy-report-tasks'),
confirmLegacySignatureReportTask: (id: string, body: { results: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; status: string; approvedAt?: string }>; reason?: string }) =>
request<{ legacyTaskId: string }>(`/admin/signature-retirement/legacy-report-tasks/${id}/confirm`, { method: 'POST', body: JSON.stringify(body) }),
};
+2
View File
@@ -9,6 +9,7 @@ import { adminChannelsReportsApi } from './admin/channels-reports.api';
import { adminOperationsApi } from './admin/operations.api';
import { adminGovernanceApi } from './admin/governance.api';
import { adminFilesApi } from './admin/files.api';
import { adminSignatureRetirementApi } from './admin/signature-retirement.api';
export const adminApi = {
...adminIdentityApi,
@@ -16,4 +17,5 @@ export const adminApi = {
...adminOperationsApi,
...adminGovernanceApi,
...adminFilesApi,
...adminSignatureRetirementApi,
};
+5 -1
View File
@@ -7,6 +7,7 @@ export type AdminChannel = {
code: string;
name: string;
carrier?: string | null;
carriers?: Array<'mobile' | 'unicom' | 'telecom'>;
sendRegion?: string | null;
gatewayHost: string;
gatewayPort: number;
@@ -261,6 +262,9 @@ export type ReportTask = DictionaryItem & {
tenantId: string;
signatureId: string;
channelId: string;
carrier?: 'mobile' | 'unicom' | 'telecom' | null;
approvedAt?: string | null;
approvalScope?: 'carrier_specific' | 'legacy_channel';
reportType?: 'signature' | 'drainage';
drainageItemId?: string | null;
status: string;
@@ -273,7 +277,7 @@ export type ReportTask = DictionaryItem & {
application?: { id: string; name: string } | null;
};
drainageInfo?: SmsDrainageInfo | null;
channel?: { id: string; name: string; code: string };
channel?: { id: string; name: string; code: string; carrier?: string | null; carriers?: Array<'mobile' | 'unicom' | 'telecom'> };
reason?: string | null;
createdAt?: string;
updatedAt?: string;
+1 -1
View File
@@ -238,7 +238,7 @@ export type ClientSmsSignature = {
application?: ClientSmsApplication | null;
reportStatus?: string;
reportTasks?: Array<ReportTask & { channel?: AdminChannel }>;
reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>;
reportTargets?: Array<{ channel: AdminChannel; channelId: string; carrier: 'mobile' | 'unicom' | 'telecom'; status: string; taskId?: string; approvedAt?: string | null; approvalScope?: 'carrier_specific' | 'legacy_channel' }>;
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
drainageReportTargets?: Record<string, Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>>;
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
+1
View File
@@ -3,3 +3,4 @@ export * from './identity-config';
export * from './channels-reports';
export * from './operations';
export * from './governance';
export * from './signature-retirement';
+108
View File
@@ -0,0 +1,108 @@
import type { AdminChannel, ReportTask } from './channels-reports';
export type SignatureRetirementRuleType = 'enterprise_global' | 'enterprise_application' | 'channel_global' | 'channel';
export type SignatureRetirementCarrier = 'mobile' | 'unicom' | 'telecom';
export type SignatureRetirementRule = {
id: string;
ruleType: SignatureRetirementRuleType;
targetId?: string | null;
targetKey: string;
enabled: boolean;
mobileWindowDays: number;
mobileThreshold: number;
unicomWindowDays: number;
unicomThreshold: number;
telecomWindowDays: number;
telecomThreshold: number;
messageTemplate?: string | null;
version: number;
updatedAt: string;
};
export type SignatureRetirementWebhook = {
id: string;
name: string;
platform: 'wecom' | 'feishu';
urlMasked: string;
status: string;
createdAt: string;
};
export type SignatureRetirementDetection = {
id: string;
detectionDate: string;
dimensionType: 'enterprise' | 'channel';
tenantId: string;
applicationId?: string | null;
signatureId: string;
channelId?: string | null;
carrier: SignatureRetirementCarrier;
windowDays: number;
threshold: number;
submittedAttempts: number;
acceptedBusinessCount: number;
deliveredBusinessCount: number;
approvedAt: string;
status: 'alert' | 'healthy';
suppressed: boolean;
};
export type SignatureRetirementMessage = {
id: string;
detectionId: string;
cycleId: string;
tenantId: string;
title: string;
content: string;
isRead: boolean;
suppressed: boolean;
createdAt: string;
detection?: SignatureRetirementDetection;
signatureName?: string;
channelName?: string | null;
tenantName?: string;
applicationName?: string;
};
export type SignatureRetirementSuppression = {
id: string;
dimensionType: 'enterprise' | 'channel';
signatureId: string;
channelId?: string | null;
carrier: SignatureRetirementCarrier;
mode: 'temporary' | 'permanent';
muteUntil?: string | null;
reason?: string | null;
updatedAt: string;
};
export type SignatureRetirementHeatmapItem = SignatureRetirementDetection & {
signatureName?: string;
channelName?: string | null;
tenantName?: string;
};
export type SignatureRetirementHeatmapDimension = {
dimensionType: 'enterprise' | 'channel';
signatureId: string;
channelId: string | null;
carrier: SignatureRetirementCarrier;
approvedAt: string;
signatureName: string;
channelName: string | null;
tenantName: string;
applicationName?: string | null;
};
export type UnreportedSignatureItem = {
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationId?: string | null;
applicationName?: string | null;
messageCount: number;
};
export type LegacySignatureReportTask = ReportTask & { channel: AdminChannel };
+214 -56
View File
@@ -1,15 +1,17 @@
import { useEffect, useMemo, useState } from 'react';
import { useDeferredValue, useEffect, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
type SendQualityResponse,
type SignatureChannelCarrierQualityStat,
type SignatureChannelQualityItem,
type SignatureChannelQualityResponse,
type SignatureRetirementHeatmapItem,
type SignatureRetirementHeatmapDimension,
type UnreportedSignatureItem,
type PagedResult,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { createBarOption, createPieOption } from '@/theme/chartOptions';
import { successRateClassName } from '@/utils/successRate';
import { Breadcrumb, Button, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { successRateClassName, successRateTone } from '@/utils/successRate';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
const majorCarrierOrder = ['mobile', 'unicom', 'telecom'] as const;
@@ -27,10 +29,14 @@ const carrierLabels: Record<string, string> = {
export function AdminAnalyticsPage() {
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]);
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
const [unreportedSignatures, setUnreportedSignatures] = useState<(PagedResult<UnreportedSignatureItem> & { date: string }) | null>(null);
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [unreportedKeyword, setUnreportedKeyword] = useState('');
const [appliedUnreportedKeyword, setAppliedUnreportedKeyword] = useState('');
const [selectedSignature, setSelectedSignature] = useState<SignatureChannelQualityItem | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -38,17 +44,20 @@ export function AdminAnalyticsPage() {
async function loadData(page = 1, keyword = appliedKeyword) {
setLoading(true);
try {
const [qualityData, signatureData] = await Promise.all([
adminApi.getSendQuality(statisticsDate),
const [signatureData, heatmapData, unreportedData] = await Promise.all([
adminApi.getSignatureQuality({
date: statisticsDate,
keyword: keyword || undefined,
page,
pageSize: 10,
}),
adminApi.getSignatureRetirementHeatmap(statisticsDate),
adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: appliedUnreportedKeyword || undefined, page: 1, pageSize: 10 }),
]);
setQuality(qualityData);
setSignatureQuality(signatureData);
setRetirementHeatmap(heatmapData.items);
setRetirementDimensions(heatmapData.dimensions);
setUnreportedSignatures(unreportedData);
setAppliedKeyword(keyword);
setSelectedSignature((current) => current
? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null
@@ -79,15 +88,7 @@ export function AdminAnalyticsPage() {
};
}, [selectedSignature]);
const applicationOption = useMemo(() => createBarOption({
labels: quality?.applications.map((item) => item.applicationName) ?? [],
series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }],
}), [quality]);
const channelOption = useMemo(() => createPieOption({
data: quality?.channels.map((item) => ({ name: item.channelName || item.channelId, value: item.total })) ?? [],
}), [quality]);
const effectiveDate = quality?.date ?? statisticsDate;
const effectiveDate = signatureQuality?.date ?? statisticsDate;
const signatureColumns: Array<TableColumn<SignatureChannelQualityItem>> = [
{
@@ -168,14 +169,34 @@ export function AdminAnalyticsPage() {
}
function changeSignaturePage(page: number) {
void loadData(page, appliedKeyword);
setLoading(true);
void adminApi.getSignatureQuality({ date: statisticsDate, keyword: appliedKeyword || undefined, page, pageSize: 10 })
.then((data) => {
setSignatureQuality(data);
setError('');
})
.catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '签名发送质量加载失败'))
.finally(() => setLoading(false));
}
function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) {
setLoading(true);
void adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: keyword || undefined, page, pageSize: 10 })
.then((data) => {
setUnreportedSignatures(data);
setAppliedUnreportedKeyword(keyword);
setError('');
})
.catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '未报备签名加载失败'))
.finally(() => setLoading(false));
}
return (
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['数据统计']} />
<Breadcrumb items={['签名质量检测']} />
<h1></h1>
</div>
<div className="page-actions">
<Input
@@ -192,42 +213,6 @@ export function AdminAnalyticsPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface metric-card">
<span>{effectiveDate} </span>
<strong>{quality?.summary.total.toLocaleString('zh-CN') ?? 0}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span>{effectiveDate} </span>
<strong>{(quality?.summary.successRate ?? 0).toFixed(1)}%</strong>
<small>{quality?.summary.successCount.toLocaleString('zh-CN') ?? 0} / {quality?.summary.total.toLocaleString('zh-CN') ?? 0} </small>
</div>
</div>
<div className="chart-grid">
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted">{effectiveDate} </p>
</div>
<Tag tone="info"></Tag>
</div>
<Chart height={320} option={applicationOption} />
</div>
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted">{effectiveDate} </p>
</div>
<Tag tone="accent"></Tag>
</div>
<Chart height={320} option={channelOption} />
</div>
</div>
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
@@ -277,6 +262,18 @@ export function AdminAnalyticsPage() {
) : null}
</div>
<RetirementHeatmap date={statisticsDate} dimensionType="enterprise" dimensions={retirementDimensions} items={retirementHeatmap} title="企业签名活跃度热力图" />
<RetirementHeatmap date={statisticsDate} dimensionType="channel" dimensions={retirementDimensions} items={retirementHeatmap} title="通道签名活跃度热力图" />
<UnreportedSignaturesCard
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
/>
{selectedSignature ? (
<SignatureQualityDrawer
date={signatureQuality?.date ?? effectiveDate}
@@ -288,6 +285,162 @@ export function AdminAnalyticsPage() {
);
}
function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: { date: string; dimensionType: 'enterprise' | 'channel'; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[]; title: string }) {
const pageSize = 10;
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30);
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
signatureName: item.signatureName,
channelName: item.channelName,
tenantName: item.tenantName,
applicationName: item.applicationName,
carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10),
}));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.detectionDate.slice(0, 10)}`, item]));
useEffect(() => {
setPage(1);
}, [date, deferredKeyword, dimensionType, dimensions.length]);
return (
<div className="surface signature-retirement-heatmap">
<div className="section-heading signature-retirement-heatmap__heading">
<div>
<h2>{title}</h2>
<p className="muted"></p>
</div>
<div className="signature-retirement-heatmap__actions">
<Input
aria-label={`${title}搜索企业、企业应用或签名`}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、企业应用或签名"
value={keyword}
/>
<Tag tone="info">T-1 T-30</Tag>
</div>
</div>
{rows.length ? (
<>
<div className="signature-retirement-heatmap__scroll">
<table>
<thead>
<tr><th></th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
</thead>
<tbody>
{pagedRows.map((row) => (
<tr key={row.key}>
<th>
<span className="signature-retirement-heatmap__identity">
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>{row.signatureName}</strong>
{row.channelName ? <small>{row.channelName}</small> : null}
</span>
<Tag tone="neutral">{carrierLabels[row.carrier] ?? row.carrier}</Tag>
</th>
{dates.map((dateKey) => {
const item = cellMap.get(`${row.key}:${dateKey}`);
const beforeApproval = dateKey < row.approvedAt;
const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0;
const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`;
const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n预警阈值:${item.threshold}` : '当日无检测快照';
return <td className={className} key={dateKey} title={titleText}>{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage(currentPage + 1)}
onPageChange={setPage}
onPrevious={() => setPage(currentPage - 1)}
page={currentPage}
previousDisabled={currentPage <= 1}
total={rows.length}
totalPages={totalPages}
/>
</>
) : <p className="empty-state">{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}</p>}
</div>
);
}
function UnreportedSignaturesCard({
data,
keyword,
loading,
onKeywordChange,
onPageChange,
onSearch,
}: {
data: (PagedResult<UnreportedSignatureItem> & { date: string }) | null;
keyword: string;
loading: boolean;
onKeywordChange: (value: string) => void;
onPageChange: (page: number) => void;
onSearch: () => void;
}) {
const columns: Array<TableColumn<UnreportedSignatureItem>> = [
{ key: 'signatureName', title: '短信签名', render: (record) => <strong>{record.signatureName}</strong> },
{ key: 'tenantName', title: '企业名称', render: (record) => record.tenantName },
{ key: 'applicationName', title: '企业应用', render: (record) => record.applicationName || '未关联企业应用' },
{ key: 'messageCount', title: '未报备短信', align: 'right', width: '150px', render: (record) => `${record.messageCount.toLocaleString('zh-CN')}` },
];
const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 10)));
return (
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
<div className="section-heading__title"><h2></h2><Tag tone="warning"></Tag></div>
<p className="muted">{data?.date ?? '所选日期'} </p>
</div>
<div className="signature-quality-card__query">
<Input
aria-label="搜索未报备签名、企业或企业应用"
onChange={(event) => onKeywordChange(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }}
placeholder="搜索签名、企业或企业应用"
value={keyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary"></Button>
</div>
</div>
<div className="signature-quality-card__note"><strong></strong></div>
<Table
columns={columns}
data={data?.items ?? []}
emptyText={loading ? '正在加载未报备签名…' : '所选日期没有未报备签名短信'}
pagination={false}
rowKey={(record) => `${record.signatureId}:${record.applicationId ?? ''}`}
/>
{(data?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={(data?.page ?? 1) >= totalPages}
onNext={() => onPageChange((data?.page ?? 1) + 1)}
onPageChange={onPageChange}
onPrevious={() => onPageChange((data?.page ?? 1) - 1)}
page={data?.page ?? 1}
previousDisabled={(data?.page ?? 1) <= 1}
total={data?.total ?? 0}
totalPages={totalPages}
/>
) : null}
</div>
);
}
function SignatureQualityDrawer({
date,
item,
@@ -507,3 +660,8 @@ function shanghaiDateKey(value = new Date()) {
const byType = new Map(parts.map((part) => [part.type, part.value]));
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
}
function previousDateKeys(endKey: string, days: number) {
const end = new Date(`${endKey}T12:00:00+08:00`);
return Array.from({ length: days }, (_, index) => shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000)));
}
+5 -5
View File
@@ -53,8 +53,8 @@ function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
}
function isCarrierCompatible(channelCarrier: string | null | undefined, carrier: Carrier) {
return !channelCarrier || channelCarrier === 'all' || channelCarrier === carrier;
function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) {
return channel.carriers?.length ? channel.carriers.includes(carrier) : !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier;
}
function getChannelStatus(channel?: AdminChannel): ChannelStatus {
@@ -95,14 +95,14 @@ function RouteConfigModal({
const provinceOptions = [
{ label: '请选择省份', value: '' },
...Array.from(new Set(channels
.filter((channel) => isCarrierCompatible(channel.carrier, carrier))
.filter((channel) => isCarrierCompatible(channel, carrier))
.map((channel) => channel.sendRegion)
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')),
)).sort().map((region) => ({ label: region, value: region })),
];
const selectableChannels = channels.filter((channel) => {
if (!isCarrierCompatible(channel.carrier, carrier)) return false;
if (!isCarrierCompatible(channel, carrier)) return false;
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false;
if (modal.type === 'province' && province) {
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
@@ -112,7 +112,7 @@ function RouteConfigModal({
const channelOptions = [
{ label: '请选择', value: '' },
...selectableChannels.map((channel) => ({
label: `${channel.name}${channel.code} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
label: `${channel.name}${channel.code} / ${(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).join('、')} / ${channel.sendRegion ?? '全国'}`,
value: channel.id,
})),
];
+3 -3
View File
@@ -147,7 +147,7 @@ export function AdminChannelReportPage() {
function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' })
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, carrier: statusTask.carrier ?? undefined, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' })
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
@@ -182,10 +182,10 @@ export function AdminChannelReportPage() {
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined;
const reportedAt = approvedRecord(task.id)?.createdAt;
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : signature?.tenant?.name ?? task.tenantId}</small></span></div>
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : `${signature?.tenant?.name ?? task.tenantId} · ${task.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[task.carrier] : '历史通道级(未拆分)'}`}</small></span></div>
<ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={reportedAt} />
+1 -1
View File
@@ -105,7 +105,7 @@ export function AdminReportRecordsPage() {
{ key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action },
{ key: 'status', title: '状态变化', width: '210px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag> },
{ key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '备注', render: (record) => record.reason ?? '-' },
{ key: 'reason', title: '备注', width: '320px', render: (record) => <span className="ui-table__long-text">{record.reason ?? '-'}</span> },
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
];
+5 -2
View File
@@ -39,6 +39,8 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
<div><span></span><strong>{task.signature?.tenant?.name ?? task.tenantId}</strong></div>
<div><span></span><strong>{task.signature?.application?.name ?? '未指定应用'}</strong></div>
<div><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div>
{task.reportType !== 'drainage' ? <div><span></span><strong>{task.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[task.carrier] : '历史通道级(未拆分)'}</strong></div> : null}
{task.reportType !== 'drainage' ? <div><span></span><strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong></div> : null}
<div><span></span><Tag tone={status.tone}>{status.label}</Tag></div>
<div><span></span><strong>{formatDateTime(task.createdAt)}</strong></div>
<div><span></span><strong>{formatDateTime(task.updatedAt)}</strong></div>
@@ -107,6 +109,7 @@ export function AdminReportTasksPage() {
items: [{
signatureId: statusTask.signatureId,
channelId: statusTask.channelId,
carrier: statusTask.carrier ?? undefined,
reportType: statusTask.reportType,
drainageItemId: statusTask.drainageItemId ?? undefined,
status: nextStatus,
@@ -127,7 +130,7 @@ export function AdminReportTasksPage() {
const columns: Array<TableColumn<ReportTask>> = [
{ key: 'target', title: '报备对象', render: (record) => <div><strong>{taskTargetLabel(record)}</strong><div className="muted">{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}</div></div> },
{ key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' },
{ key: 'channel', title: '通道', render: (record) => record.channel?.name ?? record.channelId },
{ key: 'channel', title: '通道/运营商', render: (record) => <div><strong>{record.channel?.name ?? record.channelId}</strong>{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[record.carrier] : '历史通道级(未拆分)'}</div> : null}</div> },
{ key: 'batch', title: '批次/版本', render: (record) => {
const source = record.exportItems?.[0];
return source ? <div><strong>{source.batchItem.batch.batchNo}</strong><div className="muted">V{source.batchItem.materialVersion} · {source.rowNumber}</div></div> : '-';
@@ -142,7 +145,7 @@ export function AdminReportTasksPage() {
];
return <section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading"><div><Breadcrumb items={['报备任务', '报备明细']} /><h1></h1><p></p></div></div>
<div className="page-heading"><div><Breadcrumb items={['报备任务', '报备明细']} /><h1></h1><p></p></div></div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
@@ -0,0 +1,291 @@
import { useCallback, useEffect, useState } from 'react';
import { Check, Plus, RefreshCw, Search, Settings2, ShieldOff, Trash2 } from 'lucide-react';
import {
adminApi,
type AdminChannel,
type EnterpriseApplication,
type LegacySignatureReportTask,
type SignatureRetirementMessage,
type SignatureRetirementRule,
type SignatureRetirementRuleType,
type SignatureRetirementSuppression,
type SignatureRetirementWebhook,
type TenantOption,
} from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
const carriers = ['mobile', 'unicom', 'telecom'] as const;
const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' };
const ruleTypeLabels: Record<SignatureRetirementRuleType, string> = {
enterprise_global: '企业全局规则', enterprise_application: '企业应用特殊规则', channel_global: '通道全局规则', channel: '通道特殊规则',
};
const statusOptions = [
{ value: 'pending', label: '待处理' }, { value: 'waiting_material', label: '待材料' }, { value: 'reporting', label: '报备中' },
{ value: 'approved', label: '已通过' }, { value: 'failed', label: '失败' }, { value: 'rejected', label: '驳回' }, { value: 'abandoned', label: '已放弃' },
];
type RuleDraft = {
ruleType: SignatureRetirementRuleType; targetId: string; enabled: boolean;
mobileWindowDays: string; mobileThreshold: string; unicomWindowDays: string; unicomThreshold: string;
telecomWindowDays: string; telecomThreshold: string; messageTemplate: string;
};
const emptyRule: RuleDraft = {
ruleType: 'enterprise_global', targetId: '', enabled: true,
mobileWindowDays: '30', mobileThreshold: '1', unicomWindowDays: '30', unicomThreshold: '1',
telecomWindowDays: '30', telecomThreshold: '1', messageTemplate: '',
};
type MessageFilters = {
dateRange: DateRangeValue;
tenantId: string;
applicationId: string;
signatureKeyword: string;
channelId: string;
};
type SuppressionDraft = {
messageId: string;
mode: 'temporary' | 'permanent';
muteUntil: string;
reason: string;
};
function defaultMessageFilters(): MessageFilters {
const today = shanghaiDateKey();
return { dateRange: { start: today, end: today }, tenantId: '', applicationId: '', signatureKeyword: '', channelId: '' };
}
export function AdminSignatureRetirementPage() {
const [rules, setRules] = useState<SignatureRetirementRule[]>([]);
const [webhooks, setWebhooks] = useState<SignatureRetirementWebhook[]>([]);
const [messages, setMessages] = useState<SignatureRetirementMessage[]>([]);
const [suppressions, setSuppressions] = useState<SignatureRetirementSuppression[]>([]);
const [legacyTasks, setLegacyTasks] = useState<LegacySignatureReportTask[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [messageTotal, setMessageTotal] = useState(0);
const [messagePage, setMessagePage] = useState(1);
const [messageFilters, setMessageFilters] = useState<MessageFilters>(defaultMessageFilters);
const [appliedMessageFilters, setAppliedMessageFilters] = useState<MessageFilters>(defaultMessageFilters);
const [suppressionDraft, setSuppressionDraft] = useState<SuppressionDraft | null>(null);
const [cancelSuppressionDraft, setCancelSuppressionDraft] = useState<{ id: string; reason: string } | null>(null);
const [actionError, setActionError] = useState('');
const [ruleDraft, setRuleDraft] = useState<RuleDraft | null>(null);
const [webhookOpen, setWebhookOpen] = useState(false);
const [webhookDraft, setWebhookDraft] = useState({ name: '', platform: 'wecom' as 'wecom' | 'feishu', url: '' });
const [legacyDraft, setLegacyDraft] = useState<LegacySignatureReportTask | null>(null);
const [legacyResults, setLegacyResults] = useState<Record<string, { status: string; approvedAt: string }>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => {
setLoading(true);
try {
const [configuration, messageResult, activeSuppressions, history, applicationRows, channelRows, tenantRows] = await Promise.all([
adminApi.getSignatureRetirementConfiguration(),
adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)),
adminApi.listSignatureRetirementSuppressions(), adminApi.listLegacySignatureReportTasks(),
adminApi.listEnterpriseApplications(), adminApi.listChannels(), adminApi.listTenants(),
]);
setRules(configuration.rules); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active'));
setMessages(messageResult.items); setMessageTotal(messageResult.total); setMessagePage(messageResult.page);
setSuppressions(activeSuppressions); setLegacyTasks(history);
setApplications(applicationRows); setChannels(channelRows); setTenants(tenantRows); setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '签名清退预警数据加载失败');
} finally { setLoading(false); }
}, []);
useEffect(() => { void loadData(1, defaultMessageFilters()); }, [loadData]);
async function loadMessages(targetPage: number, filters: MessageFilters) {
setLoading(true);
try {
const result = await adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters));
setMessages(result.items); setMessageTotal(result.total); setMessagePage(result.page); setError('');
} catch (failure) {
setError(errorMessage(failure, '预警消息加载失败'));
} finally { setLoading(false); }
}
const ruleColumns: Array<TableColumn<SignatureRetirementRule>> = [
{ key: 'type', title: '规则范围', render: (item) => <><strong>{ruleTypeLabels[item.ruleType]}</strong><br /><small>{targetName(item, applications, channels)}</small></> },
{ key: 'mobile', title: '移动', render: (item) => `${item.mobileWindowDays}天 / ${item.mobileThreshold}` },
{ key: 'unicom', title: '联通', render: (item) => `${item.unicomWindowDays}天 / ${item.unicomThreshold}` },
{ key: 'telecom', title: '电信', render: (item) => `${item.telecomWindowDays}天 / ${item.telecomThreshold}` },
{ key: 'version', title: '版本', render: (item) => `v${item.version}` },
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button onClick={() => setRuleDraft(ruleToDraft(item))} size="sm" variant="ghost"></Button> },
];
const messageColumns: Array<TableColumn<SignatureRetirementMessage>> = [
{ key: 'title', title: '预警', width: '340px', render: (item) => <div className="ui-table__long-text"><strong>{item.title}</strong><br /><span>{item.content}</span></div> },
{ key: 'dimension', title: '维度', width: '240px', render: (item) => <>{item.tenantName ?? '-'}<br /><small>{item.applicationName ?? '未关联企业应用'} / {item.signatureName ?? '-'}</small><br /><small>{item.channelName ?? '企业维度'}</small></> },
{ key: 'carrier', title: '运营商', width: '90px', render: (item) => <Tag tone="warning">{carrierLabels[item.detection?.carrier ?? 'mobile']}</Tag> },
{ key: 'count', title: '活动量', width: '130px', render: (item) => item.detection ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` : '-' },
{ key: 'time', title: '消息时间', width: '170px', render: (item) => formatDateTime(item.createdAt) },
{ key: 'state', title: '状态', width: '90px', render: (item) => item.suppressed ? <Tag></Tag> : item.isRead ? <Tag tone="info"></Tag> : <Tag tone="warning"></Tag> },
{ key: 'actions', title: '操作', align: 'right', width: '190px', render: (item) => <div className="page-actions">{!item.isRead ? <Button onClick={() => void readMessage(item.id)} size="sm" variant="ghost"></Button> : null}{!item.suppressed ? <Button onClick={() => openSuppression(item.id)} size="sm" variant="ghost"></Button> : null}</div> },
];
const suppressionColumns: Array<TableColumn<SignatureRetirementSuppression>> = [
{ key: 'dimension', title: '维度', render: (item) => `${item.dimensionType === 'enterprise' ? '企业' : '通道'} / ${carrierLabels[item.carrier]}` },
{ key: 'mode', title: '方式', render: (item) => item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}` },
{ key: 'reason', title: '原因', render: (item) => item.reason || '-' },
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button icon={<ShieldOff size={15} />} onClick={() => { setActionError(''); setCancelSuppressionDraft({ id: item.id, reason: '' }); }} size="sm" variant="ghost"></Button> },
];
const legacyColumns: Array<TableColumn<LegacySignatureReportTask>> = [
{ key: 'signature', title: '签名', render: (item) => <><strong>{item.signature?.name}</strong><br /><small>{item.signature?.tenant?.name}</small></> },
{ key: 'channel', title: '历史通道', render: (item) => <>{item.channel?.name}<br /><small>{supportedCarriers(item).map((carrier) => carrierLabels[carrier]).join('、')}</small></> },
{ key: 'status', title: '原状态', render: (item) => <Tag tone={item.status === 'approved' ? 'success' : 'neutral'}>{statusLabel(item.status)}</Tag> },
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button icon={<Check size={15} />} onClick={() => openLegacy(item)} size="sm"></Button> },
];
async function saveRule() {
if (!ruleDraft) return;
setLoading(true);
try {
await adminApi.saveSignatureRetirementRule({
...ruleDraft, targetId: ruleDraft.targetId || undefined,
mobileWindowDays: Number(ruleDraft.mobileWindowDays), mobileThreshold: Number(ruleDraft.mobileThreshold),
unicomWindowDays: Number(ruleDraft.unicomWindowDays), unicomThreshold: Number(ruleDraft.unicomThreshold),
telecomWindowDays: Number(ruleDraft.telecomWindowDays), telecomThreshold: Number(ruleDraft.telecomThreshold),
});
setRuleDraft(null); await loadData(messagePage, appliedMessageFilters);
} catch (failure) { setError(errorMessage(failure, '规则保存失败')); } finally { setLoading(false); }
}
async function saveWebhook() {
try { await adminApi.createSignatureRetirementWebhook(webhookDraft); setWebhookOpen(false); setWebhookDraft({ name: '', platform: 'wecom', url: '' }); await loadData(messagePage, appliedMessageFilters); }
catch (failure) { setError(errorMessage(failure, 'Webhook保存失败')); }
}
async function readMessage(id: string) { await adminApi.readSignatureRetirementMessage(id); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); }
function openSuppression(messageId: string) {
setActionError('');
setSuppressionDraft({ messageId, mode: 'temporary', muteUntil: addDateKey(shanghaiDateKey(), 7), reason: '' });
}
async function saveSuppression() {
if (!suppressionDraft) return;
const reason = suppressionDraft.reason.trim();
const days = suppressionDraft.mode === 'temporary' ? differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) : undefined;
if (!reason) { setActionError('请输入抑制原因'); return; }
if (suppressionDraft.mode === 'temporary' && (!days || days < 1)) { setActionError('临时抑制截止日期必须晚于今天'); return; }
setLoading(true);
try {
await adminApi.suppressSignatureRetirementMessage(suppressionDraft.messageId, suppressionDraft.mode === 'permanent' ? { mode: 'permanent', reason } : { mode: 'temporary', days, reason });
setSuppressionDraft(null); setActionError('');
await loadData(messagePage, appliedMessageFilters);
window.dispatchEvent(new Event('cmpp-retirement-count-refresh'));
} catch (failure) { setActionError(errorMessage(failure, '抑制失败')); } finally { setLoading(false); }
}
async function confirmCancelSuppression() {
if (!cancelSuppressionDraft) return;
const reason = cancelSuppressionDraft.reason.trim();
if (!reason) { setActionError('请输入取消抑制原因'); return; }
setLoading(true);
try {
await adminApi.cancelSignatureRetirementSuppression(cancelSuppressionDraft.id, reason);
setCancelSuppressionDraft(null); setActionError(''); await loadData(messagePage, appliedMessageFilters);
} catch (failure) { setActionError(errorMessage(failure, '取消抑制失败')); } finally { setLoading(false); }
}
async function deleteWebhook(id: string) { if (!window.confirm('确认停用该Webhook')) return; await adminApi.deleteSignatureRetirementWebhook(id); await loadData(messagePage, appliedMessageFilters); }
async function readAll() { await adminApi.readAllSignatureRetirementMessagesToday(); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); }
function openLegacy(task: LegacySignatureReportTask) {
setLegacyDraft(task);
// 历史通道级结果不能推导任何运营商状态;留空迫使操作人逐项依据供应商结果确认。
setLegacyResults(Object.fromEntries(supportedCarriers(task).map((carrier) => [carrier, { status: '', approvedAt: '' }])));
}
async function confirmLegacy() {
if (!legacyDraft) return;
await adminApi.confirmLegacySignatureReportTask(legacyDraft.id, {
results: supportedCarriers(legacyDraft).map((carrier) => ({ carrier, status: legacyResults[carrier]?.status ?? 'pending', approvedAt: legacyResults[carrier]?.status === 'approved' && legacyResults[carrier]?.approvedAt ? new Date(legacyResults[carrier].approvedAt).toISOString() : undefined })),
reason: '历史通道级报备按运营商人工确认',
});
setLegacyDraft(null); await loadData(messagePage, appliedMessageFilters);
}
const tenantOptions = tenants.map((item) => ({ value: item.id, label: item.name }));
const applicationOptions = applications
.filter((item) => !messageFilters.tenantId || item.tenantId === messageFilters.tenantId)
.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` }));
function applyMessageQuery() {
const filters = { ...messageFilters, dateRange: normalizeMessageDateRange(messageFilters.dateRange) };
setMessageFilters(filters); setAppliedMessageFilters(filters); setMessagePage(1); void loadMessages(1, filters);
}
function resetMessageQuery() {
const filters = defaultMessageFilters();
setMessageFilters(filters); setAppliedMessageFilters(filters); setMessagePage(1); void loadMessages(1, filters);
}
const tabs = [
{ value: 'messages', label: `预警消息(${messageTotal}`, content: <div className="surface signature-retirement-message-card"><div className="section-heading"><div><h2></h2><p className="muted"></p></div><Button onClick={() => void readAll()} variant="ghost"></Button></div><div className="signature-retirement-message-filter"><Select label="企业" onChange={(event) => setMessageFilters((value) => ({ ...value, tenantId: event.target.value, applicationId: '' }))} options={[{ value: '', label: '全部企业' }, ...tenantOptions]} searchable value={messageFilters.tenantId} /><Select label="企业应用" onChange={(event) => setMessageFilters((value) => ({ ...value, applicationId: event.target.value }))} options={[{ value: '', label: '全部企业应用' }, ...applicationOptions]} searchable value={messageFilters.applicationId} /><Input label="签名" onChange={(event) => setMessageFilters((value) => ({ ...value, signatureKeyword: event.target.value }))} placeholder="请输入签名名称" value={messageFilters.signatureKeyword} /><Select label="通道" onChange={(event) => setMessageFilters((value) => ({ ...value, channelId: event.target.value }))} options={[{ value: '', label: '全部通道' }, ...channels.map((item) => ({ value: item.id, label: `${item.name}${item.code}` }))]} searchable value={messageFilters.channelId} /><DateRangeInput label="预警日期" onChange={(dateRange) => setMessageFilters((value) => ({ ...value, dateRange }))} value={messageFilters.dateRange} /><div className="signature-retirement-message-filter__actions"><Button icon={<Search size={16} />} onClick={applyMessageQuery}></Button><Button onClick={resetMessageQuery} variant="ghost"></Button></div></div><Table columns={messageColumns} data={messages} emptyText="暂无符合条件的预警消息" pagination={false} rowKey="id" />{messageTotal > 0 ? <Pagination nextDisabled={messagePage >= Math.ceil(messageTotal / 10)} onNext={() => { const page = messagePage + 1; setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} onPageChange={(page) => { setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} onPrevious={() => { const page = Math.max(1, messagePage - 1); setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} page={messagePage} previousDisabled={messagePage <= 1} total={messageTotal} totalPages={Math.max(1, Math.ceil(messageTotal / 10))} /> : null}</div> },
{ value: 'rules', label: '检测规则', content: <div className="surface"><div className="section-heading"><div><h2></h2><p className="muted"></p></div><Button icon={<Plus size={16} />} onClick={() => setRuleDraft({ ...emptyRule })}></Button></div><Table columns={ruleColumns} data={rules} emptyText="暂无规则,未配置规则的维度不会进入检测" pagination={false} rowKey="id" /></div> },
{ value: 'webhooks', label: 'Webhook', content: <div className="surface"><div className="section-heading"><div><h2> / </h2><p className="muted">退</p></div><Button icon={<Plus size={16} />} onClick={() => setWebhookOpen(true)}>Webhook</Button></div><div className="settings-list">{webhooks.map((item) => <div className="settings-list__item" key={item.id}><div><strong>{item.name}</strong><p>{item.platform === 'wecom' ? '企业微信' : '飞书'} · {item.urlMasked}</p></div><Button icon={<Trash2 size={15} />} onClick={() => void deleteWebhook(item.id)} variant="ghost"></Button></div>)}{!webhooks.length ? <p className="empty-state">Webhook</p> : null}</div></div> },
{ value: 'suppressions', label: `抑制管理(${suppressions.length}`, content: <div className="surface"><Table columns={suppressionColumns} data={suppressions} emptyText="暂无有效抑制" pagination={false} rowKey="id" /></div> },
{ value: 'legacy', label: `历史待确认(${legacyTasks.length}`, content: <div className="surface"><div className="section-heading"><div><h2></h2><p className="muted"></p></div></div><Table columns={legacyColumns} data={legacyTasks} emptyText="历史通道级报备均已确认" pagination={false} rowKey="id" /></div> },
];
return <section className="page-stack">
<div className="page-heading"><div><Breadcrumb items={['安全控制', '签名清退预警']} /><h1>退</h1><p className="muted">每天北京时间04:00自动检测08:00生成站内消息并发送Webhook</p></div><div className="page-actions"><Button disabled={loading} icon={<RefreshCw size={16} />} onClick={() => void loadData(messagePage, appliedMessageFilters)} variant="ghost"></Button></div></div>
{error ? <p className="form-error">{error}</p> : null}<Tabs items={tabs} />
<RuleModal applications={applications} channels={channels} draft={ruleDraft} loading={loading} onChange={setRuleDraft} onClose={() => setRuleDraft(null)} onSave={() => void saveRule()} />
<Modal open={webhookOpen} title="新增Webhook" onClose={() => setWebhookOpen(false)} footer={<><Button onClick={() => setWebhookOpen(false)} variant="ghost"></Button><Button onClick={() => void saveWebhook()}></Button></>}><div className="form-grid"><Input label="名称" onChange={(event) => setWebhookDraft((value) => ({ ...value, name: event.target.value }))} value={webhookDraft.name} /><Select label="平台" onChange={(event) => setWebhookDraft((value) => ({ ...value, platform: event.target.value as 'wecom' | 'feishu' }))} options={[{ value: 'wecom', label: '企业微信' }, { value: 'feishu', label: '飞书' }]} value={webhookDraft.platform} /><Input className="form-grid__full" label="Webhook HTTPS地址" onChange={(event) => setWebhookDraft((value) => ({ ...value, url: event.target.value }))} value={webhookDraft.url} /></div></Modal>
<Modal dirty={Boolean(suppressionDraft?.reason.trim())} open={Boolean(suppressionDraft)} title="设置预警抑制" onClose={() => { setSuppressionDraft(null); setActionError(''); }} footer={<><Button onClick={() => { setSuppressionDraft(null); setActionError(''); }} variant="ghost"></Button><Button disabled={loading || !suppressionDraft?.reason.trim() || (suppressionDraft?.mode === 'temporary' && differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) < 1)} onClick={() => void saveSuppression()}></Button></>}>
{suppressionDraft ? <div className="page-stack"><p className="muted">Webhook</p><div className="signature-retirement-suppression-modes"><label className={suppressionDraft.mode === 'temporary' ? 'is-selected' : ''}><input checked={suppressionDraft.mode === 'temporary'} name="suppression-mode" onChange={() => setSuppressionDraft((value) => value ? { ...value, mode: 'temporary' } : value)} type="radio" /><span><strong></strong><small></small></span></label><label className={suppressionDraft.mode === 'permanent' ? 'is-selected' : ''}><input checked={suppressionDraft.mode === 'permanent'} name="suppression-mode" onChange={() => setSuppressionDraft((value) => value ? { ...value, mode: 'permanent' } : value)} type="radio" /><span><strong></strong><small></small></span></label></div>{suppressionDraft.mode === 'temporary' ? <Input label="抑制截止日期" min={addDateKey(shanghaiDateKey(), 1)} onChange={(event) => setSuppressionDraft((value) => value ? { ...value, muteUntil: event.target.value } : value)} type="date" value={suppressionDraft.muteUntil} /> : null}<Textarea label="抑制原因" maxLength={500} onChange={(event) => setSuppressionDraft((value) => value ? { ...value, reason: event.target.value } : value)} placeholder="请填写抑制原因" rows={4} value={suppressionDraft.reason} />{actionError ? <p className="form-error">{actionError}</p> : null}</div> : null}
</Modal>
<Modal dirty={Boolean(cancelSuppressionDraft?.reason.trim())} open={Boolean(cancelSuppressionDraft)} title="取消抑制" onClose={() => { setCancelSuppressionDraft(null); setActionError(''); }} footer={<><Button onClick={() => { setCancelSuppressionDraft(null); setActionError(''); }} variant="ghost"></Button><Button disabled={loading || !cancelSuppressionDraft?.reason.trim()} onClick={() => void confirmCancelSuppression()}></Button></>}>
{cancelSuppressionDraft ? <div className="page-stack"><p className="muted"></p><Textarea label="取消原因" maxLength={500} onChange={(event) => setCancelSuppressionDraft((value) => value ? { ...value, reason: event.target.value } : value)} placeholder="请填写取消抑制原因" rows={4} value={cancelSuppressionDraft.reason} />{actionError ? <p className="form-error">{actionError}</p> : null}</div> : null}
</Modal>
<Modal open={Boolean(legacyDraft)} title="按运营商确认历史报备结果" onClose={() => setLegacyDraft(null)} footer={<><Button onClick={() => setLegacyDraft(null)} variant="ghost"></Button><Button disabled={!legacyConfirmationReady(legacyDraft, legacyResults)} onClick={() => void confirmLegacy()}></Button></>}><div className="page-stack"><p className="muted"></p>{legacyDraft ? supportedCarriers(legacyDraft).map((carrier) => <div className="surface" key={carrier}><strong>{carrierLabels[carrier]}</strong><div className="form-grid"><Select label="报备状态" onChange={(event) => setLegacyResults((value) => ({ ...value, [carrier]: { ...value[carrier], status: event.target.value, approvedAt: event.target.value === 'approved' ? value[carrier]?.approvedAt ?? '' : '' } }))} options={[{ value: '', label: '请选择' }, ...statusOptions]} value={legacyResults[carrier]?.status ?? ''} /><Input disabled={legacyResults[carrier]?.status !== 'approved'} label="通过时间" onChange={(event) => setLegacyResults((value) => ({ ...value, [carrier]: { ...value[carrier], approvedAt: event.target.value } }))} type="datetime-local" value={legacyResults[carrier]?.approvedAt ?? ''} /></div></div>) : null}</div></Modal>
</section>;
}
function RuleModal({ applications, channels, draft, loading, onChange, onClose, onSave }: { applications: EnterpriseApplication[]; channels: AdminChannel[]; draft: RuleDraft | null; loading: boolean; onChange: (value: RuleDraft | null) => void; onClose: () => void; onSave: () => void }) {
const special = draft?.ruleType === 'enterprise_application' || draft?.ruleType === 'channel';
const targetOptions = draft?.ruleType === 'enterprise_application' ? applications.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` })) : channels.map((item) => ({ value: item.id, label: `${item.name}${item.code}` }));
const change = (key: keyof RuleDraft, value: string | boolean) => draft && onChange({ ...draft, [key]: value });
return <Modal open={Boolean(draft)} title="签名清退检测规则" onClose={onClose} footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={loading || (special && !draft?.targetId)} icon={<Settings2 size={15} />} onClick={onSave}></Button></>} size="xl"><div className="form-grid">{draft ? <><Select label="规则范围" onChange={(event) => onChange({ ...draft, ruleType: event.target.value as SignatureRetirementRuleType, targetId: '' })} options={Object.entries(ruleTypeLabels).map(([value, label]) => ({ value, label }))} value={draft.ruleType} />{special ? <Select label={draft.ruleType === 'channel' ? '目标通道' : '目标企业应用'} onChange={(event) => change('targetId', event.target.value)} options={targetOptions} placeholder="请选择" searchable value={draft.targetId} /> : <div className="surface"><strong></strong><p className="muted"></p></div>}{carriers.map((carrier) => <div className="surface" key={carrier}><strong>{carrierLabels[carrier]}</strong><div className="form-grid"><Input label="统计窗口(天)" min="1" max="365" onChange={(event) => change(`${carrier}WindowDays` as keyof RuleDraft, event.target.value)} type="number" value={draft[`${carrier}WindowDays`]} /><Input label="最低活动量(条)" min="0" onChange={(event) => change(`${carrier}Threshold` as keyof RuleDraft, event.target.value)} type="number" value={draft[`${carrier}Threshold`]} /></div></div>)}<Textarea className="form-grid__full" hint="可用变量:{enterprise} {signature} {channel} {carrier} {days} {threshold} {actual}" label="消息模板(留空使用系统模板)" onChange={(event) => change('messageTemplate', event.target.value)} rows={4} value={draft.messageTemplate} /></> : null}</div></Modal>;
}
function ruleToDraft(rule: SignatureRetirementRule): RuleDraft { return { ruleType: rule.ruleType, targetId: rule.targetId ?? '', enabled: rule.enabled, mobileWindowDays: String(rule.mobileWindowDays), mobileThreshold: String(rule.mobileThreshold), unicomWindowDays: String(rule.unicomWindowDays), unicomThreshold: String(rule.unicomThreshold), telecomWindowDays: String(rule.telecomWindowDays), telecomThreshold: String(rule.telecomThreshold), messageTemplate: rule.messageTemplate ?? '' }; }
function targetName(rule: SignatureRetirementRule, applications: EnterpriseApplication[], channels: AdminChannel[]) { if (!rule.targetId) return '全局默认'; return rule.ruleType === 'channel' ? channels.find((item) => item.id === rule.targetId)?.name ?? rule.targetId : applications.find((item) => item.id === rule.targetId)?.name ?? rule.targetId; }
function supportedCarriers(task: LegacySignatureReportTask) { const values = task.channel?.carriers?.length ? task.channel.carriers : task.channel?.carrier === 'all' ? [...carriers] : [task.channel?.carrier]; return values.filter((item): item is typeof carriers[number] => carriers.includes(item as typeof carriers[number])); }
function statusLabel(status: string) { return statusOptions.find((item) => item.value === status)?.label ?? status; }
function legacyConfirmationReady(task: LegacySignatureReportTask | null, results: Record<string, { status: string; approvedAt: string }>) {
return Boolean(task && supportedCarriers(task).every((carrier) => {
const result = results[carrier];
return statusOptions.some((option) => option.value === result?.status) && (result.status !== 'approved' || Boolean(result.approvedAt));
}));
}
function localDateTime(value?: string | null) { if (!value) return ''; const date = new Date(value); const offset = date.getTimezoneOffset() * 60_000; return new Date(date.getTime() - offset).toISOString().slice(0, 16); }
function formatDate(value?: string | null) { return value ? new Date(value).toLocaleDateString('zh-CN') : '-'; }
function formatDateTime(value?: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
function errorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; }
function shanghaiDateKey(value = new Date()) { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(value); }
function normalizeMessageDateRange(value: DateRangeValue): DateRangeValue {
const fallback = shanghaiDateKey();
const start = value.start || value.end || fallback;
const end = value.end || value.start || fallback;
return start <= end ? { start, end } : { start: end, end: start };
}
function messageQuery(page: number, filters: MessageFilters) {
const dateRange = normalizeMessageDateRange(filters.dateRange);
return {
dateFrom: dateRange.start,
dateTo: dateRange.end,
tenantId: filters.tenantId || undefined,
applicationId: filters.applicationId || undefined,
signatureKeyword: filters.signatureKeyword.trim() || undefined,
channelId: filters.channelId || undefined,
page,
pageSize: 10,
};
}
function addDateKey(value: string, days: number) {
const date = new Date(`${value}T12:00:00+08:00`);
return shanghaiDateKey(new Date(date.getTime() + days * 86_400_000));
}
function differenceInDateKeys(from: string, to: string) {
if (!to) return 0;
const fromDate = new Date(`${from}T12:00:00+08:00`);
const toDate = new Date(`${to}T12:00:00+08:00`);
return Math.round((toDate.getTime() - fromDate.getTime()) / 86_400_000);
}
+15 -8
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { Button, Input, Modal, Select } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
import { carrierLabelMap, cmppVersionOptions, regionOptions } from './channelModel';
import type { Carrier, ChannelModalState, LongMessageReceiptMode, SmsChannel } from './channelTypes';
import { baseCarrierOptions, cmppVersionOptions, regionOptions } from './channelModel';
import type { BaseCarrier, ChannelModalState, LongMessageReceiptMode, SmsChannel } from './channelTypes';
export function ChannelFormModal({
modal,
@@ -15,7 +15,8 @@ export function ChannelFormModal({
}) {
const channel = modal.channel;
const [name, setName] = useState(channel?.name ?? '');
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
const [carriers, setCarriers] = useState<BaseCarrier[]>(channel?.carriers ?? ['mobile']);
const [carrierError, setCarrierError] = useState('');
const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
const [unitPriceError, setUnitPriceError] = useState('');
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
@@ -36,6 +37,10 @@ export function ChannelFormModal({
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(channel?.longMessageReceiptMode ?? 'per_segment');
function submit() {
if (carriers.length === 0) {
setCarrierError('至少选择一个运营商');
return;
}
if (!isValidMoneyInput(unitPrice)) {
setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位');
return;
@@ -43,7 +48,8 @@ export function ChannelFormModal({
onSubmit({
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
name: name || '新建短信通道',
carrier,
carrier: carriers.length === 3 ? 'all' : carriers[0],
carriers,
sendRegion: region,
unitPrice: yuanToMoneyUnits(unitPrice),
status: channel?.status ?? 'connecting',
@@ -94,12 +100,13 @@ export function ChannelFormModal({
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
<div className="sms-channel-radio-row">
<span>* </span>
{(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => (
<label key={item}>
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
{carrierLabelMap[item]}
{baseCarrierOptions.map((item) => (
<label key={item.value}>
<input checked={carriers.includes(item.value)} onChange={() => { setCarriers((current) => current.includes(item.value) ? current.filter((carrier) => carrier !== item.value) : [...current, item.value]); setCarrierError(''); }} type="checkbox" />
{item.label}
</label>
))}
{carrierError ? <small className="form-error">{carrierError}</small> : null}
</div>
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} />
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} />
+1 -1
View File
@@ -57,7 +57,7 @@ export function ChannelTable({
<span> ID{channel.id}</span>
</div>
<div className="sms-channel-carrier-price">
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
<div>{channel.carriers.map((carrier) => <Tag key={carrier} tone={carrierToneMap[carrier]}>{carrierLabelMap[carrier]}</Tag>)}</div>
<strong>{formatCents(channel.unitPrice)} </strong>
</div>
<div className="sms-channel-status-cell">
+14 -2
View File
@@ -1,5 +1,5 @@
import type { AdminChannel, ChannelQualityStat, CmppConnectionState } from '@/api/adminApi';
import type { Carrier, ChannelStatus, SmsChannel } from './channelTypes';
import type { BaseCarrier, Carrier, ChannelStatus, SmsChannel } from './channelTypes';
export const connectionStatusLabelMap: Record<string, string> = {
connected: '已连接',
@@ -44,6 +44,12 @@ export const carrierLabelMap: Record<Carrier, string> = {
all: '三网',
};
export const baseCarrierOptions: Array<{ label: string; value: BaseCarrier }> = [
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
];
export const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'> = {
mobile: 'info',
unicom: 'danger',
@@ -93,10 +99,16 @@ export function mapApiChannel(
connections: CmppConnectionState[] = channel.connectionStates ?? [],
quality?: ChannelQualityStat,
): SmsChannel {
const carriers: BaseCarrier[] = channel.carriers?.length
? channel.carriers as BaseCarrier[]
: channel.carrier === 'all'
? ['mobile', 'unicom', 'telecom'] as BaseCarrier[]
: [channel.carrier === 'unicom' || channel.carrier === 'telecom' ? channel.carrier : 'mobile'];
return {
id: channel.id,
name: channel.name,
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile',
carriers,
sendRegion: channel.sendRegion ?? '全国',
unitPrice: channel.unitPrice,
status: resolveChannelStatus(channel, connections),
@@ -133,7 +145,7 @@ export function mapUiStatusToApi(channel: SmsChannel) {
export function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
return {
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: Number(channel.gatewayPort),
+2
View File
@@ -1,6 +1,7 @@
import type { ChannelConnectionLogResponse } from '@/api/adminApi';
export type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
export type BaseCarrier = Exclude<Carrier, 'all'>;
export type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
export type LongMessageReceiptMode = 'per_segment' | 'message_level';
@@ -8,6 +9,7 @@ export type SmsChannel = {
id: string;
name: string;
carrier: Carrier;
carriers: BaseCarrier[];
sendRegion: string;
unitPrice: number;
status: ChannelStatus;
@@ -27,7 +27,7 @@ export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignatu
<button className="admin-report-carrier--unicom active" type="button"><strong></strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
<button className="admin-report-carrier--telecom active" type="button"><strong></strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
</div>
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{carrierLabel(target.channel.carrier)}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={`${target.channelId}:${target.carrier}`} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{carrierLabel(target.carrier)}{target.approvalScope === 'legacy_channel' ? ' · 历史通道级结果(待确认)' : ''}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</div>
</Modal>
);
@@ -35,21 +35,21 @@ export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignatu
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
const targets = item.reportTargets ?? [];
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])));
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, carrier: target.carrier, status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
}
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
{targets.length ? targets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="surface admin-report-target-row" key={key}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.carrier)}{target.approvalScope === 'legacy_channel' ? ' · 历史通道级结果,保存后转为运营商级确认' : ''}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
</div>
</Modal>;
+11 -4
View File
@@ -46,6 +46,7 @@ export function AdminLayout() {
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
const loadPendingAuditCount = useCallback(() => {
const currentSession = readSession('admin');
@@ -54,12 +55,14 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
return;
}
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
adminApi.getPendingAudits()
.then((counts) => {
setPendingAudits(counts);
Promise.allSettled([adminApi.getPendingAudits(), adminApi.getSignatureRetirementUnreadCount()])
.then(([audits, retirement]) => {
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
})
.catch(() => {
setPendingAudits(EMPTY_PENDING_AUDITS);
setRetirementUnreadCount(0);
});
}, []);
@@ -73,10 +76,12 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
const onAuditRefresh = () => loadPendingAuditCount();
window.addEventListener('focus', onFocus);
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
return () => {
window.clearInterval(timer);
window.removeEventListener('focus', onFocus);
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
};
}, [loadPendingAuditCount, session.portal, sessionLocked]);
@@ -90,6 +95,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
userName={session.user.displayName}
userRole="平台管理员"
onSessionLockedChange={setSessionLocked}
retirementAlert={{ count: retirementUnreadCount, to: '/admin/signature-retirement' }}
auditNotifications={[
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
@@ -105,7 +111,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
{ label: '运营看板', to: '/admin', icon: Gauge },
{ label: '发送监控', to: '/admin/monitor', icon: Activity },
{ label: '网关异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle },
{ label: '数据统计', to: '/admin/analytics', icon: BarChart3 },
{ label: '签名质量检测', to: '/admin/analytics', icon: BarChart3 },
],
},
{
@@ -181,6 +187,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
icon: Shield,
items: [
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield },
{ label: '签名清退预警', to: '/admin/signature-retirement', icon: AlertTriangle },
{ label: '企业黑名单', to: '/admin/enterprise-blacklist', icon: UserX },
{ label: '全局黑名单', to: '/admin/global-blacklist', icon: ShieldOff },
{ label: '敏感词管理', to: '/admin/sensitive-words', icon: Shield },
+15 -1
View File
@@ -2,6 +2,7 @@ import type { ComponentType } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
ClipboardList,
ChevronDown,
ChevronRight,
CircleHelp,
@@ -59,6 +60,7 @@ type AppShellProps = {
userRole: string;
navSections: ShellNavSection[];
auditNotifications?: AuditNotificationItem[];
retirementAlert?: { count: number; to: string };
onSessionLockedChange?: (locked: boolean) => void;
};
@@ -71,6 +73,7 @@ export function AppShell({
userRole,
navSections,
auditNotifications = [],
retirementAlert,
onSessionLockedChange,
}: AppShellProps) {
const [collapsed, setCollapsed] = useState(false);
@@ -414,6 +417,17 @@ export function AppShell({
<button className="icon-button topbar-help" type="button" aria-label="帮助中心">
<CircleHelp size={18} />
</button>
{retirementAlert ? (
<Link
aria-label={`今日未读且未抑制签名清退预警 ${retirementAlert.count}`}
className={['icon-button', retirementAlert.count > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
title="今日未读且未抑制签名清退预警"
to={retirementAlert.to}
>
<Bell size={18} />
{retirementAlert.count > 0 ? <span className="notice-count">{retirementAlert.count}</span> : null}
</Link>
) : null}
<div className="notice-menu-wrap">
<button
aria-expanded={noticeOpen}
@@ -423,7 +437,7 @@ export function AppShell({
type="button"
aria-label="通知"
>
<Bell size={18} />
<ClipboardList size={18} />
{auditTotal > 0 ? <span className="notice-count">{auditTotal}</span> : null}
</button>
{noticeOpen ? (
+2
View File
@@ -35,6 +35,7 @@ import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
import { AdminSignatureAuditPage } from '@/apps/admin/AdminSignatureAuditPage';
import { AdminSignatureRetirementPage } from '@/apps/admin/AdminSignatureRetirementPage';
import { AdminDrainageAuditPage } from '@/apps/admin/AdminDrainageAuditPage';
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
@@ -112,6 +113,7 @@ export function AppRoutes() {
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
<Route path="risk-rules" element={<AdminRiskRulesPage />} />
<Route path="signature-retirement" element={<AdminSignatureRetirementPage />} />
<Route path="report-tasks" element={<AdminReportTasksPage />} />
<Route path="report-materials" element={<AdminReportMaterialsPage />} />
<Route path="report-records" element={<AdminReportRecordsPage />} />
+170
View File
@@ -6523,6 +6523,160 @@
overflow: hidden;
}
.signature-retirement-heatmap {
padding: var(--space-5);
}
.signature-retirement-heatmap__heading,
.signature-retirement-heatmap__actions {
align-items: center;
}
.signature-retirement-heatmap__actions {
display: flex;
gap: var(--space-3);
}
.signature-retirement-heatmap__actions .ui-field {
min-width: 280px;
}
.signature-retirement-heatmap__scroll {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: auto;
}
.signature-retirement-heatmap table {
border-collapse: collapse;
min-width: 100%;
white-space: nowrap;
}
.signature-retirement-heatmap th,
.signature-retirement-heatmap td {
border-bottom: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
font-size: 12px;
min-width: 48px;
padding: 8px;
text-align: center;
}
.signature-retirement-heatmap th:first-child {
background: var(--color-surface);
left: 0;
min-width: 280px;
position: sticky;
text-align: left;
z-index: 1;
}
.signature-retirement-heatmap tbody th {
align-items: center;
display: flex;
gap: var(--space-2);
justify-content: space-between;
}
.signature-retirement-heatmap__identity {
display: grid;
gap: 2px;
min-width: 0;
text-align: left;
}
.signature-retirement-heatmap__identity strong {
color: var(--color-text-strong);
cursor: help;
overflow: hidden;
text-overflow: ellipsis;
}
.signature-retirement-heatmap__identity small {
color: var(--color-text-muted);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
}
.signature-retirement-heatmap td.is-zero {
background: #f3f4f6;
color: var(--color-text-muted);
font-weight: 700;
}
.signature-retirement-heatmap td.is-inapplicable {
background: repeating-linear-gradient(135deg, #f8fafc, #f8fafc 4px, #eef2f7 4px, #eef2f7 8px);
color: var(--color-text-muted);
}
.signature-retirement-heatmap td.is-rate-red { background: #fee2e2; color: #dc2626; font-weight: 700; }
.signature-retirement-heatmap td.is-rate-orange { background: #ffedd5; color: #ea580c; font-weight: 700; }
.signature-retirement-heatmap td.is-rate-yellow { background: #fef9c3; color: #ca8a04; font-weight: 700; }
.signature-retirement-heatmap td.is-rate-blue { background: #dbeafe; color: #2563eb; font-weight: 700; }
.signature-retirement-heatmap td.is-rate-green { background: #dcfce7; color: #16a34a; font-weight: 700; }
.signature-retirement-heatmap td.is-rate-deep-green { background: #d1fae5; color: #047857; font-weight: 700; }
.ui-table__long-text {
display: block;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: normal;
word-break: normal;
}
.signature-retirement-message-card {
overflow: hidden;
}
.signature-retirement-message-filter {
align-items: end;
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(4, minmax(180px, 1fr));
margin-bottom: var(--space-4);
}
.signature-retirement-message-filter__actions {
display: flex;
gap: var(--space-2);
}
.signature-retirement-suppression-modes {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.signature-retirement-suppression-modes label {
align-items: flex-start;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
gap: var(--space-3);
padding: var(--space-4);
}
.signature-retirement-suppression-modes label.is-selected {
background: var(--color-selected-soft);
border-color: var(--color-selected);
}
.signature-retirement-suppression-modes input {
margin-top: 3px;
}
.signature-retirement-suppression-modes span {
display: grid;
gap: 4px;
}
.signature-retirement-suppression-modes small {
color: var(--color-text-muted);
}
.signature-quality-card__heading {
align-items: flex-start;
display: flex;
@@ -6901,6 +7055,22 @@
}
@media (max-width: 900px) {
.signature-retirement-message-filter,
.signature-retirement-suppression-modes {
grid-template-columns: 1fr;
}
.signature-retirement-heatmap__heading,
.signature-retirement-heatmap__actions {
align-items: stretch;
flex-direction: column;
}
.signature-retirement-heatmap__actions .ui-field {
min-width: 0;
width: 100%;
}
.signature-quality-card__heading,
.signature-quality-card__query {
align-items: stretch;