feat: add channel report batch briefs

This commit is contained in:
hectorzhao
2026-09-02 18:29:05 +08:00
parent 2dff1be750
commit 7cb5dd376e
27 changed files with 1083 additions and 153 deletions
@@ -40,12 +40,12 @@ export function AdminEnterpriseSignaturesPage() {
const [error, setError] = useState('');
const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState('');
const [signatureSort, setSignatureSort] = useState<'asc' | 'desc'>('asc');
const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [total, setTotal] = useState(0);
const [pendingReportDetailTotal, setPendingReportDetailTotal] = useState(0);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
@@ -63,14 +63,14 @@ export function AdminEnterpriseSignaturesPage() {
drainageKeyword: appliedDrainageKeyword,
},
targetPage = page,
targetSort = signatureSort,
) {
const sequence = ++listRequestSequence.current;
try {
const signatureResult = await adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize });
const signatureResult = await adminApi.listEnterpriseSignaturesPage({ ...filters, page: targetPage, pageSize });
if (sequence !== listRequestSequence.current) return;
setSignatures(signatureResult.items);
setTotal(signatureResult.total);
setPendingReportDetailTotal(signatureResult.pendingReportDetailTotal);
setError('');
} catch (failure) {
if (sequence !== listRequestSequence.current) return;
@@ -216,14 +216,8 @@ export function AdminEnterpriseSignaturesPage() {
onOpenDrainageReport={(signature, item) => void openDrainageReport(signature, item)}
setExpandedSignatureId={setExpandedSignatureId}
setPage={setPage}
setSignatureSort={(nextSort) => {
setSignatureSort(nextSort);
if (page === 1) void loadData(undefined, 1, nextSort);
else setPage(1);
}}
onEditSignature={(signature) => void editSignature(signature)}
onOpenSignatureReport={(signature) => void openSignatureReport(signature)}
signatureSort={signatureSort}
total={total}
totalPages={totalPages}
visibleSignatures={visibleSignatures}
@@ -322,6 +316,15 @@ export function AdminEnterpriseSignaturesPage() {
</div>
</div>
<div className="enterprise-signature-pending-summary" role="status">
<span></span>
<strong>{pendingReportDetailTotal}</strong>
<span></span>
<Button onClick={() => navigate('/admin/report-tasks?scope=pending')} size="sm" variant="ghost">
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
+54 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { Check, Copy, Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import {
Breadcrumb,
@@ -44,6 +44,7 @@ export function AdminReportBatchesPage() {
const [nextStatus, setNextStatus] = useState('reporting');
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const [copiedChannelId, setCopiedChannelId] = useState('');
const pageSize = 20;
function load(target = page, filters = appliedFilters) {
@@ -75,11 +76,32 @@ export function AdminReportBatchesPage() {
setDetail(batchDetail);
setTasks(taskPage.items);
setSelected(new Set());
setCopiedChannelId('');
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次明细加载失败');
}
}
async function copyBrief(channelId: string, content: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(content);
} else {
const textarea = document.createElement('textarea');
textarea.value = content;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
if (!document.execCommand('copy')) throw new Error('浏览器不支持自动复制');
textarea.remove();
}
setCopiedChannelId(channelId);
window.setTimeout(() => setCopiedChannelId((current) => (current === channelId ? '' : current)), 1800);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '简报复制失败,请手工选择复制');
}
}
async function saveStatuses() {
const chosen = tasks.filter((task) => selected.has(task.id));
if (!chosen.length) return;
@@ -309,6 +331,37 @@ export function AdminReportBatchesPage() {
title={`批次明细 · ${detail.batchNo}`}
>
<div className="page-stack">
<section className="report-batch-briefs" aria-label="通道报备简报">
<div className="report-batch-briefs__title">
<div>
<h3></h3>
<p className="muted"></p>
</div>
</div>
{detail.briefs?.length ? (
detail.briefs.map((brief) => (
<article className="report-batch-brief" key={brief.channelId}>
<header>
<div>
<strong>{brief.channelName}</strong>
<span>{brief.itemCount} </span>
</div>
<Button
icon={copiedChannelId === brief.channelId ? <Check size={15} /> : <Copy size={15} />}
onClick={() => void copyBrief(brief.channelId, brief.content)}
size="sm"
variant="ghost"
>
{copiedChannelId === brief.channelId ? '已复制' : '复制简报'}
</Button>
</header>
<pre>{brief.content}</pre>
</article>
))
) : (
<p className="muted"></p>
)}
</section>
<div className="report-batch-toolbar">
<strong>
{tasks.length} {selected.size}
+24 -11
View File
@@ -176,6 +176,7 @@ export function AdminReportTasksPage() {
.then((result) => {
setTasks(result.items);
setTotal(result.total);
setSelected(new Set());
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备明细加载失败'));
@@ -348,6 +349,7 @@ export function AdminReportTasksPage() {
),
},
];
const allCurrentPageSelected = tasks.length > 0 && tasks.every((task) => selected.has(task.id));
return (
<section className="page-stack admin-sms-task-page report-task-page">
@@ -357,18 +359,29 @@ export function AdminReportTasksPage() {
<h1></h1>
<p> × / × × </p>
</div>
<Button
disabled={!selected.size}
onClick={() => {
const first = tasks.find((task) => selected.has(task.id));
if (first) {
setStatusTask(first);
setNextStatus('reporting');
<div className="page-heading-actions">
<Button
disabled={!tasks.length}
onClick={() =>
setSelected(allCurrentPageSelected ? new Set() : new Set(tasks.map((task) => task.id)))
}
}}
>
{selected.size}
</Button>
variant="ghost"
>
{allCurrentPageSelected ? '取消全选' : '全选当前页'}
</Button>
<Button
disabled={!selected.size}
onClick={() => {
const first = tasks.find((task) => selected.has(task.id));
if (first) {
setStatusTask(first);
setNextStatus('reporting');
}
}}
>
{selected.size}
</Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
@@ -0,0 +1,119 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminReportBatchesPage } from './AdminReportBatchesPage';
import { AdminReportTasksPage } from './AdminReportTasksPage';
const { adminApi, clipboardWriteText } = vi.hoisted(() => ({
adminApi: {
changeReportTaskStatuses: vi.fn(),
exportSingleReportMaterial: vi.fn(),
getReportMaterialBatch: vi.fn(),
getSingleReportMaterialDetail: vi.fn(),
listReportDetailsPage: vi.fn(),
listReportMaterialBatches: vi.fn(),
listReportMaterialBatchTasks: vi.fn(),
},
clipboardWriteText: vi.fn(),
}));
vi.mock('@/api/adminApi', () => ({ adminApi, fileDownloadUrl: (id: string) => `/api/files/${id}` }));
const task = (id: string) => ({
id,
tenantId: 'tenant-1',
signatureId: `signature-${id}`,
channelId: 'channel-1',
carrier: 'mobile',
reportType: 'signature',
status: 'pending',
createdAt: '2026-09-02T01:00:00.000Z',
updatedAt: '2026-09-02T01:00:00.000Z',
signature: {
id: `signature-${id}`,
name: `签名${id}`,
tenant: { id: 'tenant-1', name: '测试企业' },
application: { id: 'app-1', name: '测试应用' },
},
channel: { id: 'channel-1', name: '测试通道' },
});
describe('report workbench pages', () => {
beforeEach(() => {
Object.values(adminApi).forEach((method) => method.mockReset());
clipboardWriteText.mockReset().mockResolvedValue(undefined);
});
it('selects and clears every detail on the current page', async () => {
adminApi.listReportDetailsPage.mockResolvedValue({ items: [task('1'), task('2')], total: 2, page: 1, pageSize: 10 });
render(
<MemoryRouter>
<AdminReportTasksPage />
</MemoryRouter>,
);
const user = userEvent.setup();
const selectPage = await screen.findByRole('button', { name: '全选当前页' });
await user.click(selectPage);
expect(screen.getByRole('button', { name: '取消全选' })).toBeVisible();
expect(screen.getByRole('button', { name: '批量修改状态(2' })).toBeEnabled();
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).toBeChecked());
await user.click(screen.getByRole('button', { name: '取消全选' }));
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
});
it('shows and copies the channel brief returned by the batch detail API', async () => {
const batch = {
id: 'batch-1',
batchNo: 'RB20260902090000TEST',
status: 'completed',
selectedCount: 1,
channelCount: 1,
fileCount: 1,
reportTotal: 1,
successCount: 0,
successRate: 0,
createdAt: '2026-09-02T01:00:00.000Z',
exportFiles: [],
};
const content =
'尊敬的供应商您好,今天是2026年9月2日,辛苦报备以下签名或引流信息:\n' +
'1.短信签名:【测试签名】,短信内容:【测试签名】您本次提交的验证码1234;\n' +
'报备材料在表格里,报备批次号RB20260902090000TEST。';
adminApi.listReportMaterialBatches.mockResolvedValue({ items: [batch], total: 1, page: 1, pageSize: 20 });
adminApi.getReportMaterialBatch.mockResolvedValue({
...batch,
briefs: [
{
channelId: 'channel-1',
channelName: '测试通道',
fileId: 'file-1',
fileName: '测试通道.xlsx',
itemCount: 1,
content,
},
],
});
adminApi.listReportMaterialBatchTasks.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 100 });
render(
<MemoryRouter>
<AdminReportBatchesPage />
</MemoryRouter>,
);
const user = userEvent.setup();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteText },
});
await user.click(await screen.findByRole('button', { name: '打开明细' }));
expect(await screen.findByText('测试通道')).toBeVisible();
expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
await user.click(screen.getByRole('button', { name: '复制简报' }));
await waitFor(() => expect(clipboardWriteText).toHaveBeenCalledWith(content));
expect(screen.getByRole('button', { name: '已复制' })).toBeVisible();
});
});
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { ClientSmsSignature } from '@/api/adminApi';
@@ -51,8 +50,6 @@ function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTa
setDeleteTarget: vi.fn(),
setExpandedSignatureId: vi.fn(),
setPage: vi.fn(),
setSignatureSort: vi.fn(),
signatureSort: 'asc',
...overrides,
};
return {
@@ -84,20 +81,14 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
screen.getAllByRole('button', { name: /报备状态|编辑|删除/ }).forEach((button) => {
expect(button).toHaveClass('ui-button--sm', 'enterprise-signature-action-button');
});
expect(screen.getByRole('button', { name: '4 条' })).toBeVisible();
expect(screen.queryByText('待生成明细')).not.toBeInTheDocument();
});
it('requests a real descending sort from the signature column control', async () => {
const setSignatureSort = vi.fn();
renderTable({ expandedSignatureId: '', setSignatureSort });
await userEvent.click(screen.getByRole('button', { name: '签名序' }));
expect(setSignatureSort).toHaveBeenCalledWith('desc');
expect(screen.getByRole('button', { name: '签名升序' }).querySelector('.lucide-triangle')).toBeInTheDocument();
expect(
screen
.getByRole('button', { name: '签名降序' })
.querySelector('.enterprise-signature-table__sort-triangle--down'),
).toBeInTheDocument();
expect(document.querySelector('.lucide-arrow-up, .lucide-arrow-down')).not.toBeInTheDocument();
it('keeps the signature header plain without sorting controls or per-row pending counts', () => {
renderTable({ expandedSignatureId: '' });
expect(screen.getByText('签名')).toBeVisible();
expect(screen.queryByRole('button', { name: '签名序' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '签名降序' })).not.toBeInTheDocument();
expect(screen.queryByText('待生成明细')).not.toBeInTheDocument();
});
});
@@ -1,7 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
import { ChevronDown, ChevronRight, Edit3, Plus, Triangle } from 'lucide-react';
import { ChevronDown, ChevronRight, Edit3, Plus } from 'lucide-react';
import type { ClientSmsSignature } from '@/api/adminApi';
import { useNavigate } from 'react-router-dom';
import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
import {
AuditStatusTag,
@@ -24,10 +23,8 @@ type EnterpriseSignaturesTableProps = {
onOpenDrainageReport: (signature: ClientSmsSignature, item: DrainageInfo) => void;
setExpandedSignatureId: Dispatch<SetStateAction<string>>;
setPage: Dispatch<SetStateAction<number>>;
setSignatureSort: (sort: 'asc' | 'desc') => void;
onEditSignature: (signature: ClientSmsSignature) => void;
onOpenSignatureReport: (signature: ClientSmsSignature) => void;
signatureSort: 'asc' | 'desc';
total: number;
totalPages: number;
visibleSignatures: ClientSmsSignature[];
@@ -45,51 +42,17 @@ export function EnterpriseSignaturesTable({
onOpenDrainageReport,
setExpandedSignatureId,
setPage,
setSignatureSort,
onEditSignature,
onOpenSignatureReport,
signatureSort,
total,
totalPages,
visibleSignatures,
}: EnterpriseSignaturesTableProps) {
const navigate = useNavigate();
return (
<div className="signature-list admin-enterprise-signature-list">
<div className="enterprise-signature-table__head" role="row">
<span aria-hidden="true" />
<span className="enterprise-signature-table__sortable">
<span className="enterprise-signature-table__sort-actions">
<Button
aria-pressed={signatureSort === 'asc'}
icon={<Triangle aria-hidden="true" fill="currentColor" size={11} />}
iconOnly
onClick={() => setSignatureSort('asc')}
size="sm"
variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}
>
</Button>
<Button
aria-pressed={signatureSort === 'desc'}
icon={
<Triangle
aria-hidden="true"
className="enterprise-signature-table__sort-triangle--down"
fill="currentColor"
size={11}
/>
}
iconOnly
onClick={() => setSignatureSort('desc')}
size="sm"
variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}
>
</Button>
</span>
</span>
<span></span>
<span></span>
<span></span>
<span></span>
@@ -97,7 +60,6 @@ export function EnterpriseSignaturesTable({
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleSignatures.map((signature) => {
@@ -148,18 +110,6 @@ export function EnterpriseSignaturesTable({
<div data-label="引流信息">
<strong>{payload.links.length} </strong>
</div>
<div data-label="待生成明细">
<Button
disabled={!signature.pendingReportDetailCount}
onClick={() =>
navigate(`/admin/report-tasks?signatureId=${encodeURIComponent(signature.id)}&scope=pending`)
}
size="sm"
variant="ghost"
>
{signature.pendingReportDetailCount ?? 0}
</Button>
</div>
<div className="signature-actions">
<Button
className="enterprise-signature-action-button"