fix: bound formatter memory and improve operations workflows

This commit is contained in:
hectorzhao
2026-09-08 12:46:15 +08:00
parent 50ae37242b
commit 2c228a94e1
27 changed files with 4013 additions and 1230 deletions
+2
View File
@@ -47,6 +47,8 @@ export type SignatureRetirementDetection = {
};
export type SignatureRetirementMessage = {
dailyGroupKey?: string | null;
detections?: Array<SignatureRetirementDetection & { signatureName?: string; channelName?: string }>;
id: string;
detectionId: string;
cycleId: string;
+22
View File
@@ -0,0 +1,22 @@
.admin-analytics-page .page-actions {
flex-wrap: wrap;
}
.admin-analytics-page .page-heading {
gap: 16px;
}
.admin-analytics-page [hidden] {
display: none;
}
@media (width <= 600px) {
.admin-analytics-page .page-heading {
align-items: stretch;
flex-direction: column;
}
.admin-analytics-page .page-actions {
align-items: end;
}
}
@@ -0,0 +1,39 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminAnalyticsPage } from './AdminAnalyticsPage';
const { api } = vi.hoisted(() => ({
api: { getSignatureQuality: vi.fn(), getSignatureRetirementHeatmap: vi.fn(), getUnreportedSignatures: vi.fn() },
}));
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
describe('independent analytics tabs', () => {
beforeEach(() => {
vi.resetAllMocks();
api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
api.getUnreportedSignatures.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
api.getSignatureRetirementHeatmap.mockResolvedValue({ items: [], dimensions: [] });
});
it('loads only the visited tab and preserves independent dates when switching back', async () => {
render(<AdminAnalyticsPage />);
await waitFor(() =>
expect(api.getSignatureQuality).toHaveBeenCalledWith(expect.objectContaining({ pageSize: 25 })),
);
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled();
const quality = screen.getByRole('region', { name: '签名通道发送质量' });
fireEvent.change(within(quality).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
fireEvent.click(within(quality).getByRole('button', { name: '查询统计' }));
await waitFor(() =>
expect(api.getSignatureQuality).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })),
);
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
await waitFor(() => expect(api.getUnreportedSignatures).toHaveBeenCalledTimes(1));
const unreported = screen.getByRole('region', { name: '未报备签名' });
fireEvent.change(within(unreported).getByLabelText('统计日期'), { target: { value: '2026-08-25' } });
fireEvent.click(screen.getByRole('tab', { name: '签名通道发送质量' }));
expect(within(quality).getByLabelText('统计日期')).toHaveValue('2026-08-20');
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
expect(within(unreported).getByLabelText('统计日期')).toHaveValue('2026-08-25');
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled();
});
});
+443 -193
View File
@@ -1,4 +1,4 @@
import { useDeferredValue, useEffect, useState } from 'react';
import { useDeferredValue, useEffect, useRef, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
@@ -10,7 +10,19 @@ import {
type UnreportedSignatureItem,
type PagedResult,
} from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import {
Breadcrumb,
Button,
CarrierTag,
Input,
Pagination,
Select,
Tabs,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
import './AdminAnalyticsPage.css';
import { successRateClassName, successRateTone } from '@/utils/successRate';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
@@ -20,19 +32,51 @@ const drainageStates = [
{ value: 'without', label: '不含引流' },
{ value: 'unknown', label: '未检测' },
] as const;
const carrierLabels: Record<string, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
unknown: '未知',
};
const analyticsTabs = [
{ value: 'quality', label: '签名通道发送质量' },
{ value: 'enterprise', label: '企业签名活跃度' },
{ value: 'channel', label: '通道签名活跃度' },
{ value: 'unreported', label: '未报备签名' },
];
export function AdminAnalyticsPage() {
const [active, setActive] = useState('quality');
const [visited, setVisited] = useState(['quality']);
return (
<section className="page-stack admin-analytics-page">
<div className="page-heading">
<div>
<Breadcrumb items={['签名质量检测']} />
<h1></h1>
</div>
</div>
<Tabs
value={active}
onChange={(value) => {
setActive(value);
setVisited((current) => (current.includes(value) ? current : [...current, value]));
}}
items={analyticsTabs.map((tab) => ({ ...tab, content: null }))}
/>
{visited.map((kind) => (
<div key={kind} hidden={kind !== active}>
<AnalyticsPanel kind={kind} />
</div>
))}
</section>
);
}
function AnalyticsPanel({ kind }: { kind: string }) {
const [pageSize, setPageSize] = useState(25);
const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey());
const requestId = useRef(0);
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
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 [unreportedSignatures, setUnreportedSignatures] = useState<
(PagedResult<UnreportedSignatureItem> & { date: string }) | null
>(null);
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [unreportedKeyword, setUnreportedKeyword] = useState('');
@@ -41,37 +85,52 @@ export function AdminAnalyticsPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function loadData(page = 1, keyword = appliedKeyword) {
async function loadData(
page = 1,
keyword = appliedKeyword,
unreported = appliedUnreportedKeyword,
date = statisticsDate,
size = pageSize,
) {
const id = ++requestId.current;
setLoading(true);
setError('');
try {
const [signatureData, heatmapData, unreportedData] = await Promise.all([
adminApi.getSignatureQuality({
date: statisticsDate,
keyword: keyword || undefined,
if (kind === 'quality') {
const data = await adminApi.getSignatureQuality({ date, keyword: keyword || undefined, page, pageSize: size });
if (id !== requestId.current) return;
setSignatureQuality(data);
setAppliedKeyword(keyword);
setSelectedSignature(null);
} else if (kind === 'unreported') {
const data = await adminApi.getUnreportedSignatures({
date,
keyword: unreported || undefined,
page,
pageSize: 10,
}),
adminApi.getSignatureRetirementHeatmap(statisticsDate),
adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: appliedUnreportedKeyword || undefined, page: 1, pageSize: 10 }),
]);
setSignatureQuality(signatureData);
setRetirementHeatmap(heatmapData.items);
setRetirementDimensions(heatmapData.dimensions);
setUnreportedSignatures(unreportedData);
setAppliedKeyword(keyword);
setSelectedSignature((current) => current
? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null
: null);
setError('');
pageSize: size,
});
if (id !== requestId.current) return;
setUnreportedSignatures(data);
setAppliedUnreportedKeyword(unreported);
} else {
const data = await adminApi.getSignatureRetirementHeatmap(date);
if (id !== requestId.current) return;
setRetirementHeatmap(data.items);
setRetirementDimensions(data.dimensions);
}
setAppliedDate(date);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '统计数据加载失败');
if (id === requestId.current) setError(failure instanceof Error ? failure.message : '统计数据加载失败');
} finally {
setLoading(false);
if (id === requestId.current) setLoading(false);
}
}
useEffect(() => {
void loadData(1, '');
return () => {
requestId.current += 1;
};
}, []);
useEffect(() => {
@@ -122,21 +181,29 @@ export function AdminAnalyticsPage() {
title: '送达成功',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>,
render: (record) => (
<span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>
),
},
{
key: 'failureCount',
title: '送达失败',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>,
render: (record) => (
<span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>
),
},
{
key: 'submitFailureCount',
title: '提交失败',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--warning">{record.submitFailureCount.toLocaleString('zh-CN')}</span>,
render: (record) => (
<span className="quality-number quality-number--warning">
{record.submitFailureCount.toLocaleString('zh-CN')}
</span>
),
},
{
key: 'successRate',
@@ -169,36 +236,30 @@ export function AdminAnalyticsPage() {
}
function changeSignaturePage(page: number) {
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));
void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate);
}
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));
void loadData(page, appliedKeyword, keyword, appliedDate);
}
return (
<section className="page-stack">
<section className="page-stack" aria-label={analyticsTabs.find((tab) => tab.value === kind)?.label}>
<div className="page-heading">
<div>
<Breadcrumb items={['签名质量检测']} />
<h1></h1>
<p className="muted"></p>
</div>
<div className="page-actions">
<Select
label="每页数量"
value={String(pageSize)}
options={[10, 25, 50, 100].map((value) => ({ value: String(value), label: `${value} 条/页` }))}
onChange={(event) => {
const size = Number(event.target.value);
setPageSize(size);
if (kind === 'quality' || kind === 'unreported')
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
}}
/>
<Input
aria-label="统计日期"
max={shanghaiDateKey()}
@@ -213,66 +274,93 @@ export function AdminAnalyticsPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
<div className="section-heading__title">
<h2></h2>
<Tag tone="info"></Tag>
{kind === 'quality' ? (
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
<div className="section-heading__title">
<h2></h2>
<Tag tone="info"></Tag>
</div>
<p className="muted">
{signatureQuality?.date ?? effectiveDate}
</p>
</div>
<div className="signature-quality-card__query">
<Input
aria-label="搜索短信签名、企业或应用"
onChange={(event) => setSignatureKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') queryStatistics();
}}
placeholder="搜索签名、企业或应用"
value={signatureKeyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={queryStatistics} variant="secondary">
</Button>
</div>
<p className="muted">
{signatureQuality?.date ?? effectiveDate}
</p>
</div>
<div className="signature-quality-card__query">
<Input
aria-label="搜索短信签名、企业或应用"
onChange={(event) => setSignatureKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') queryStatistics();
}}
placeholder="搜索签名、企业或应用"
value={signatureKeyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={queryStatistics} variant="secondary"></Button>
<div className="signature-quality-card__note">
<strong></strong>
</div>
</div>
<div className="signature-quality-card__note">
<strong></strong>
</div>
<Table
columns={signatureColumns}
data={signatureQuality?.items ?? []}
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
pagination={false}
rowKey="signatureId"
/>
{(signatureQuality?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={(signatureQuality?.page ?? 1) >= Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
onPageChange={changeSignaturePage}
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
page={signatureQuality?.page ?? 1}
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
total={signatureQuality?.total ?? 0}
totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
<Table
columns={signatureColumns}
data={signatureQuality?.items ?? []}
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
pagination={false}
rowKey="signatureId"
/>
) : null}
</div>
{(signatureQuality?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={
(signatureQuality?.page ?? 1) >=
Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))
}
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
onPageChange={changeSignaturePage}
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
page={signatureQuality?.page ?? 1}
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
total={signatureQuality?.total ?? 0}
totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
/>
) : null}
</div>
) : null}
<RetirementHeatmap date={statisticsDate} dimensionType="enterprise" dimensions={retirementDimensions} items={retirementHeatmap} title="企业签名活跃度热力图" />
<RetirementHeatmap date={statisticsDate} dimensionType="channel" dimensions={retirementDimensions} items={retirementHeatmap} title="通道签名活跃度热力图" />
{kind === 'enterprise' ? (
<RetirementHeatmap
pageSize={pageSize}
date={appliedDate}
dimensionType="enterprise"
dimensions={retirementDimensions}
items={retirementHeatmap}
title="企业签名活跃度热力图"
/>
) : null}
{kind === 'channel' ? (
<RetirementHeatmap
pageSize={pageSize}
date={appliedDate}
dimensionType="channel"
dimensions={retirementDimensions}
items={retirementHeatmap}
title="通道签名活跃度热力图"
/>
) : null}
<UnreportedSignaturesCard
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
/>
{kind === 'unreported' ? (
<UnreportedSignaturesCard
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
/>
) : null}
{selectedSignature ? (
<SignatureQualityDrawer
@@ -285,18 +373,41 @@ 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);
function RetirementHeatmap({
pageSize,
date,
dimensionType,
dimensions,
items,
title,
}: {
pageSize: number;
date: string;
dimensionType: 'enterprise' | 'channel';
dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[];
title: string;
}) {
const [pageState, setPageState] = useState({ key: '', page: 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 cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
const cellMap = new Map(
visible.map((item) => [
`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`,
item,
]),
);
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.channelName, item.tenantName, item.applicationName, item.signatureName]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
.filter(
(item) =>
!deferredKeyword ||
[item.channelName, 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,
@@ -305,17 +416,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
applicationName: item.applicationName,
carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10),
total: dates.reduce((sum, dateKey) => sum + (cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)?.acceptedBusinessCount ?? 0), 0),
total: dates.reduce(
(sum, dateKey) =>
sum +
(cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)
?.acceptedBusinessCount ?? 0),
0,
),
}))
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const paginationKey = JSON.stringify([date, deferredKeyword, dimensionType, dimensions.length, pageSize]);
const page = pageState.key === paginationKey ? pageState.page : 1;
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
const currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [date, deferredKeyword, dimensionType, dimensions.length]);
return (
<div className="surface signature-retirement-heatmap">
<div className="section-heading signature-retirement-heatmap__heading">
@@ -338,14 +454,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
<div className="signature-retirement-heatmap__scroll">
<table>
<thead>
<tr><th></th><th>30</th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
<tr>
<th></th>
<th>30</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>
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>
{row.signatureName}
</strong>
{row.channelName ? <small>{row.channelName}</small> : null}
</span>
<CarrierTag carrier={row.carrier} />
@@ -354,10 +478,26 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
{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.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold}` : '当日无检测快照';
return <td className={className} key={dateKey} title={titleText}>{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}</td>;
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.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold}`
: '当日无检测快照';
return (
<td className={className} key={dateKey} title={titleText}>
{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
</td>
);
})}
</tr>
))}
@@ -375,7 +515,13 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
totalPages={totalPages}
/>
</>
) : <p className="empty-state">{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}</p>}
) : (
<p className="empty-state">
{deferredKeyword
? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
)}
</div>
);
}
@@ -399,28 +545,43 @@ function UnreportedSignaturesCard({
{ 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')}` },
{
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>
<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(); }}
onKeyDown={(event) => {
if (event.key === 'Enter') onSearch();
}}
placeholder="搜索签名、企业或企业应用"
value={keyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary"></Button>
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary">
</Button>
</div>
</div>
<div className="signature-quality-card__note"><strong></strong></div>
<div className="signature-quality-card__note">
<strong></strong>
</div>
<Table
columns={columns}
data={data?.items ?? []}
@@ -467,33 +628,52 @@ function SignatureQualityDrawer({
.sort((left, right) => {
const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier));
const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier));
return (leftRank < 0 ? carrierOrder.length : leftRank)
- (rightRank < 0 ? carrierOrder.length : rightRank);
return (leftRank < 0 ? carrierOrder.length : leftRank) - (rightRank < 0 ? carrierOrder.length : rightRank);
});
const channels = [...new Map([...item.breakdowns, ...item.drainageBreakdowns]
.map((entry) => [entry.channelId, entry.channelName])).entries()]
.map(([channelId, channelName]) => ({ channelId, channelName }));
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
const channels = [
...new Map(
[...item.breakdowns, ...item.drainageBreakdowns].map((entry) => [entry.channelId, entry.channelName]),
).entries(),
].map(([channelId, channelName]) => ({ channelId, channelName }));
const visibleCarriers = carrierOrder.filter((carrier) =>
item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier),
);
return (
<div className="signature-quality-drawer__backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<aside aria-labelledby="signature-quality-drawer-title" aria-modal="true" className="signature-quality-drawer" role="dialog">
<div
className="signature-quality-drawer__backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<aside
aria-labelledby="signature-quality-drawer-title"
aria-modal="true"
className="signature-quality-drawer"
role="dialog"
>
<div className="signature-quality-drawer__header">
<div>
<p> / {item.signatureName}</p>
<h2 id="signature-quality-drawer-title">{item.signatureName}</h2>
<span>{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}</span>
<span>
{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}
</span>
</div>
<button aria-label="关闭签名发送质量详情" onClick={onClose} type="button"><X size={20} /></button>
<button aria-label="关闭签名发送质量详情" onClick={onClose} type="button">
<X size={20} />
</button>
</div>
<div className="signature-quality-drawer__body">
<div className="signature-quality-overview">
<QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} />
<QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
<QualityMetric label="最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} />
<QualityMetric
label="最终成功率"
value={`${item.successRate.toFixed(1)}%`}
valueClassName={successRateClassName(item.successRate)}
/>
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
</div>
@@ -506,15 +686,29 @@ function SignatureQualityDrawer({
</div>
<div className="signature-carrier-grid">
{carriers.map((carrier) => (
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}>
<article
className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`}
key={carrier.carrier}
>
<div>
<CarrierTag carrier={carrier.carrier} />
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} </strong>
</div>
<dl>
<div><dt></dt><dd className={successRateClassName(carrier.finalSuccessRate)}>{carrier.finalSuccessRate.toFixed(1)}%</dd></div>
<div><dt></dt><dd>{formatDuration(carrier.averageArrivalMs)}</dd></div>
<div><dt></dt><dd>{carrier.channelCount} </dd></div>
<div>
<dt></dt>
<dd className={successRateClassName(carrier.finalSuccessRate)}>
{carrier.finalSuccessRate.toFixed(1)}%
</dd>
</div>
<div>
<dt></dt>
<dd>{formatDuration(carrier.averageArrivalMs)}</dd>
</div>
<div>
<dt></dt>
<dd>{carrier.channelCount} </dd>
</div>
</dl>
</article>
))}
@@ -525,11 +719,28 @@ function SignatureQualityDrawer({
<div className="signature-quality-section__heading">
<div>
<h3> × </h3>
<p>{matrixMode === 'overall'
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}</p>
<p>
{matrixMode === 'overall'
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}
</p>
</div>
<div className="page-actions">
<Button
onClick={() => setMatrixMode('overall')}
size="sm"
variant={matrixMode === 'overall' ? 'primary' : 'ghost'}
>
</Button>
<Button
onClick={() => setMatrixMode('drainage')}
size="sm"
variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}
>
</Button>
</div>
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}></Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}></Button></div>
</div>
<div className="signature-quality-matrix">
<table>
@@ -537,47 +748,67 @@ function SignatureQualityDrawer({
{matrixMode === 'overall' ? (
<tr>
<th></th>
{visibleCarriers.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)}
{visibleCarriers.map((carrier) => (
<th key={carrier}>
<CarrierTag carrier={carrier} />
</th>
))}
</tr>
) : (
<tr>
<th></th>
<th></th>
{majorCarrierOrder.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)}
{majorCarrierOrder.map((carrier) => (
<th key={carrier}>
<CarrierTag carrier={carrier} />
</th>
))}
</tr>
)}
</thead>
<tbody>
{matrixMode === 'overall'
? channels.map((channel) => (
<tr key={channel.channelId}>
<th>{channel.channelName}</th>
{visibleCarriers.map((carrier) => {
const metric = item.breakdowns.find((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
return (
<td key={carrier}>
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>}
</td>
);
})}
</tr>
))
: channels.flatMap((channel) => drainageStates.map((state, stateIndex) => (
<tr key={`${channel.channelId}-${state.value}`}>
{stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null}
<th className="signature-quality-matrix__drainage-label">{state.label}</th>
{majorCarrierOrder.map((carrier) => {
const metric = item.drainageBreakdowns.find((entry) => (
entry.channelId === channel.channelId
&& normalizeCarrier(entry.carrier) === carrier
&& entry.drainageState === state.value
));
return <td key={carrier}><MatrixMetric metric={metric} zeroWhenEmpty /></td>;
})}
</tr>
)))}
<tr key={channel.channelId}>
<th>{channel.channelName}</th>
{visibleCarriers.map((carrier) => {
const metric = item.breakdowns.find(
(entry) =>
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier,
);
return (
<td key={carrier}>
{metric ? (
<MatrixMetric metric={metric} />
) : (
<span className="signature-quality-matrix__empty"></span>
)}
</td>
);
})}
</tr>
))
: channels.flatMap((channel) =>
drainageStates.map((state, stateIndex) => (
<tr key={`${channel.channelId}-${state.value}`}>
{stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null}
<th className="signature-quality-matrix__drainage-label">{state.label}</th>
{majorCarrierOrder.map((carrier) => {
const metric = item.drainageBreakdowns.find(
(entry) =>
entry.channelId === channel.channelId &&
normalizeCarrier(entry.carrier) === carrier &&
entry.drainageState === state.value,
);
return (
<td key={carrier}>
<MatrixMetric metric={metric} zeroWhenEmpty />
</td>
);
})}
</tr>
)),
)}
</tbody>
</table>
</div>
@@ -601,16 +832,35 @@ function QualityMetric({ label, value, valueClassName }: { label: string; value:
);
}
function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) {
function MatrixMetric({
metric,
zeroWhenEmpty = false,
}: {
metric?: SignatureChannelCarrierQualityStat;
zeroWhenEmpty?: boolean;
}) {
const total = metric?.total ?? 0;
const successRate = metric?.successRate ?? 0;
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
return (
<div className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}>
<div><small></small><strong>{total.toLocaleString('zh-CN')} </strong></div>
<div><small></small><span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>{successRate.toFixed(1)}%</span></div>
<div><small></small><span>{formatDuration(metric?.averageArrivalMs)}</span></div>
<div
className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}
>
<div>
<small></small>
<strong>{total.toLocaleString('zh-CN')} </strong>
</div>
<div>
<small></small>
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
{successRate.toFixed(1)}%
</span>
</div>
<div>
<small></small>
<span>{formatDuration(metric?.averageArrivalMs)}</span>
</div>
{(metric?.submitFailureCount ?? 0) > 0 ? <em> {metric?.submitFailureCount}</em> : null}
</div>
);
@@ -619,7 +869,9 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
<div><span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} /></div>
<div>
<span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
</div>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div>
);
@@ -633,10 +885,6 @@ function normalizeCarrier(value: string) {
return 'unknown';
}
function carrierLabel(value: string) {
return carrierLabels[normalizeCarrier(value)] ?? '未知';
}
function formatDuration(value?: number | null) {
if (value == null) return '—';
if (value < 1000) return `${Math.round(value)} 毫秒`;
@@ -656,5 +904,7 @@ function shanghaiDateKey(value = new Date()) {
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)));
return Array.from({ length: days }, (_, index) =>
shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000)),
);
}
+87
View File
@@ -0,0 +1,87 @@
.admin-dashboard .home-metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.admin-dashboard .home-metric {
padding: 18px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: #fff;
min-width: 0;
}
.admin-dashboard .home-metric__heading {
display: flex;
justify-content: space-between;
align-items: start;
gap: 8px;
font-size: 13px;
color: #6b7280;
}
.admin-dashboard .home-metric__category {
display: flex;
align-items: center;
gap: 4px;
color: #2563eb;
font-size: 12px;
white-space: nowrap;
}
.admin-dashboard .home-metric__number {
display: flex;
align-items: baseline;
gap: 6px;
margin: 16px 0 10px;
color: #111827;
font-variant-numeric: tabular-nums;
flex-wrap: wrap;
overflow-wrap: anywhere;
}
.admin-dashboard .home-metric__number strong {
font-size: 26px;
font-weight: 600;
line-height: 1.2;
}
.admin-dashboard .home-metric__number span {
font-size: 12px;
color: #6b7280;
}
.admin-dashboard .home-metric__number.is-negative {
color: #dc2626;
}
.admin-dashboard .home-metric p {
margin: 0;
font-size: 12px;
color: #6b7280;
}
@media (width <= 1400px) {
.admin-dashboard .home-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (width <= 700px) {
.admin-dashboard .home-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard .home-metric {
padding: 14px;
}
.admin-dashboard .home-metric__category {
display: none;
}
.admin-dashboard .home-metric__number strong {
font-size: 23px;
}
}
+94 -55
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import './AdminHome.css';
import { Chart } from '@/components/ui/Chart';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
@@ -170,61 +171,99 @@ export function AdminHome() {
</div>
</div>
<div className="dashboard-grid admin-metric-grid">
<div className="surface metric-card">
<span></span>
<strong>{formatCount(totalSend)} </strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{formatCount(dashboard?.today.segmentCount ?? 0)} </strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{averageSuccessRate.toFixed(1)}%</strong>
<small>delivered / </small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{(dashboard?.today.arrivalRate ?? 0).toFixed(1)}%</strong>
<small> / </small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{activeSignatureCount}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todayReturned)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todayBilled)}</strong>
<small> × </small>
</div>
<div className="surface metric-card">
<span></span>
<strong className={todayProfit < 0 ? 'metric-card__value--danger' : undefined}>
¥{formatCurrency(todayProfit)}
</strong>
<small> - </small>
</div>
<div className="surface metric-card">
<span></span>
<strong className={(dashboard?.today.profitRate ?? 0) < 0 ? 'metric-card__value--danger' : undefined}>
{(dashboard?.today.profitRate ?? 0).toFixed(1)}%
</strong>
<small> / </small>
</div>
<div className="home-metrics" aria-busy={!dashboard && !error}>
{[
{
label: '今日发送总量',
value: formatCount(totalSend),
unit: '条',
note: '业务短信',
group: '发送',
icon: <BarChart3 size={18} />,
},
{
label: '今日消息分片数',
value: formatCount(dashboard?.today.segmentCount ?? 0),
unit: '片',
note: '实际消息分片',
group: '发送',
},
{
label: '总体成功率',
value: averageSuccessRate.toFixed(1),
unit: '%',
note: '送达成功 / 今日总量',
group: '质量',
icon: <ShieldCheck size={18} />,
},
{
label: '今日到达率',
value: (dashboard?.today.arrivalRate ?? 0).toFixed(1),
unit: '%',
note: '到达分片 / 发送总分片',
group: '质量',
},
{
label: '今日活跃签名',
value: formatCount(activeSignatureCount),
unit: '个',
note: '今日有真实发送记录',
group: '发送',
},
{
label: '今日消费金额',
value: formatCurrency(todaySpend),
unit: '元',
note: '今日消息消费',
group: '经营',
icon: <DollarSign size={18} />,
},
{
label: '今日返还金额',
value: formatCurrency(todayReturned),
unit: '元',
note: '今日返还流水',
group: '经营',
},
{
label: '今日计收金额',
value: formatCurrency(todayBilled),
unit: '元',
note: '成功计费条数 × 客户价',
group: '经营',
},
{
label: '今日利润',
value: formatCurrency(todayProfit),
unit: '元',
note: '计收金额 − 成功分片通道成本',
group: '经营',
danger: todayProfit < 0,
},
{
label: '今日利润率',
value: (dashboard?.today.profitRate ?? 0).toFixed(1),
unit: '%',
note: '今日利润 / 今日计收金额',
group: '经营',
danger: (dashboard?.today.profitRate ?? 0) < 0,
},
].map((metric) => (
<article className="home-metric" key={metric.label}>
<div className="home-metric__heading">
<span>{metric.label}</span>
<span className="home-metric__category">
{metric.icon}
{metric.group}
</span>
</div>
<div className={metric.danger ? 'home-metric__number is-negative' : 'home-metric__number'}>
<strong>{dashboard ? metric.value : '—'}</strong>
<span>{metric.unit}</span>
</div>
<p>{dashboard ? metric.note : error ? '数据暂不可用' : '正在加载…'}</p>
</article>
))}
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
+3 -1
View File
@@ -471,7 +471,9 @@ export function AdminReportTasksPage() {
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value })),
...Object.entries(statusMeta)
.filter(([value]) => !['exporting', 'rejected'].includes(value))
.map(([value, meta]) => ({ label: meta.label, value })),
]}
value={status}
/>
+815 -80
View File
@@ -11,23 +11,55 @@ import {
type SignatureRetirementWebhook,
type TenantOption,
} from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import {
Breadcrumb,
Button,
CarrierTag,
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: '通道特殊规则',
enterprise_global: '企业全局规则',
enterprise_application: '企业应用特殊规则',
channel_global: '通道全局规则',
channel: '通道特殊规则',
};
type RuleDraft = {
ruleType: SignatureRetirementRuleType; targetId: string; enabled: boolean;
mobileWindowDays: string; mobileThreshold: string; unicomWindowDays: string; unicomThreshold: string;
telecomWindowDays: string; telecomThreshold: string; messageTemplate: string;
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: '',
ruleType: 'enterprise_global',
targetId: '',
enabled: true,
mobileWindowDays: '30',
mobileThreshold: '1',
unicomWindowDays: '30',
unicomThreshold: '1',
telecomWindowDays: '30',
telecomThreshold: '1',
messageTemplate: '',
};
type MessageFilters = {
@@ -47,7 +79,13 @@ type SuppressionDraft = {
function defaultMessageFilters(): MessageFilters {
const today = shanghaiDateKey();
return { dateRange: { start: today, end: today }, tenantId: '', applicationId: '', signatureKeyword: '', channelId: '' };
return {
dateRange: { start: today, end: today },
tenantId: '',
applicationId: '',
signatureKeyword: '',
channelId: '',
};
}
export function AdminSignatureRetirementPage() {
@@ -74,74 +112,252 @@ export function AdminSignatureRetirementPage() {
const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => {
setLoading(true);
try {
const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] = await Promise.all([
adminApi.getSignatureRetirementConfiguration(),
adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)),
adminApi.listSignatureRetirementSuppressions(),
adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels(), adminApi.listTenantOptions(),
]);
setRules(configuration.rules); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active'));
setMessages(messageResult.items); setMessageTotal(messageResult.total); setMessagePage(messageResult.page);
const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] =
await Promise.all([
adminApi.getSignatureRetirementConfiguration(),
adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)),
adminApi.listSignatureRetirementSuppressions(),
adminApi.listEnterpriseApplicationOptions(),
adminApi.listChannels(),
adminApi.listTenantOptions(),
]);
setRules(configuration.rules);
setWebhooks(configuration.webhooks.filter((item) => item.status === 'active'));
setMessages(messageResult.items);
setMessageTotal(messageResult.total);
setMessagePage(messageResult.page);
setSuppressions(activeSuppressions);
setApplications(applicationRows); setChannels(channelRows); setTenants(tenantRows); setError('');
setApplications(applicationRows);
setChannels(channelRows);
setTenants(tenantRows);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '签名清退预警数据加载失败');
} finally { setLoading(false); }
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void loadData(1, defaultMessageFilters()); }, [loadData]);
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('');
setMessages(result.items);
setMessageTotal(result.total);
setMessagePage(result.page);
setError('');
} catch (failure) {
setError(errorMessage(failure, '预警消息加载失败'));
} finally { setLoading(false); }
} 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: '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> },
{
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) => <CarrierTag carrier={item.detection?.carrier ?? 'mobile'} /> },
{ key: 'count', title: '活动量', width: '130px', render: (item) => item.detection ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` : '-' },
{
key: 'title',
title: '预警',
width: '340px',
render: (item) => (
<div className="ui-table__long-text">
<strong>{item.title}</strong>
<br />
<div>
{item.dailyGroupKey ? (
<details>
<summary>{item.detections?.length ?? 0} </summary>
{item.content.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</details>
) : (
item.content
)}
</div>
</div>
),
},
{
key: 'dimension',
title: '维度',
width: '240px',
render: (item) => (
<>
{item.tenantName ?? '-'}
<br />
<small>
{item.applicationName ?? '未关联企业应用'}
{item.dailyGroupKey ? '' : ` / ${item.signatureName ?? '-'}`}
</small>
<br />
<small>{item.dailyGroupKey ? '企业应用每日汇总' : (item.channelName ?? '企业维度')}</small>
</>
),
},
{
key: 'carrier',
title: '运营商',
width: '90px',
render: (item) =>
item.dailyGroupKey ? (
<>
{[...new Set(item.detections?.map((entry) => entry.carrier) ?? [])].map((carrier) => (
<CarrierTag key={carrier} carrier={carrier} />
))}
</>
) : (
<CarrierTag carrier={item.detection?.carrier ?? 'mobile'} />
),
},
{
key: 'count',
title: '活动量',
width: '130px',
render: (item) =>
item.dailyGroupKey
? `${item.detections?.length ?? 0} 项(详见正文)`
: 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> },
{
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">
{item.dailyGroupKey ? '抑制整组' : '抑制'}
</Button>
) : null}
</div>
),
},
];
const suppressionColumns: Array<TableColumn<SignatureRetirementSuppression>> = [
{ key: 'dimension', title: '维度', render: (item) => <span>{item.dimensionType === 'enterprise' ? '企业' : '通道'} / <CarrierTag carrier={item.carrier} /></span> },
{ key: 'mode', title: '方式', render: (item) => item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}` },
{
key: 'dimension',
title: '维度',
render: (item) => (
<span>
{item.dimensionType === 'enterprise' ? '企业' : '通道'} / <CarrierTag carrier={item.carrier} />
</span>
),
},
{
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> },
{
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button
icon={<ShieldOff size={15} />}
onClick={() => {
setActionError('');
setCancelSuppressionDraft({ id: item.id, reason: '' });
}}
size="sm"
variant="ghost"
>
</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),
...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); }
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保存失败')); }
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'));
}
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: '' });
@@ -149,76 +365,595 @@ export function AdminSignatureRetirementPage() {
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; }
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 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); }
} catch (failure) {
setActionError(errorMessage(failure, '抑制失败'));
} finally {
setLoading(false);
}
}
async function confirmCancelSuppression() {
if (!cancelSuppressionDraft) return;
const reason = cancelSuppressionDraft.reason.trim();
if (!reason) { setActionError('请输入取消抑制原因'); return; }
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); }
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'));
}
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')); }
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);
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);
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: '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>
),
},
];
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>
</section>;
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>
</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 }) {
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 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>;
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 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 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 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;
+143 -23
View File
@@ -32,9 +32,13 @@ export function ChannelFormModal({
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(
String(channel?.heartbeatIntervalSeconds ?? 30),
);
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(channel?.longMessageReceiptMode ?? 'per_segment');
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(
channel?.longMessageReceiptMode ?? 'per_segment',
);
function submit() {
if (carriers.length === 0) {
@@ -82,34 +86,74 @@ export function ChannelFormModal({
return (
<Modal
footer={(
closeOnBackdrop={modal.mode === 'edit'}
closeOnEscape={modal.mode === 'edit'}
footer={
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={onClose} variant="ghost">
</Button>
<Button onClick={submit}></Button>
</>
)}
}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2></div>}
title={
<div className="template-modal-title">
<h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2>
</div>
}
>
<div className="sms-channel-form">
<section>
<h3></h3>
<div className="sms-channel-form-grid">
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
<Input
label="* 通道名称"
onChange={(event) => setName(event.target.value)}
placeholder="请输入通道名称"
value={name}
/>
<div className="sms-channel-radio-row">
<span>* </span>
{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" />
<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} />
<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}
/>
</div>
</section>
@@ -118,13 +162,39 @@ export function ChannelFormModal({
<div className="sms-channel-form-grid">
<Input disabled label="* 协议选择" value="CMPP" />
<div className="sms-channel-inline-field">
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
<Input
label="* 网关地址"
onChange={(event) => setGatewayHost(event.target.value)}
placeholder="请输入网关地址"
value={gatewayHost}
/>
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
</div>
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} />
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
<Input
hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。"
label="* 业务代码"
maxLength={10}
onChange={(event) => setBusinessCode(event.target.value.toUpperCase())}
value={businessCode}
/>
<Input
label="* 企业代码"
onChange={(event) => setCorpCode(event.target.value)}
placeholder="请输入企业代码"
value={corpCode}
/>
<Input
label="* 网关账号"
onChange={(event) => setAccount(event.target.value)}
placeholder="请输入网关账号"
value={account}
/>
<Select
label="* CMPP版本"
onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')}
options={cmppVersionOptions}
value={cmppVersion}
/>
<Input
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
label="网关密码"
@@ -137,14 +207,62 @@ export function ChannelFormModal({
value={password}
/>
<div className="sms-channel-inline-field">
<Input autoComplete="off" label="* 接入号" name="cmpp-access-number" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
<Input
autoComplete="off"
label="* 接入号"
name="cmpp-access-number"
onChange={(event) => setAccessNo(event.target.value)}
placeholder="请输入通道接入号"
value={accessNo}
/>
<Input
label="扩展位数"
max="20"
min="0"
onChange={(event) => setExtensionDigits(event.target.value)}
type="number"
value={extensionDigits}
/>
</div>
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
<Input
label="* 通道流速"
max="2000"
min="1"
onChange={(event) => setFlowLimit(event.target.value)}
suffix="条/秒"
type="number"
value={flowLimit}
/>
<Input
label="* 期望连接数"
onChange={(event) => setDesiredConnections(event.target.value)}
placeholder="1"
value={desiredConnections}
/>
<Input
label="* 提交窗口"
onChange={(event) => setWindowSize(event.target.value)}
placeholder="16"
value={windowSize}
/>
<Input
hint="平台主动向供应商发送 ACTIVE_TEST 的间隔"
label="* 心跳间隔"
min="1"
onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)}
suffix="秒"
type="number"
value={heartbeatIntervalSeconds}
/>
<Input
hint="连续未收到心跳响应达到该次数后重连"
label="* 心跳失败阈值"
min="1"
onChange={(event) => setHeartbeatMissThreshold(event.target.value)}
suffix="次"
type="number"
value={heartbeatMissThreshold}
/>
<Select
label="* 长短信成功回执口径"
onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)}
@@ -154,7 +272,9 @@ export function ChannelFormModal({
]}
value={longMessageReceiptMode}
/>
<p className="page-inline-hint"></p>
<p className="page-inline-hint">
</p>
</div>
</section>
</div>
+29
View File
@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Modal } from './Modal';
describe('Modal close policy', () => {
it('keeps an explicitly locked form open on mask and Escape but permits the close button', () => {
const close = vi.fn();
render(
<Modal open title="创建通道" closeOnBackdrop={false} closeOnEscape={false} onClose={close}>
</Modal>,
);
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
fireEvent.keyDown(document, { key: 'Escape' });
expect(close).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(close).toHaveBeenCalledOnce();
});
it('preserves mask closing by default for existing consumers', () => {
const close = vi.fn();
render(
<Modal open title="普通弹窗" onClose={close}>
</Modal>,
);
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
expect(close).toHaveBeenCalledOnce();
});
});
+42 -16
View File
@@ -16,6 +16,8 @@ type ModalProps = {
size?: 'md' | 'xl';
onClose: () => void;
dirty?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
initialFocusRef?: RefObject<HTMLElement | null>;
closeGuardTitle?: string;
closeGuardDescription?: string;
@@ -82,8 +84,9 @@ function unlockDocument() {
}
function focusableElements(root: HTMLElement) {
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector))
.filter((element) => !element.hidden && element.getClientRects().length > 0);
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector)).filter(
(element) => !element.hidden && element.getClientRects().length > 0,
);
}
export function Modal({
@@ -94,6 +97,8 @@ export function Modal({
size = 'md',
onClose,
dirty = false,
closeOnBackdrop = true,
closeOnEscape = true,
initialFocusRef,
closeGuardTitle = '放弃未保存的修改?',
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
@@ -108,6 +113,7 @@ export function Modal({
const guardRestoreFocusRef = useRef<HTMLElement | null>(null);
const [showCloseGuard, setShowCloseGuard] = useState(false);
const [layer] = useState(() => modalLayer());
if (!open && showCloseGuard) setShowCloseGuard(false);
const requestClose = useCallback(() => {
if (dirty) {
@@ -124,10 +130,7 @@ export function Modal({
}, [onClose]);
useEffect(() => {
if (!open) {
setShowCloseGuard(false);
return undefined;
}
if (!open) return undefined;
const panel = panelRef.current;
if (!panel) return undefined;
@@ -163,7 +166,7 @@ export function Modal({
event.preventDefault();
event.stopPropagation();
if (showCloseGuard) setShowCloseGuard(false);
else requestClose();
else if (closeOnEscape) requestClose();
return;
}
if (event.key !== 'Tab') return;
@@ -188,7 +191,7 @@ export function Modal({
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [open, requestClose, showCloseGuard]);
}, [closeOnEscape, open, requestClose, showCloseGuard]);
useEffect(() => {
if (!showCloseGuard) return;
@@ -205,13 +208,16 @@ export function Modal({
}, [showCloseGuard]);
if (!open) return null;
const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer;
return createPortal(
<div className="ui-modal" data-ui-modal-root>
<div aria-hidden="true" className="ui-modal__mask" onMouseDown={(event) => {
if (event.target === event.currentTarget) requestClose();
}} />
<div
aria-hidden="true"
className="ui-modal__mask"
onMouseDown={(event) => {
if (closeOnBackdrop && event.target === event.currentTarget) requestClose();
}}
/>
<section
aria-labelledby={titleId}
aria-modal="true"
@@ -221,13 +227,19 @@ export function Modal({
tabIndex={-1}
>
<header className="ui-modal__header">
<div className="ui-modal__title" id={titleId}>{title}</div>
<div className="ui-modal__title" id={titleId}>
{title}
</div>
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
</Button>
</header>
<div className="ui-modal__body">{children}</div>
{renderedFooter ? <footer className="ui-modal__footer">{renderedFooter}</footer> : null}
{footer ? (
<footer className="ui-modal__footer">
<ModalFooter footer={footer} requestClose={requestClose} />
</footer>
) : null}
</section>
{showCloseGuard ? (
<div className="ui-modal__guard-layer">
@@ -246,8 +258,12 @@ export function Modal({
<p id={guardDescriptionId}>{closeGuardDescription}</p>
</div>
<footer>
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost"></Button>
<Button onClick={discardAndClose} variant="danger"></Button>
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">
</Button>
<Button onClick={discardAndClose} variant="danger">
</Button>
</footer>
</section>
</div>
@@ -256,3 +272,13 @@ export function Modal({
layer,
);
}
function ModalFooter({
footer,
requestClose,
}: {
footer: NonNullable<ModalProps['footer']>;
requestClose: () => void;
}) {
return typeof footer === 'function' ? footer({ requestClose }) : footer;
}