fix: 修复上行归属并实现签名质量日报优化
This commit is contained in:
@@ -43,8 +43,10 @@ export const adminOperationsApi = {
|
||||
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }),
|
||||
getSendQuality: (date?: string) =>
|
||||
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)),
|
||||
getSignatureQuality: (
|
||||
query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {},
|
||||
signal?: AbortSignal,
|
||||
) => request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query), { signal }),
|
||||
listSystemLogs: (query: {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { request, withQuery } from '../core/httpClient';
|
||||
import type {
|
||||
PagedResult,
|
||||
SignatureActivityResponse,
|
||||
SignatureAnalyticsMetadata,
|
||||
SignatureRetirementHeatmapItem,
|
||||
SignatureRetirementHeatmapDimension,
|
||||
SignatureRetirementMessage,
|
||||
@@ -12,6 +14,19 @@ import type {
|
||||
} from '../types';
|
||||
|
||||
export const adminSignatureRetirementApi = {
|
||||
getSignatureActivity: (
|
||||
query: {
|
||||
date: string;
|
||||
dimensionType: 'enterprise' | 'channel';
|
||||
page: number;
|
||||
pageSize: number;
|
||||
tenantName?: string;
|
||||
applicationName?: string;
|
||||
signatureName?: string;
|
||||
channelName?: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) => request<SignatureActivityResponse>(withQuery('/admin/signature-retirement/activity', query), { signal }),
|
||||
getSignatureRetirementConfiguration: () =>
|
||||
request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>(
|
||||
'/admin/signature-retirement/configuration',
|
||||
@@ -79,8 +94,12 @@ export const adminSignatureRetirementApi = {
|
||||
dimensions: SignatureRetirementHeatmapDimension[];
|
||||
items: SignatureRetirementHeatmapItem[];
|
||||
}>(withQuery('/admin/signature-retirement/heatmap', { date })),
|
||||
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResult<UnreportedSignatureItem> & { date: string }>(
|
||||
getUnreportedSignatures: (
|
||||
query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {},
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
request<PagedResult<UnreportedSignatureItem> & { date: string } & SignatureAnalyticsMetadata>(
|
||||
withQuery('/admin/signature-retirement/unreported-signatures', query),
|
||||
{ signal },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SignatureAnalyticsMetadata } from './signature-retirement';
|
||||
// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts.
|
||||
|
||||
import type { AdminChannel } from './channels-reports';
|
||||
@@ -85,7 +86,7 @@ export type SignatureChannelQualityItem = {
|
||||
drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[];
|
||||
};
|
||||
|
||||
export type SignatureChannelQualityResponse = {
|
||||
export type SignatureChannelQualityResponse = SignatureAnalyticsMetadata & {
|
||||
date: string;
|
||||
items: SignatureChannelQualityItem[];
|
||||
total: number;
|
||||
|
||||
@@ -105,3 +105,42 @@ export type UnreportedSignatureItem = {
|
||||
applicationName?: string | null;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
export type SignatureAnalyticsMetadata = {
|
||||
dataSource?: 'live' | 'report';
|
||||
reportState?: string;
|
||||
frozen?: boolean;
|
||||
provenance?: string;
|
||||
sourceAsOf?: string | null;
|
||||
generatedAt?: string | null;
|
||||
generationId?: string | null;
|
||||
};
|
||||
export type SignatureActivityItem = Pick<
|
||||
SignatureRetirementHeatmapItem,
|
||||
| 'id'
|
||||
| 'dimensionType'
|
||||
| 'signatureId'
|
||||
| 'channelId'
|
||||
| 'carrier'
|
||||
| 'activityDate'
|
||||
| 'submittedAttempts'
|
||||
| 'acceptedBusinessCount'
|
||||
| 'deliveredBusinessCount'
|
||||
| 'status'
|
||||
>;
|
||||
export type SignatureActivityResponse = {
|
||||
date: string;
|
||||
items: SignatureActivityItem[];
|
||||
dimensions: (SignatureRetirementHeatmapDimension & { total: number })[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
complete: boolean;
|
||||
coverage: {
|
||||
date: string;
|
||||
generationId: string | null;
|
||||
reportState: string;
|
||||
frozen: boolean;
|
||||
sourceAsOf: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -3,49 +3,33 @@ 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() },
|
||||
api: { getSignatureQuality: vi.fn(), getSignatureActivity: vi.fn(), getUnreportedSignatures: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
|
||||
|
||||
describe('independent analytics tabs', () => {
|
||||
it('combines separate activity search fields with AND and preserves them across tabs', async () => {
|
||||
api.getSignatureRetirementHeatmap.mockResolvedValue({
|
||||
items: [],
|
||||
dimensions: [
|
||||
{
|
||||
dimensionType: 'channel',
|
||||
signatureId: 'a',
|
||||
signatureName: '签名甲',
|
||||
tenantName: '企业甲',
|
||||
applicationName: '应用甲',
|
||||
channelName: '通道甲',
|
||||
channelId: 'c',
|
||||
carrier: 'mobile',
|
||||
approvedAt: '2026-08-01',
|
||||
},
|
||||
{
|
||||
dimensionType: 'channel',
|
||||
signatureId: 'b',
|
||||
signatureName: '签名乙',
|
||||
tenantName: '企业甲',
|
||||
applicationName: '应用乙',
|
||||
channelName: '通道乙',
|
||||
channelId: 'd',
|
||||
carrier: 'unicom',
|
||||
approvedAt: '2026-08-01',
|
||||
},
|
||||
],
|
||||
});
|
||||
it('submits independent activity fields to server and preserves drafts across tabs', async () => {
|
||||
render(<AdminAnalyticsPage />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
|
||||
const panel = screen.getByRole('region', { name: '通道签名活跃度' });
|
||||
await within(panel).findByText('签名甲');
|
||||
await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(1));
|
||||
fireEvent.change(within(panel).getByLabelText('企业'), { target: { value: '企业甲' } });
|
||||
fireEvent.change(within(panel).getByLabelText('企业应用'), { target: { value: '应用甲' } });
|
||||
await waitFor(() => expect(within(panel).queryByText('签名乙')).not.toBeInTheDocument());
|
||||
fireEvent.change(within(panel).getByLabelText('通道'), { target: { value: '通道乙' } });
|
||||
await waitFor(() => expect(within(panel).queryByText('签名甲')).not.toBeInTheDocument());
|
||||
expect(api.getSignatureActivity).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '查询统计' }));
|
||||
await waitFor(() =>
|
||||
expect(api.getSignatureActivity).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ dimensionType: 'channel', tenantName: '企业甲', applicationName: '应用甲', page: 1 }),
|
||||
expect.any(AbortSignal),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '企业签名活跃度' }));
|
||||
await waitFor(() =>
|
||||
expect(api.getSignatureActivity).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ dimensionType: 'enterprise', tenantName: '', applicationName: '' }),
|
||||
expect.any(AbortSignal),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
|
||||
expect(within(panel).getByLabelText('企业应用')).toHaveValue('应用甲');
|
||||
});
|
||||
@@ -53,19 +37,33 @@ describe('independent analytics tabs', () => {
|
||||
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: [] });
|
||||
api.getSignatureActivity.mockResolvedValue({
|
||||
items: [],
|
||||
dimensions: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
coverage: [],
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
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.getSignatureQuality).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 25 }),
|
||||
expect.any(AbortSignal),
|
||||
),
|
||||
);
|
||||
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled();
|
||||
expect(api.getSignatureActivity).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' })),
|
||||
expect(api.getSignatureQuality).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ date: '2026-08-20' }),
|
||||
expect.any(AbortSignal),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
|
||||
await waitFor(() => expect(api.getUnreportedSignatures).toHaveBeenCalledTimes(1));
|
||||
@@ -75,7 +73,7 @@ describe('independent analytics tabs', () => {
|
||||
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();
|
||||
expect(api.getSignatureActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -89,9 +87,16 @@ describe('independent analytics tabs', () => {
|
||||
await waitFor(() => expect(within(panel).getByRole('button', { name: '下一页' })).toBeEnabled());
|
||||
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '查询统计' }));
|
||||
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })));
|
||||
await waitFor(() =>
|
||||
expect(api[method]).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ date: '2026-08-20' }),
|
||||
expect.any(AbortSignal),
|
||||
),
|
||||
);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '下一页' }));
|
||||
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })));
|
||||
await waitFor(() =>
|
||||
expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }), expect.any(AbortSignal)),
|
||||
);
|
||||
// An unsubmitted date must not alter a pagination request.
|
||||
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-21' } });
|
||||
const size = within(panel).getByLabelText(/^每页数量/);
|
||||
@@ -108,6 +113,7 @@ describe('independent analytics tabs', () => {
|
||||
await waitFor(() =>
|
||||
expect(api[method]).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 1, pageSize: 100, date: '2026-08-20' }),
|
||||
expect.any(AbortSignal),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(within(panel).getByLabelText('跳转页码')).toHaveValue(1));
|
||||
@@ -118,7 +124,7 @@ describe('independent analytics tabs', () => {
|
||||
async (tab) => {
|
||||
render(<AdminAnalyticsPage />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: tab }));
|
||||
await waitFor(() => expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(1));
|
||||
const panel = screen.getByRole('region', { name: tab });
|
||||
const size = within(panel).getByLabelText(/^每页数量/);
|
||||
expect(size.closest('.ui-pagination')).not.toBeNull();
|
||||
@@ -131,7 +137,7 @@ describe('independent analytics tabs', () => {
|
||||
).toHaveTextContent('25 条/页');
|
||||
fireEvent.click(screen.getByRole('tab', { name: tab }));
|
||||
expect(size).toHaveTextContent('50 条/页');
|
||||
expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(2));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useDeferredValue, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BarChart3, Eye, Search, X } from 'lucide-react';
|
||||
import {
|
||||
adminApi,
|
||||
type SignatureChannelCarrierQualityStat,
|
||||
type SignatureChannelQualityItem,
|
||||
type SignatureActivityResponse,
|
||||
type SignatureAnalyticsMetadata,
|
||||
type SignatureChannelQualityResponse,
|
||||
type SignatureRetirementHeatmapItem,
|
||||
type SignatureActivityItem,
|
||||
type SignatureRetirementHeatmapDimension,
|
||||
type UnreportedSignatureItem,
|
||||
type PagedResult,
|
||||
@@ -59,9 +61,19 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey());
|
||||
const requestId = useRef(0);
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
const [activity, setActivity] = useState<SignatureActivityResponse | null>(null);
|
||||
const [activityFilters, setActivityFilters] = useState({
|
||||
tenantName: '',
|
||||
applicationName: '',
|
||||
signatureName: '',
|
||||
channelName: '',
|
||||
});
|
||||
const [appliedActivityFilters, setAppliedActivityFilters] = useState(activityFilters);
|
||||
const [metadata, setMetadata] = useState<SignatureAnalyticsMetadata | null>(null);
|
||||
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
||||
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
|
||||
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]);
|
||||
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureActivityItem[]>([]);
|
||||
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
|
||||
const [unreportedSignatures, setUnreportedSignatures] = useState<
|
||||
(PagedResult<UnreportedSignatureItem> & { date: string }) | null
|
||||
@@ -80,30 +92,47 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
unreported = appliedUnreportedKeyword,
|
||||
date = statisticsDate,
|
||||
size = pageSize,
|
||||
filters = appliedActivityFilters,
|
||||
) {
|
||||
abort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abort.current = controller;
|
||||
const id = ++requestId.current;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
if (kind === 'quality') {
|
||||
const data = await adminApi.getSignatureQuality({ date, keyword: keyword || undefined, page, pageSize: size });
|
||||
const data = await adminApi.getSignatureQuality(
|
||||
{ date, keyword: keyword || undefined, page, pageSize: size },
|
||||
controller.signal,
|
||||
);
|
||||
if (id !== requestId.current) return;
|
||||
setSignatureQuality(data);
|
||||
setMetadata(data);
|
||||
setAppliedKeyword(keyword);
|
||||
setSelectedSignature(null);
|
||||
} else if (kind === 'unreported') {
|
||||
const data = await adminApi.getUnreportedSignatures({
|
||||
date,
|
||||
keyword: unreported || undefined,
|
||||
page,
|
||||
pageSize: size,
|
||||
});
|
||||
const data = await adminApi.getUnreportedSignatures(
|
||||
{
|
||||
date,
|
||||
keyword: unreported || undefined,
|
||||
page,
|
||||
pageSize: size,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (id !== requestId.current) return;
|
||||
setUnreportedSignatures(data);
|
||||
setMetadata(data);
|
||||
setAppliedUnreportedKeyword(unreported);
|
||||
} else {
|
||||
const data = await adminApi.getSignatureRetirementHeatmap(date);
|
||||
const data = await adminApi.getSignatureActivity(
|
||||
{ date, dimensionType: kind as 'enterprise' | 'channel', page, pageSize: size, ...filters },
|
||||
controller.signal,
|
||||
);
|
||||
if (id !== requestId.current) return;
|
||||
setActivity(data);
|
||||
setAppliedActivityFilters(filters);
|
||||
setRetirementHeatmap(data.items);
|
||||
setRetirementDimensions(data.dimensions);
|
||||
}
|
||||
@@ -115,9 +144,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const initialLoad = useRef(loadData);
|
||||
useEffect(() => {
|
||||
void loadData(1, '');
|
||||
void initialLoad.current(1, '');
|
||||
return () => {
|
||||
abort.current?.abort();
|
||||
requestId.current += 1;
|
||||
};
|
||||
}, []);
|
||||
@@ -221,7 +252,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
];
|
||||
|
||||
function queryStatistics() {
|
||||
void loadData(1, signatureKeyword.trim());
|
||||
void loadData(1, signatureKeyword.trim(), unreportedKeyword.trim(), statisticsDate, pageSize, activityFilters);
|
||||
}
|
||||
|
||||
function changeSignaturePage(page: number) {
|
||||
@@ -233,8 +264,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
|
||||
function changePageSize(size: number) {
|
||||
setPageSize(size);
|
||||
if (kind === 'quality' || kind === 'unreported')
|
||||
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
|
||||
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -256,7 +286,28 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{error ? (
|
||||
<p className="form-error">
|
||||
{error}
|
||||
{signatureQuality || activity || unreportedSignatures
|
||||
? `;仍显示上次成功查询的数据,日期为 ${appliedDate}。`
|
||||
: ';尚无成功查询的数据。'}
|
||||
</p>
|
||||
) : null}
|
||||
{metadata ? (
|
||||
<p className="muted" role="status">
|
||||
{metadata.dataSource === 'live' ? '当天实时查询' : metadata.frozen ? '已冻结日报' : '近三日可刷新日报'} ·{' '}
|
||||
{metadata.reportState === 'missing'
|
||||
? '报表尚未生成'
|
||||
: metadata.reportState === 'failed'
|
||||
? '生成失败,保留上次完整日报'
|
||||
: metadata.reportState === 'refreshing'
|
||||
? '正在刷新,显示上次完整日报'
|
||||
: '查询完成'}
|
||||
{metadata.provenance === 'backfill-current-source' ? ' · 事后补建' : ''}
|
||||
{metadata.sourceAsOf ? ` · 数据截止 ${new Date(metadata.sourceAsOf).toLocaleString('zh-CN')}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{kind === 'quality' ? (
|
||||
<div className="surface signature-quality-card">
|
||||
@@ -323,6 +374,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
onPageSizeChange={changePageSize}
|
||||
date={appliedDate}
|
||||
dimensionType="enterprise"
|
||||
activity={activity}
|
||||
filters={activityFilters}
|
||||
onFilterChange={setActivityFilters}
|
||||
onSearch={queryStatistics}
|
||||
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
|
||||
dimensions={retirementDimensions}
|
||||
items={retirementHeatmap}
|
||||
title="企业签名活跃度热力图"
|
||||
@@ -334,6 +390,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
onPageSizeChange={changePageSize}
|
||||
date={appliedDate}
|
||||
dimensionType="channel"
|
||||
activity={activity}
|
||||
filters={activityFilters}
|
||||
onFilterChange={setActivityFilters}
|
||||
onSearch={queryStatistics}
|
||||
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
|
||||
dimensions={retirementDimensions}
|
||||
items={retirementHeatmap}
|
||||
title="通道签名活跃度热力图"
|
||||
@@ -349,7 +410,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
loading={loading}
|
||||
onKeywordChange={setUnreportedKeyword}
|
||||
onPageChange={(page) => loadUnreportedSignatures(page)}
|
||||
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
|
||||
onSearch={queryStatistics}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -365,6 +426,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
}
|
||||
|
||||
function RetirementHeatmap({
|
||||
activity,
|
||||
filters,
|
||||
onFilterChange,
|
||||
onSearch,
|
||||
onPageChange,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
date,
|
||||
@@ -378,12 +444,14 @@ function RetirementHeatmap({
|
||||
date: string;
|
||||
dimensionType: 'enterprise' | 'channel';
|
||||
dimensions: SignatureRetirementHeatmapDimension[];
|
||||
items: SignatureRetirementHeatmapItem[];
|
||||
items: SignatureActivityItem[];
|
||||
title: string;
|
||||
activity: SignatureActivityResponse | null;
|
||||
filters: { tenantName: string; applicationName: string; signatureName: string; channelName: string };
|
||||
onFilterChange: (value: typeof filters) => void;
|
||||
onSearch: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
const [pageState, setPageState] = useState({ key: '', page: 1 });
|
||||
const [filters, setFilters] = useState({ tenantName: '', applicationName: '', signatureName: '', channelName: '' });
|
||||
const deferredFilters = useDeferredValue(filters);
|
||||
const visible = items.filter((item) => item.dimensionType === dimensionType);
|
||||
const dates = previousDateKeys(date, 30);
|
||||
const cellMap = new Map(
|
||||
@@ -392,40 +460,17 @@ function RetirementHeatmap({
|
||||
item,
|
||||
]),
|
||||
);
|
||||
const rows = dimensions
|
||||
.filter((item) => item.dimensionType === dimensionType)
|
||||
.filter((item) =>
|
||||
Object.entries(deferredFilters).every(
|
||||
([key, value]) =>
|
||||
!value.trim() ||
|
||||
(item[key as keyof typeof deferredFilters] ?? '')
|
||||
.toLocaleLowerCase('zh-CN')
|
||||
.includes(value.trim().toLocaleLowerCase('zh-CN')),
|
||||
),
|
||||
)
|
||||
.map((item) => ({
|
||||
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
|
||||
signatureName: item.signatureName,
|
||||
channelName: item.channelName,
|
||||
tenantName: item.tenantName,
|
||||
applicationName: item.applicationName,
|
||||
carrier: item.carrier,
|
||||
approvedAt: item.approvedAt.slice(0, 10),
|
||||
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, deferredFilters, 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);
|
||||
const rows = dimensions.map((item) => ({
|
||||
...item,
|
||||
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
|
||||
approvedAt: item.approvedAt?.slice(0, 10) ?? '',
|
||||
total: (item as SignatureRetirementHeatmapDimension & { total: number }).total ?? 0,
|
||||
}));
|
||||
const totalPages = Math.max(1, Math.ceil((activity?.total ?? 0) / pageSize));
|
||||
const currentPage = activity?.page ?? 1;
|
||||
const pagedRows = rows;
|
||||
const setPage = onPageChange;
|
||||
const coverage = new Map(activity?.coverage.map((day) => [day.date, day]));
|
||||
|
||||
return (
|
||||
<div className="surface signature-retirement-heatmap">
|
||||
@@ -448,12 +493,18 @@ function RetirementHeatmap({
|
||||
label={label}
|
||||
placeholder={`搜索${label}`}
|
||||
value={filters[key]}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, [key]: event.target.value }))}
|
||||
onChange={(event) => onFilterChange({ ...filters, [key]: event.target.value })}
|
||||
/>
|
||||
))}
|
||||
<Tag tone="info">T-1 至 T-30</Tag>
|
||||
<Button onClick={onSearch} icon={<Search size={16} />}>
|
||||
查询
|
||||
</Button>
|
||||
<Tag tone="info">所选日之前30天</Tag>
|
||||
</div>
|
||||
</div>
|
||||
{activity && !activity.complete ? (
|
||||
<p className="form-error">部分日期报表尚未生成,合计仅包含已发布日期,不代表完整30日数据。</p>
|
||||
) : null}
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="signature-retirement-heatmap__scroll">
|
||||
@@ -482,7 +533,7 @@ function RetirementHeatmap({
|
||||
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
|
||||
{dates.map((dateKey) => {
|
||||
const item = cellMap.get(`${row.key}:${dateKey}`);
|
||||
const beforeApproval = dateKey < row.approvedAt;
|
||||
const beforeApproval = Boolean(coverage.get(dateKey)?.generationId && !item);
|
||||
const successRate = item?.acceptedBusinessCount
|
||||
? (item.deliveredBusinessCount / item.acceptedBusinessCount) * 100
|
||||
: 0;
|
||||
@@ -494,10 +545,10 @@ function RetirementHeatmap({
|
||||
? '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} 条`
|
||||
: '当日无检测快照';
|
||||
? `提交条数:${item.submittedAttempts} 条\n上游接受条数:${item.acceptedBusinessCount} 条\n发送成功条数:${item.deliveredBusinessCount} 条\n发送成功率:${successRate.toFixed(1)}%\n数据来源:${coverage.get(dateKey)?.frozen ? '冻结日报' : '可刷新日报'}`
|
||||
: '当日报表尚未生成';
|
||||
return (
|
||||
<td className={className} key={dateKey} title={titleText}>
|
||||
{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
|
||||
@@ -512,7 +563,7 @@ function RetirementHeatmap({
|
||||
</>
|
||||
) : (
|
||||
<p className="empty-state">
|
||||
{Object.values(deferredFilters).some((value) => value.trim())
|
||||
{Object.values(filters).some((value) => value.trim())
|
||||
? '没有匹配企业、企业应用或签名的热力图维度。'
|
||||
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
|
||||
</p>
|
||||
@@ -526,7 +577,7 @@ function RetirementHeatmap({
|
||||
onPrevious={() => setPage(currentPage - 1)}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={rows.length}
|
||||
total={activity?.total ?? 0}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user