feat: add channel report batch briefs
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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} 条
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user