feat: support WPS report material workbooks

This commit is contained in:
hectorzhao
2026-09-04 17:10:04 +08:00
parent 48d0363920
commit fb39c8b606
29 changed files with 2737 additions and 734 deletions
+73 -20
View File
@@ -3,9 +3,11 @@ import { ArrowLeft, Download, Eye, FileSliders, Search } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import {
adminApi,
fileDownloadUrl,
type AdminChannel,
type ChannelReportField,
type ClientSmsSignature,
type CommonReportField,
type DictionaryItem,
type ReportTask,
type SingleReportMaterialDetail,
@@ -14,6 +16,7 @@ import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag,
import { formatDateTime } from '@/utils/dateTime';
import { successRateClassName } from '@/utils/successRate';
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
import { ReportExportFormatModal, type ReportWorkbookFormat } from './ReportExportFormatModal';
type ReportType = 'signature' | 'drainage';
type DrainageItem = Record<string, unknown> & {
@@ -62,6 +65,24 @@ function ReportStatus({ value }: { value?: string }) {
return <Tag tone={meta.tone}>{meta.label}</Tag>;
}
function MaterialFieldValue({ value }: { value: unknown }) {
const file = asRecord(value);
const fileObjectId = String(file.fileObjectId ?? '');
const fileName = String(file.fileName ?? fileObjectId ?? '-');
const isImage =
String(file.contentType ?? '').startsWith('image/') || /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(fileName);
if (fileObjectId && isImage) {
return (
<div className="report-material-image-value">
<img alt={fileName} src={fileDownloadUrl(fileObjectId, 'inline')} />
<a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>
</div>
);
}
if (fileObjectId) return <a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>;
return <>{String(value ?? '-')}</>;
}
function DeliveryStats({ task }: { task: ReportTask }) {
const stats = task.deliveryStats ?? {
submitFailureCount: 0,
@@ -197,6 +218,7 @@ export function AdminChannelReportPage() {
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]);
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [commonFields, setCommonFields] = useState<CommonReportField[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [carrier, setCarrier] = useState('all');
@@ -204,7 +226,13 @@ export function AdminChannelReportPage() {
const [todaySendMax, setTodaySendMax] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'all', carrier: 'all', todaySendMin: '', todaySendMax: '' });
const [appliedFilters, setAppliedFilters] = useState({
keyword: '',
status: 'all',
carrier: 'all',
todaySendMin: '',
todaySendMax: '',
});
const pageSize = 10;
const [material, setMaterial] = useState<SingleReportMaterialDetail>();
const [detail, setDetail] = useState<{
@@ -218,9 +246,12 @@ export function AdminChannelReportPage() {
const [statusReason, setStatusReason] = useState('');
const [configType, setConfigType] = useState<ReportType>();
const [error, setError] = useState('');
const [exportTask, setExportTask] = useState<ReportTask>();
const [exportBusy, setExportBusy] = useState(false);
function loadData(targetPage = page, filters = appliedFilters) {
adminApi.listReportTasksPage({
adminApi
.listReportTasksPage({
channelId,
keyword: filters.keyword || undefined,
status: filters.status === 'all' ? undefined : filters.status,
@@ -240,16 +271,24 @@ export function AdminChannelReportPage() {
}
useEffect(() => {
void Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(channelId), adminApi.listDrainageFields()])
.then(([channelItems, fieldItems, libraryItems]) => {
void Promise.all([
adminApi.listChannels(),
adminApi.listChannelReportFields(channelId),
adminApi.listDrainageFields(),
adminApi.listCommonReportFields(),
])
.then(([channelItems, fieldItems, libraryItems, commonItems]) => {
setChannel(channelItems.find((item) => item.id === channelId));
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setCommonFields(commonItems);
})
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
}, [channelId]);
useEffect(() => { loadData(page); }, [channelId, page]);
useEffect(() => {
loadData(page);
}, [channelId, page]);
const visibleTasks = tasks;
async function openMaterial(task: ReportTask) {
@@ -269,8 +308,9 @@ export function AdminChannelReportPage() {
}
}
async function exportMaterial(task: ReportTask) {
async function exportMaterial(task: ReportTask, outputFormat: ReportWorkbookFormat) {
try {
setExportBusy(true);
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
@@ -278,6 +318,7 @@ export function AdminChannelReportPage() {
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
outputFormat,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
@@ -285,8 +326,11 @@ export function AdminChannelReportPage() {
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
setExportTask(undefined);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
} finally {
setExportBusy(false);
}
}
@@ -330,7 +374,7 @@ export function AdminChannelReportPage() {
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">
</Button>
<h1>{channel?.name ?? '通道报备详情'}</h1>
<h1></h1>
<div className="channel-report-config-actions">
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost">
@@ -341,7 +385,7 @@ export function AdminChannelReportPage() {
</div>
</div>
<div className="muted">
{channel?.code ?? channelId} · {fields.length}
{channel?.name ?? '-'} · {channel?.code ?? channelId} · {fields.length}
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -445,9 +489,7 @@ export function AdminChannelReportPage() {
visibleTasks.map((task) => {
const signature = task.signature as ClientSmsSignature | undefined;
const drainage =
task.reportType === 'drainage'
? task.drainageInfo as DrainageItem | undefined
: undefined;
task.reportType === 'drainage' ? (task.drainageInfo as DrainageItem | undefined) : undefined;
const reportedAt = task.approvedAt;
return (
<div
@@ -486,7 +528,7 @@ export function AdminChannelReportPage() {
</button>
{task.reportType !== 'drainage' ? (
<button onClick={() => void exportMaterial(task)} type="button">
<button onClick={() => setExportTask(task)} type="button">
<Download size={16} />
</button>
@@ -540,9 +582,9 @@ export function AdminChannelReportPage() {
</strong>
</div>
<div>
<span>/</span>
<span> / / </span>
<strong>
{material.channel.name} · V{material.materialVersion}
{material.channel.name} · {material.channel.code} · V{material.materialVersion}
</strong>
</div>
</div>
@@ -550,20 +592,23 @@ export function AdminChannelReportPage() {
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.exportName || field.name}
{field.name}{field.code}
{field.exportName && field.exportName !== field.name ? ` · 导出为“${field.exportName}` : ''}
{field.required ? ' *' : ''}
</span>
<strong>
{typeof field.value === 'object'
? String((field.value as Record<string, unknown>)?.fileName ?? '-')
: String(field.value ?? '-')}
<MaterialFieldValue value={field.value} />
</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>{field.name}</span>
<strong>{String(field.value ?? '-')}</strong>
<span>
{field.name}{field.code}
</span>
<strong>
<MaterialFieldValue value={field.value} />
</strong>
</div>
))}
</div>
@@ -608,12 +653,20 @@ export function AdminChannelReportPage() {
{configType ? (
<ReportFieldMappingModal
fields={fields}
commonFields={commonFields}
libraryFields={libraryFields}
onClose={() => setConfigType(undefined)}
onSave={saveFieldMapping}
reportType={configType}
/>
) : null}
{exportTask ? (
<ReportExportFormatModal
busy={exportBusy}
onClose={() => setExportTask(undefined)}
onConfirm={(format) => void exportMaterial(exportTask, format)}
/>
) : null}
</section>
);
}
+68 -12
View File
@@ -2,7 +2,17 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage';
const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), reorderCommonReportFields: vi.fn(), updateCommonReportField: vi.fn(), updateDrainageField: vi.fn(), createDrainageField: vi.fn(), deleteDrainageField: vi.fn() } }));
const { adminApi } = vi.hoisted(() => ({
adminApi: {
listDrainageFields: vi.fn(),
listCommonReportFields: vi.fn(),
reorderCommonReportFields: vi.fn(),
updateCommonReportField: vi.fn(),
updateDrainageField: vi.fn(),
createDrainageField: vi.fn(),
deleteDrainageField: vi.fn(),
},
}));
vi.mock('@/api/adminApi', () => ({ adminApi }));
describe('common reporting configuration', () => {
@@ -12,8 +22,22 @@ describe('common reporting configuration', () => {
const secondField = { id: 'field-2', code: 'smsContent', name: '短信内容', fieldType: 'string', status: 'active' };
adminApi.listDrainageFields.mockResolvedValue([field, secondField]);
adminApi.listCommonReportFields.mockResolvedValue([
{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, sortOrder: 10, drainageField: field },
{ id: 'common-2', drainageFieldId: 'field-2', reportType: 'signature', required: false, sortOrder: 20, drainageField: secondField },
{
id: 'common-1',
drainageFieldId: 'field-1',
reportType: 'signature',
required: false,
sortOrder: 10,
drainageField: field,
},
{
id: 'common-2',
drainageFieldId: 'field-2',
reportType: 'signature',
required: false,
sortOrder: 20,
drainageField: secondField,
},
]);
adminApi.reorderCommonReportFields.mockResolvedValue([]);
adminApi.updateCommonReportField.mockResolvedValue({});
@@ -26,7 +50,13 @@ describe('common reporting configuration', () => {
fireEvent.click(screen.getByRole('button', { name: '是否必填' }));
fireEvent.click(screen.getByRole('option', { name: '必填' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(adminApi.updateCommonReportField).toHaveBeenCalledWith('common-1', { drainageFieldId: 'field-1', reportType: 'signature', required: true }));
await waitFor(() =>
expect(adminApi.updateCommonReportField).toHaveBeenCalledWith('common-1', {
drainageFieldId: 'field-1',
reportType: 'signature',
required: true,
}),
);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2);
});
@@ -34,16 +64,26 @@ describe('common reporting configuration', () => {
it('moves a common field within its own material type through the reorder API', async () => {
render(<AdminDrainageFieldsPage />);
fireEvent.click(await screen.findByRole('button', { name: '下移通用字段主体证明' }));
await waitFor(() => expect(adminApi.reorderCommonReportFields).toHaveBeenCalledWith({
reportType: 'signature',
ids: ['common-2', 'common-1'],
}));
await waitFor(() =>
expect(adminApi.reorderCommonReportFields).toHaveBeenCalledWith({
reportType: 'signature',
ids: ['common-2', 'common-1'],
}),
);
await waitFor(() => expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2));
});
it('edits a field definition and locks mapping keys for a referenced field', async () => {
adminApi.listDrainageFields.mockResolvedValueOnce([
{ id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active', usageCount: 1, description: '旧说明' },
{
id: 'field-1',
code: 'license',
name: '主体证明',
fieldType: 'file',
status: 'active',
usageCount: 1,
description: '旧说明',
},
]);
render(<AdminDrainageFieldsPage />);
fireEvent.click(await screen.findByRole('button', { name: '编辑主体证明' }));
@@ -53,8 +93,24 @@ describe('common reporting configuration', () => {
fireEvent.change(screen.getByLabelText('字段名称'), { target: { value: '企业主体证明' } });
fireEvent.change(screen.getByLabelText('描述'), { target: { value: '新说明' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(adminApi.updateDrainageField).toHaveBeenCalledWith('field-1', {
code: 'license', name: '企业主体证明', fieldType: 'file', description: '新说明',
}));
await waitFor(() =>
expect(adminApi.updateDrainageField).toHaveBeenCalledWith('field-1', {
code: 'license',
name: '企业主体证明',
fieldType: 'file',
description: '新说明',
}),
);
});
it('places the add-field action inside the field definition section', async () => {
render(<AdminDrainageFieldsPage />);
const heading = await screen.findByRole('heading', { name: '字段定义' });
expect(heading.closest('.admin-drainage-section__heading')).toContainElement(
screen.getByRole('button', { name: '添加字段' }),
);
expect(document.querySelector('.page-heading')).not.toContainElement(
screen.getByRole('button', { name: '添加字段' }),
);
});
});
+328 -44
View File
@@ -65,11 +65,16 @@ export function AdminDrainageFieldsPage() {
}, []);
const filteredFields = useMemo(
() => fields.filter((field) => {
const matchesKeyword = !appliedKeyword || [field.code, field.name, field.fieldType, field.description].some((value) => String(value ?? '').includes(appliedKeyword));
const matchesType = appliedType === 'all' || field.fieldType === appliedType;
return matchesKeyword && matchesType;
}),
() =>
fields.filter((field) => {
const matchesKeyword =
!appliedKeyword ||
[field.code, field.name, field.fieldType, field.description].some((value) =>
String(value ?? '').includes(appliedKeyword),
);
const matchesType = appliedType === 'all' || field.fieldType === appliedType;
return matchesKeyword && matchesType;
}),
[appliedKeyword, appliedType, fields],
);
@@ -109,7 +114,8 @@ export function AdminDrainageFieldsPage() {
function deleteField() {
if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0 || (deleteTarget.commonUsageCount ?? 0) > 0) return;
adminApi.deleteDrainageField(deleteTarget.id)
adminApi
.deleteDrainageField(deleteTarget.id)
.then(() => {
setDeleteTarget(null);
loadData();
@@ -122,7 +128,9 @@ export function AdminDrainageFieldsPage() {
setCommonSaving(true);
setError('');
const body = { drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired };
const request = editingCommonId ? adminApi.updateCommonReportField(editingCommonId, body) : adminApi.createCommonReportField(body);
const request = editingCommonId
? adminApi.updateCommonReportField(editingCommonId, body)
: adminApi.createCommonReportField(body);
request
.then(() => {
setCommonFieldId('');
@@ -146,7 +154,8 @@ export function AdminDrainageFieldsPage() {
function deleteCommonField() {
if (!commonDeleteTarget) return;
adminApi.deleteCommonReportField(commonDeleteTarget.id)
adminApi
.deleteCommonReportField(commonDeleteTarget.id)
.then(() => {
setCommonDeleteTarget(null);
loadData();
@@ -176,7 +185,9 @@ export function AdminDrainageFieldsPage() {
const signatureCommon = commonFields.filter((field) => field.reportType === 'signature');
const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage');
const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length;
const referencedCount = fields.filter(
(field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0,
).length;
return (
<section className="page-stack admin-system-page admin-drainage-page">
@@ -186,22 +197,68 @@ export function AdminDrainageFieldsPage() {
<h1></h1>
<p></p>
</div>
<Button icon={<Plus size={16} />} onClick={() => openFieldModal()} size="sm"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="admin-drainage-summary">
<article><span><Database size={18} /></span><div><strong>{fields.length}</strong><p></p></div></article>
<article><span><FileCheck2 size={18} /></span><div><strong>{commonFields.length}</strong><p></p></div></article>
<article><span><Link2 size={18} /></span><div><strong>{referencedCount}</strong><p></p></div></article>
<article>
<span>
<Database size={18} />
</span>
<div>
<strong>{fields.length}</strong>
<p></p>
</div>
</article>
<article>
<span>
<FileCheck2 size={18} />
</span>
<div>
<strong>{commonFields.length}</strong>
<p></p>
</div>
</article>
<article>
<span>
<Link2 size={18} />
</span>
<div>
<strong>{referencedCount}</strong>
<p></p>
</div>
</article>
</div>
<div className="surface admin-drainage-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={<Search size={16} />} value={keyword} />
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索字段名称、代码或描述..."
prefix={<Search size={16} />}
value={keyword}
/>
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
<div className="admin-system-toolbar__actions">
<Button icon={<Search size={16} />} onClick={() => { setAppliedKeyword(keyword.trim()); setAppliedType(type); }}></Button>
<Button onClick={() => { setKeyword(''); setType('all'); setAppliedKeyword(''); setAppliedType('all'); }} variant="ghost"></Button>
<Button
icon={<Search size={16} />}
onClick={() => {
setAppliedKeyword(keyword.trim());
setAppliedType(type);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setType('all');
setAppliedKeyword('');
setAppliedType('all');
}}
variant="ghost"
>
</Button>
</div>
</div>
@@ -211,63 +268,185 @@ export function AdminDrainageFieldsPage() {
<h2></h2>
<p></p>
</div>
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary"></Button>
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary">
</Button>
</div>
<div className="admin-drainage-common-grid">
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} onMove={moveCommonField} orderingId={commonOrderingId} tone="info" />
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} onMove={moveCommonField} orderingId={commonOrderingId} tone="warning" />
<CommonFieldGroup
fields={signatureCommon}
label="签名报备资料"
onEdit={openCommonField}
onDelete={setCommonDeleteTarget}
onMove={moveCommonField}
orderingId={commonOrderingId}
tone="info"
/>
<CommonFieldGroup
fields={drainageCommon}
label="引流信息报备资料"
onEdit={openCommonField}
onDelete={setCommonDeleteTarget}
onMove={moveCommonField}
orderingId={commonOrderingId}
tone="warning"
/>
</div>
</div>
<div className="surface admin-drainage-section">
<div className="admin-drainage-section__heading"><div><h2></h2><p> {filteredFields.length} </p></div></div>
{filteredFields.length ? <div className="admin-drainage-field-grid">{filteredFields.map((field) => {
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
return <article className="admin-drainage-field-card" key={field.id}>
<div className="admin-drainage-field-card__top"><span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span><div className="admin-drainage-field-card__actions"><Button aria-label={`编辑${field.name}`} icon={<Edit3 size={14} />} iconOnly onClick={() => openFieldModal(field)} size="sm" variant="ghost"></Button><Button aria-label={`删除${field.name}`} disabled={locked} icon={<Trash2 size={14} />} iconOnly onClick={() => setDeleteTarget(field)} size="sm" variant="ghost"></Button></div></div>
<h3>{field.name ?? '-'}</h3><code>{field.code}</code><p>{field.description || '暂无字段说明'}</p>
<div className="admin-drainage-field-card__meta"><span> <strong>{field.usageCount ?? 0}</strong></span><span> <strong>{field.commonUsageCount ?? 0}</strong></span></div>
</article>;
})}</div> : <div className="admin-drainage-empty"></div>}
<div className="admin-drainage-section__heading">
<div>
<h2></h2>
<p> {filteredFields.length} </p>
</div>
<Button icon={<Plus size={16} />} onClick={() => openFieldModal()} size="sm">
</Button>
</div>
{filteredFields.length ? (
<div className="admin-drainage-field-grid">
{filteredFields.map((field) => {
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
return (
<article className="admin-drainage-field-card" key={field.id}>
<div className="admin-drainage-field-card__top">
<span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span>
<div className="admin-drainage-field-card__actions">
<Button
aria-label={`编辑${field.name}`}
icon={<Edit3 size={14} />}
iconOnly
onClick={() => openFieldModal(field)}
size="sm"
variant="ghost"
>
</Button>
<Button
aria-label={`删除${field.name}`}
disabled={locked}
icon={<Trash2 size={14} />}
iconOnly
onClick={() => setDeleteTarget(field)}
size="sm"
variant="ghost"
>
</Button>
</div>
</div>
<h3>{field.name ?? '-'}</h3>
<code>{field.code}</code>
<p>{field.description || '暂无字段说明'}</p>
<div className="admin-drainage-field-card__meta">
<span>
<strong>{field.usageCount ?? 0}</strong>
</span>
<span>
<strong>{field.commonUsageCount ?? 0}</strong>
</span>
</div>
</article>
);
})}
</div>
) : (
<div className="admin-drainage-empty"></div>
)}
</div>
<Modal
footer={<><Button disabled={commonSaving} onClick={() => setConfiguringCommon(false)} variant="ghost"></Button><Button disabled={!commonFieldId || commonSaving} onClick={createCommonField}>{commonSaving ? '保存中...' : '保存'}</Button></>}
footer={
<>
<Button disabled={commonSaving} onClick={() => setConfiguringCommon(false)} variant="ghost">
</Button>
<Button disabled={!commonFieldId || commonSaving} onClick={createCommonField}>
{commonSaving ? '保存中...' : '保存'}
</Button>
</>
}
onClose={() => setConfiguringCommon(false)}
open={configuringCommon}
title={editingCommonId ? '修改通用字段配置' : '配置通用字段'}
>
<div className="admin-system-modal-form">
<Select label="报备字段" onChange={(event) => setCommonFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...fields.filter((field) => field.status !== 'deleted').map((field) => ({ label: `${field.name}${field.code}`, value: field.id }))]} value={commonFieldId} />
<Select label="资料用途" onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')} options={[{ label: '签名报备资料', value: 'signature' }, { label: '引流信息报备资料', value: 'drainage' }]} value={commonReportType} />
<Select label="是否必填" onChange={(event) => setCommonRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(commonRequired)} />
<Select
label="报备字段"
onChange={(event) => setCommonFieldId(event.target.value)}
options={[
{ label: '请选择字段', value: '' },
...fields
.filter((field) => field.status !== 'deleted')
.map((field) => ({ label: `${field.name}${field.code}`, value: field.id })),
]}
value={commonFieldId}
/>
<Select
label="资料用途"
onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')}
options={[
{ label: '签名报备资料', value: 'signature' },
{ label: '引流信息报备资料', value: 'drainage' },
]}
value={commonReportType}
/>
<Select
label="是否必填"
onChange={(event) => setCommonRequired(event.target.value === 'true')}
options={[
{ label: '选填', value: 'false' },
{ label: '必填', value: 'true' },
]}
value={String(commonRequired)}
/>
<p></p>
{error ? <p className="form-error">{error}</p> : null}
</div>
</Modal>
<Modal
footer={(
footer={
<>
<Button disabled={fieldSaving} onClick={closeFieldModal} variant="ghost"></Button>
<Button disabled={!code || !name || Boolean(codeError) || fieldSaving} onClick={saveField}>{fieldSaving ? '保存中...' : '保存'}</Button>
<Button disabled={fieldSaving} onClick={closeFieldModal} variant="ghost">
</Button>
<Button disabled={!code || !name || Boolean(codeError) || fieldSaving} onClick={saveField}>
{fieldSaving ? '保存中...' : '保存'}
</Button>
</>
)}
}
onClose={closeFieldModal}
open={creating}
title={editingField ? '编辑报备字段' : '添加报备字段'}
>
<div className="admin-system-modal-form">
<Input disabled={Boolean(editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0))} error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} />
<Input
disabled={Boolean(
editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0),
)}
error={codeError}
label="字段代码"
onChange={(event) => setCode(event.target.value)}
placeholder="仅允许数字和英文字母"
value={code}
/>
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
<Select
disabled={Boolean(editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0))}
disabled={Boolean(
editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0),
)}
label="字段类型"
onChange={(event) => setFieldType(event.target.value as ReportFieldType)}
options={typeOptions.filter((option) => option.value !== 'all')}
value={fieldType}
/>
{editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0) ? <p className="admin-system-modal-form__wide"></p> : null}
{editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0) ? (
<p className="admin-system-modal-form__wide">
</p>
) : null}
<Textarea
className="admin-system-modal-form__wide"
label="描述"
@@ -279,7 +458,16 @@ export function AdminDrainageFieldsPage() {
</Modal>
{deleteTarget ? (
<Modal
footer={<><Button onClick={() => setDeleteTarget(null)} variant="ghost"></Button><Button onClick={deleteField} variant="danger"></Button></>}
footer={
<>
<Button onClick={() => setDeleteTarget(null)} variant="ghost">
</Button>
<Button onClick={deleteField} variant="danger">
</Button>
</>
}
onClose={() => setDeleteTarget(null)}
open
title="删除报备字段"
@@ -289,18 +477,114 @@ export function AdminDrainageFieldsPage() {
) : null}
{commonDeleteTarget ? (
<Modal
footer={<><Button onClick={() => setCommonDeleteTarget(null)} variant="ghost"></Button><Button onClick={deleteCommonField} variant="danger"></Button></>}
footer={
<>
<Button onClick={() => setCommonDeleteTarget(null)} variant="ghost">
</Button>
<Button onClick={deleteCommonField} variant="danger">
</Button>
</>
}
onClose={() => setCommonDeleteTarget(null)}
open
title="删除通用字段配置"
>
<p>{String(commonDeleteTarget.drainageField.name ?? commonDeleteTarget.drainageField.code)}{commonDeleteTarget.reportType === 'signature' ? '签名报备' : '引流信息报备'}</p>
<p>
{String(commonDeleteTarget.drainageField.name ?? commonDeleteTarget.drainageField.code)}
{commonDeleteTarget.reportType === 'signature' ? '签名报备' : '引流信息报备'}
</p>
</Modal>
) : null}
</section>
);
}
function CommonFieldGroup({ fields, label, onEdit, onDelete, onMove, orderingId, tone }: { fields: CommonReportField[]; label: string; onEdit: (field: CommonReportField) => void; onDelete: (field: CommonReportField) => void; onMove: (field: CommonReportField, direction: -1 | 1) => void; orderingId?: string; tone: 'info' | 'warning' }) {
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} </span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field, index) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><div className="admin-drainage-common-order"><Button aria-label={`上移通用字段${field.drainageField.name}`} disabled={index === 0 || Boolean(orderingId)} icon={<ArrowUp size={14} />} iconOnly onClick={() => onMove(field, -1)} size="sm" variant="ghost"></Button><Button aria-label={`下移通用字段${field.drainageField.name}`} disabled={index === fields.length - 1 || Boolean(orderingId)} icon={<ArrowDown size={14} />} iconOnly onClick={() => onMove(field, 1)} size="sm" variant="ghost"></Button></div><Button aria-label={`修改通用字段${field.drainageField.name}`} icon={<Edit3 size={14} />} onClick={() => onEdit(field)} size="sm" variant="ghost"></Button><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost"></Button></div>)}</div> : <p className="admin-drainage-common-empty"></p>}</section>;
function CommonFieldGroup({
fields,
label,
onEdit,
onDelete,
onMove,
orderingId,
tone,
}: {
fields: CommonReportField[];
label: string;
onEdit: (field: CommonReportField) => void;
onDelete: (field: CommonReportField) => void;
onMove: (field: CommonReportField, direction: -1 | 1) => void;
orderingId?: string;
tone: 'info' | 'warning';
}) {
return (
<section className="admin-drainage-common-group">
<div className="admin-drainage-common-group__title">
<Tag tone={tone}>{label}</Tag>
<span>{fields.length} </span>
</div>
{fields.length ? (
<div className="admin-drainage-common-list">
{fields.map((field, index) => (
<div key={field.id}>
<div>
<strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong>
<span>
{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}
</span>
</div>
<Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag>
<div className="admin-drainage-common-order">
<Button
aria-label={`上移通用字段${field.drainageField.name}`}
disabled={index === 0 || Boolean(orderingId)}
icon={<ArrowUp size={14} />}
iconOnly
onClick={() => onMove(field, -1)}
size="sm"
variant="ghost"
>
</Button>
<Button
aria-label={`下移通用字段${field.drainageField.name}`}
disabled={index === fields.length - 1 || Boolean(orderingId)}
icon={<ArrowDown size={14} />}
iconOnly
onClick={() => onMove(field, 1)}
size="sm"
variant="ghost"
>
</Button>
</div>
<Button
aria-label={`修改通用字段${field.drainageField.name}`}
icon={<Edit3 size={14} />}
onClick={() => onEdit(field)}
size="sm"
variant="ghost"
>
</Button>
<Button
aria-label="删除通用字段"
icon={<Trash2 size={14} />}
iconOnly
onClick={() => onDelete(field)}
size="sm"
variant="ghost"
>
</Button>
</div>
))}
</div>
) : (
<p className="admin-drainage-common-empty"></p>
)}
</section>
);
}
+132 -111
View File
@@ -1,20 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import {
BarChart3,
DollarSign,
FileCheck2,
ShieldCheck,
} from 'lucide-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 { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
@@ -70,7 +57,11 @@ export function AdminHome() {
enterprise: account.tenantName,
todaySpend,
availableBalance,
balanceStatus: (availableBalance <= 0 ? '欠费' : availableBalance < 100 ? '紧张' : '充足') as EnterpriseSpendRank['balanceStatus'],
balanceStatus: (availableBalance <= 0
? '欠费'
: availableBalance < 100
? '紧张'
: '充足') as EnterpriseSpendRank['balanceStatus'],
};
});
}, [dashboard]);
@@ -83,33 +74,43 @@ export function AdminHome() {
const todayProfit = moneyUnitsToYuan(dashboard?.today.profitCents);
const activeSignatureCount = new Set(quality?.signatures.map((item) => item.signatureId) ?? []).size;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
const pendingAudits = dashboard?.pendingAudits ?? {
enterpriseCertifications: 0,
smsAudits: 0,
templates: 0,
signatures: 0,
drainageInfos: 0,
total: 0,
};
const sendTrendOption = useMemo(
() => createLineOption({
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
series: [
{ name: '提交总条数', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
{ name: '成功条数', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
],
}),
() =>
createLineOption({
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
series: [
{ name: '提交总条数', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
{ name: '成功条数', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
],
}),
[dashboard],
);
const auditSpeedOption = useMemo(
() => createDualAxisBarLineOption({
labels: dashboard?.auditProcessingSpeed.map((item) => item.label) ?? [],
bar: {
name: '审核数量',
data: dashboard?.auditProcessingSpeed.map((item) => item.count) ?? [],
},
line: {
name: '平均处理时长(分钟)',
data: dashboard?.auditProcessingSpeed.map((item) => (
item.averageProcessingMs == null ? null : Number((item.averageProcessingMs / 60_000).toFixed(1))
)) ?? [],
},
}),
() =>
createDualAxisBarLineOption({
labels: dashboard?.auditProcessingSpeed.map((item) => item.label) ?? [],
bar: {
name: '审核数量',
data: dashboard?.auditProcessingSpeed.map((item) => item.count) ?? [],
},
line: {
name: '平均处理时长(分钟)',
data:
dashboard?.auditProcessingSpeed.map((item) =>
item.averageProcessingMs == null ? null : Number((item.averageProcessingMs / 60_000).toFixed(1)),
) ?? [],
},
}),
[dashboard],
);
@@ -125,9 +126,23 @@ export function AdminHome() {
</div>
),
},
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText> },
{ key: 'availableBalance', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance) },
{ key: 'balanceStatus', title: '余额状态', render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag> },
{
key: 'todaySpend',
title: '今日消费(元)',
align: 'right',
render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText>,
},
{
key: 'availableBalance',
title: '可用余额',
align: 'right',
render: (record) => formatCount(record.availableBalance),
},
{
key: 'balanceStatus',
title: '余额状态',
render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag>,
},
{
key: 'actions',
title: '操作',
@@ -151,9 +166,7 @@ export function AdminHome() {
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
</Button>
<Button onClick={() => navigate('/admin/monitor')}>
</Button>
<Button onClick={() => navigate('/admin/monitor')}></Button>
</div>
</div>
@@ -163,15 +176,20 @@ export function AdminHome() {
<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>¥{formatCurrency(todaySpend)}</strong>
<small></small>
<span></span>
<strong>{(dashboard?.today.arrivalRate ?? 0).toFixed(1)}%</strong>
<small> / </small>
</div>
<div className="surface metric-card">
<span></span>
@@ -179,14 +197,9 @@ export function AdminHome() {
<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>{(dashboard?.today.arrivalRate ?? 0).toFixed(1)}%</strong>
<small> / </small>
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
@@ -200,12 +213,16 @@ export function AdminHome() {
</div>
<div className="surface metric-card">
<span></span>
<strong className={todayProfit < 0 ? 'metric-card__value--danger' : undefined}>¥{formatCurrency(todayProfit)}</strong>
<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>
<strong className={(dashboard?.today.profitRate ?? 0) < 0 ? 'metric-card__value--danger' : undefined}>
{(dashboard?.today.profitRate ?? 0).toFixed(1)}%
</strong>
<small> / </small>
</div>
</div>
@@ -214,12 +231,12 @@ export function AdminHome() {
<div className="chart-grid">
<div className="surface chart-card">
<h2></h2>
<p className="muted"></p>
<p className="muted"></p>
<Chart height={300} option={sendTrendOption} />
</div>
<div className="surface chart-card">
<h2></h2>
<p className="muted"></p>
<p className="muted"></p>
<Chart height={300} option={auditSpeedOption} />
</div>
</div>
@@ -238,73 +255,75 @@ export function AdminHome() {
</div>
<div className="surface section-stack">
<div className="section-heading">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<BarChart3 size={20} className="status-info" />
</div>
<div className="overview-grid overview-grid--three">
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.enterpriseCertifications} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.smsAudits} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.templates} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.signatures} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/drainage-audits')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.drainageInfos} </strong>
</Button>
<div className="mini-status-card">
<ShieldCheck size={22} />
<div>
<h2></h2>
<p className="muted"></p>
</div>
<BarChart3 size={20} className="status-info" />
</div>
<div className="overview-grid overview-grid--three">
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.enterpriseCertifications} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.smsAudits} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.templates} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.signatures} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/drainage-audits')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.drainageInfos} </strong>
</Button>
<div className="mini-status-card">
<ShieldCheck size={22} />
<div>
<span></span>
<strong>{dashboard?.taskCount ?? 0} </strong>
<small></small>
</div>
</div>
<div className="mini-status-card">
<ShieldCheck size={22} />
<div>
<span></span>
<strong>{downstreamAlertCount} </strong>
<small></small>
</div>
<span></span>
<strong>{dashboard?.taskCount ?? 0} </strong>
<small></small>
</div>
</div>
<div className="mini-status-card">
<ShieldCheck size={22} />
<div>
<span></span>
<strong>{downstreamAlertCount} </strong>
<small></small>
</div>
</div>
</div>
</div>
<Modal
footer={(
footer={
<>
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost"></Button>
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost">
</Button>
<Button onClick={() => navigate('/admin/recharge-records')}></Button>
</>
)}
}
onClose={() => setSelectedEnterprise(null)}
open={Boolean(selectedEnterprise)}
title={(
title={
<div className="ui-detail-title">
<h2></h2>
<p>{selectedEnterprise?.id}</p>
</div>
)}
}
>
{selectedEnterprise ? (
<div className="ui-detail-info-grid">
@@ -324,7 +343,9 @@ export function AdminHome() {
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong><MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText></strong>
<strong>
<MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText>
</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
+14 -3
View File
@@ -48,6 +48,7 @@ export function AdminReportBatchesPage() {
const [error, setError] = useState('');
const [copiedChannelId, setCopiedChannelId] = useState('');
const [downloadBusy, setDownloadBusy] = useState('');
const [outputFormat, setOutputFormat] = useState<'excel_drawing' | 'wps_cell_image'>('excel_drawing');
const [statusBusy, setStatusBusy] = useState(false);
const pageSize = 20;
@@ -89,6 +90,7 @@ export function AdminReportBatchesPage() {
async function openExports(batch: ReportMaterialBatch) {
try {
setExportDetail(await adminApi.getReportMaterialBatch(batch.id));
setOutputFormat('excel_drawing');
setCopiedChannelId('');
setError('');
} catch (failure) {
@@ -166,7 +168,7 @@ export function AdminReportBatchesPage() {
async function downloadChannelFile(batch: ReportMaterialBatch, fileId: string, channelName: string) {
try {
setDownloadBusy(fileId);
const blob = await adminApi.downloadReportMaterialBatchFile(batch.id, fileId);
const blob = await adminApi.downloadReportMaterialBatchFile(batch.id, fileId, outputFormat);
downloadBlob(
blob,
`${batchDate(batch)}_${safeDownloadName(channelName)}_${safeDownloadName(batch.batchNo)}.xlsx`,
@@ -180,7 +182,7 @@ export function AdminReportBatchesPage() {
async function downloadAll(batch: ReportMaterialBatch) {
try {
setDownloadBusy('all');
const blob = await adminApi.downloadReportMaterialBatch(batch.id);
const blob = await adminApi.downloadReportMaterialBatch(batch.id, outputFormat);
downloadBlob(blob, `${batchDate(batch)}_${safeDownloadName(batch.batchNo)}_报备文件.zip`);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次报备文件下载失败');
@@ -480,7 +482,16 @@ export function AdminReportBatchesPage() {
title={`报备文件导出 · ${exportDetail.batchNo}`}
>
<section className="report-batch-briefs" aria-label="通道报备简报与文件">
<p className="muted">ZIP压缩包XLSX和TXT</p>
<Select
label="导出格式"
onChange={(event) => setOutputFormat(event.target.value as 'excel_drawing' | 'wps_cell_image')}
options={[
{ label: '系统 Excel 文件(标准 Drawing 图片)', value: 'excel_drawing' },
{ label: 'WPS 单元格图片文件(DISPIMG', value: 'wps_cell_image' },
]}
value={outputFormat}
/>
<p className="muted">ZIP压缩包XLSX和TXT</p>
{exportDetail.briefs?.length ? (
exportDetail.briefs.map((brief) => {
const fileAvailable = exportDetail.exportFiles.some(
+127 -23
View File
@@ -2,8 +2,41 @@ import { useEffect, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import {
Breadcrumb,
Button,
CarrierTag,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { ReportExportFormatModal, type ReportWorkbookFormat } from './ReportExportFormatModal';
function materialValue(value: unknown) {
const file = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
const fileObjectId = String(file.fileObjectId ?? '');
const fileName = String(file.fileName ?? fileObjectId ?? '-');
if (
fileObjectId &&
(String(file.contentType ?? '').startsWith('image/') || /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(fileName))
)
return (
<div className="report-material-image-value">
<img alt={fileName} src={fileDownloadUrl(fileObjectId, 'inline')} />
<a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>
</div>
);
if (fileObjectId) return <a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>;
return String(value ?? '-');
}
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
pending: { label: '未报备', tone: 'neutral' },
@@ -119,7 +152,8 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
<div>
<span></span>
<strong>
{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {statusMeta[record.statusAfter]?.label ?? record.statusAfter}
{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {' '}
{statusMeta[record.statusAfter]?.label ?? record.statusAfter}
</strong>
</div>
<div>
@@ -155,9 +189,17 @@ export function AdminReportTasksPage() {
const [statusReason, setStatusReason] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [exportTask, setExportTask] = useState<ReportTask | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', status: initialStatus, carrier: 'all' });
const [appliedFilters, setAppliedFilters] = useState({
keyword: '',
dateRange: {} as DateRangeValue,
reportType: 'all',
status: initialStatus,
carrier: 'all',
});
const pageSize = 10;
function loadData(targetPage = page, filters = appliedFilters) {
@@ -231,8 +273,9 @@ export function AdminReportTasksPage() {
}
}
async function exportMaterial(task: ReportTask) {
async function exportMaterial(task: ReportTask, outputFormat: ReportWorkbookFormat) {
try {
setExportBusy(true);
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
@@ -240,6 +283,7 @@ export function AdminReportTasksPage() {
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
outputFormat,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
@@ -247,8 +291,11 @@ export function AdminReportTasksPage() {
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
setExportTask(null);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
} finally {
setExportBusy(false);
}
}
@@ -280,7 +327,8 @@ export function AdminReportTasksPage() {
<div>
<strong>{taskTargetLabel(record)}</strong>
<div className="muted">
{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}
{record.reportType === 'drainage' ? '引流信息' : '签名'} ·{' '}
{record.signature?.tenant?.name ?? record.tenantId}
</div>
</div>
),
@@ -292,7 +340,11 @@ export function AdminReportTasksPage() {
render: (record) => (
<div>
<strong>{record.channel?.name ?? record.channelId}</strong>
{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}
{record.reportType !== 'drainage' ? (
<div className="muted">
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
</div>
) : null}
</div>
),
},
@@ -316,7 +368,11 @@ export function AdminReportTasksPage() {
{
key: 'status',
title: '状态',
render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag>,
render: (record) => (
<Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>
{(statusMeta[record.status] ?? { label: record.status }).label}
</Tag>
),
},
{ key: 'time', title: '更新时间', render: (record) => formatDateTime(record.updatedAt ?? record.createdAt) },
{
@@ -329,7 +385,7 @@ export function AdminReportTasksPage() {
</Button>
{record.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportMaterial(record)} size="sm" variant="ghost">
<Button icon={<Download size={14} />} onClick={() => setExportTask(record)} size="sm" variant="ghost">
</Button>
) : null}
@@ -362,9 +418,7 @@ export function AdminReportTasksPage() {
<div className="page-heading__actions">
<Button
disabled={!tasks.length}
onClick={() =>
setSelected(allCurrentPageSelected ? new Set() : new Set(tasks.map((task) => task.id)))
}
onClick={() => setSelected(allCurrentPageSelected ? new Set() : new Set(tasks.map((task) => task.id)))}
variant="ghost"
>
{allCurrentPageSelected ? '取消全选' : '全选当页'}
@@ -385,7 +439,12 @@ export function AdminReportTasksPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
<Input
label="企业/应用/通道/报备对象"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索报备明细"
value={keyword}
/>
<Select
label="报备类型"
onChange={(event) => setReportType(event.target.value)}
@@ -407,7 +466,15 @@ export function AdminReportTasksPage() {
]}
value={carrier}
/>
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} />
<Select
label="报备状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value })),
]}
value={status}
/>
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button
@@ -428,7 +495,13 @@ export function AdminReportTasksPage() {
setReportType('all');
setCarrier('all');
setStatus('all');
const filters = { keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', status: 'all', carrier: 'all' };
const filters = {
keyword: '',
dateRange: {} as DateRangeValue,
reportType: 'all',
status: 'all',
carrier: 'all',
};
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData(1, filters);
@@ -442,10 +515,25 @@ export function AdminReportTasksPage() {
<div className="surface report-task-table-card">
<Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" />
</div>
<Pagination nextDisabled={page * pageSize >= total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} />
<Pagination
nextDisabled={page * pageSize >= total}
onNext={() => setPage((current) => current + 1)}
onPageChange={setPage}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={page}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
/>
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
{material ? (
<Modal footer={<Button onClick={() => setMaterial(null)}></Button>} onClose={() => setMaterial(null)} open size="xl" title="查看报备资料">
<Modal
footer={<Button onClick={() => setMaterial(null)}></Button>}
onClose={() => setMaterial(null)}
open
size="xl"
title="查看报备资料"
>
<div className="page-stack">
<div className="detail-grid">
<div>
@@ -459,9 +547,9 @@ export function AdminReportTasksPage() {
</strong>
</div>
<div>
<span>/</span>
<span> / / </span>
<strong>
{material.channel.name} · V{material.materialVersion}
{material.channel.name} · {material.channel.code} · V{material.materialVersion}
</strong>
</div>
</div>
@@ -469,16 +557,19 @@ export function AdminReportTasksPage() {
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.exportName || field.name}
{field.name}{field.code}
{field.exportName && field.exportName !== field.name ? ` · 导出为“${field.exportName}` : ''}
{field.required ? ' *' : ''}
</span>
<strong>{typeof field.value === 'object' ? String((field.value as Record<string, unknown>)?.fileName ?? '-') : String(field.value ?? '-')}</strong>
<strong>{materialValue(field.value)}</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>{field.name}</span>
<strong>{String(field.value ?? '-')}</strong>
<span>
{field.name}{field.code}
</span>
<strong>{materialValue(field.value)}</strong>
</div>
))}
</div>
@@ -529,10 +620,23 @@ export function AdminReportTasksPage() {
]}
value={nextStatus}
/>
<Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
<Textarea
label="修改原因(选填)"
onChange={(event) => setStatusReason(event.target.value)}
placeholder="可填写供应商反馈或人工处理说明"
rows={3}
value={statusReason}
/>
</div>
) : null}
</Modal>
{exportTask ? (
<ReportExportFormatModal
busy={exportBusy}
onClose={() => setExportTask(null)}
onConfirm={(format) => void exportMaterial(exportTask, format)}
/>
) : null}
</section>
);
}
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { Button, Modal } from '@/components/ui';
export type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image';
export function ReportExportFormatModal({
busy = false,
onClose,
onConfirm,
title = '选择报备文件格式',
}: {
busy?: boolean;
onClose: () => void;
onConfirm: (format: ReportWorkbookFormat) => void;
title?: string;
}) {
const [format, setFormat] = useState<ReportWorkbookFormat>('excel_drawing');
return (
<Modal
footer={
<>
<Button disabled={busy} onClick={onClose} variant="ghost">
</Button>
<Button disabled={busy} onClick={() => onConfirm(format)}>
{busy ? '生成中…' : '确认导出'}
</Button>
</>
}
onClose={onClose}
open
title={title}
>
<div className="report-export-format-options" role="radiogroup" aria-label="报备文件格式">
<button
aria-checked={format === 'excel_drawing'}
onClick={() => setFormat('excel_drawing')}
role="radio"
type="button"
>
<strong> Excel </strong>
<span> Drawing Microsoft Excel </span>
</button>
<button
aria-checked={format === 'wps_cell_image'}
onClick={() => setFormat('wps_cell_image')}
role="radio"
type="button"
>
<strong>WPS </strong>
<span>使 DISPIMG cellimages.xml WPS </span>
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
describe('ReportFieldMappingModal', () => {
it('includes common report fields by default and uses a quiet normal-width remove action', () => {
const library = { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'image', status: 'active' };
render(
<ReportFieldMappingModal
fields={[]}
commonFields={[
{
id: 'common-1',
drainageFieldId: 'field-1',
reportType: 'signature',
required: true,
sortOrder: 10,
drainageField: library,
},
]}
libraryFields={[library]}
reportType="signature"
onClose={vi.fn()}
onSave={vi.fn()}
/>,
);
expect(screen.getByText('营业执照')).toBeVisible();
expect(screen.getByText('1 列')).toBeVisible();
const remove = screen.getByRole('button', { name: '移除字段' });
expect(remove).toHaveClass('channel-remove-field-button', 'ui-button--ghost');
});
});
+251 -72
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronUp, Plus, Search, Trash2 } from 'lucide-react';
import { type ChannelReportField, type DictionaryItem } from '@/api/adminApi';
import { type ChannelReportField, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
type ReportType = 'signature' | 'drainage';
@@ -30,8 +30,12 @@ const transformOptions = [
{ label: '转小写', value: 'lowercase' },
];
function initialDraft(fields: ChannelReportField[], reportType: ReportType): DraftField[] {
return fields
function initialDraft(
fields: ChannelReportField[],
commonFields: CommonReportField[],
reportType: ReportType,
): DraftField[] {
const configured = fields
.filter((field) => field.reportType === reportType || field.reportType === 'both')
.sort((left, right) => (left.sortOrder ?? 100) - (right.sortOrder ?? 100))
.map((field, index) => ({
@@ -50,43 +54,90 @@ function initialDraft(fields: ChannelReportField[], reportType: ReportType): Dra
transform: String(field.transform ?? ''),
status: 'active',
}));
}
export function ReportFieldMappingModal({ fields, libraryFields, reportType, onClose, onSave }: {
fields: ChannelReportField[];
libraryFields: DictionaryItem[];
reportType: ReportType;
onClose: () => void;
onSave: (fields: DraftField[]) => Promise<void>;
}) {
const [draft, setDraft] = useState(() => initialDraft(fields, reportType));
const [search, setSearch] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const selectedIds = useMemo(() => new Set(draft.map((field) => field.drainageFieldId)), [draft]);
const available = useMemo(() => libraryFields.filter((field) => !selectedIds.has(String(field.id)) && [field.name, field.code].some((value) => String(value ?? '').toLowerCase().includes(search.trim().toLowerCase()))), [libraryFields, search, selectedIds]);
function addField(field: DictionaryItem) {
setDraft((current) => [...current, {
drainageFieldId: String(field.id),
code: String(field.code ?? field.id),
name: String(field.name ?? field.code ?? '未命名字段'),
fieldType: String(field.fieldType ?? 'string'),
exportName: String(field.name ?? field.code ?? ''),
required: Boolean(field.required),
description: String(field.description ?? ''),
sortOrder: (current.length + 1) * 10,
const configuredIds = new Set(configured.map((field) => field.drainageFieldId));
const defaults = commonFields
.filter(
(field) =>
field.reportType === reportType && field.status !== 'deleted' && !configuredIds.has(field.drainageFieldId),
)
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((field, index) => ({
drainageFieldId: field.drainageFieldId,
code: String(field.drainageField.code ?? field.drainageFieldId),
name: String(field.drainageField.name ?? field.drainageField.code ?? '未命名字段'),
fieldType: String(field.drainageField.fieldType ?? 'string'),
exportName: String(field.drainageField.name ?? field.drainageField.code ?? ''),
required: field.required,
description: String(field.drainageField.description ?? ''),
sortOrder: (configured.length + index + 1) * 10,
columnWidth: 18,
imageWidth: 120,
imageHeight: 80,
defaultValue: '',
transform: '',
status: 'active',
}]);
}));
return [...configured, ...defaults].map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 }));
}
export function ReportFieldMappingModal({
fields,
commonFields,
libraryFields,
reportType,
onClose,
onSave,
}: {
fields: ChannelReportField[];
commonFields: CommonReportField[];
libraryFields: DictionaryItem[];
reportType: ReportType;
onClose: () => void;
onSave: (fields: DraftField[]) => Promise<void>;
}) {
const [draft, setDraft] = useState(() => initialDraft(fields, commonFields, reportType));
const [search, setSearch] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const selectedIds = useMemo(() => new Set(draft.map((field) => field.drainageFieldId)), [draft]);
const available = useMemo(
() =>
libraryFields.filter(
(field) =>
!selectedIds.has(String(field.id)) &&
[field.name, field.code].some((value) =>
String(value ?? '')
.toLowerCase()
.includes(search.trim().toLowerCase()),
),
),
[libraryFields, search, selectedIds],
);
function addField(field: DictionaryItem) {
setDraft((current) => [
...current,
{
drainageFieldId: String(field.id),
code: String(field.code ?? field.id),
name: String(field.name ?? field.code ?? '未命名字段'),
fieldType: String(field.fieldType ?? 'string'),
exportName: String(field.name ?? field.code ?? ''),
required: Boolean(field.required),
description: String(field.description ?? ''),
sortOrder: (current.length + 1) * 10,
columnWidth: 18,
imageWidth: 120,
imageHeight: 80,
defaultValue: '',
transform: '',
status: 'active',
},
]);
}
function patchField(index: number, patch: Partial<DraftField>) {
setDraft((current) => current.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field));
setDraft((current) => current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)));
}
function move(index: number, offset: number) {
@@ -100,49 +151,177 @@ export function ReportFieldMappingModal({ fields, libraryFields, reportType, onC
}
async function save() {
if (draft.some((field) => !field.exportName.trim())) { setError('导出表头名称不能为空'); return; }
setSaving(true); setError('');
try { await onSave(draft.map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 }))); onClose(); }
catch (failure) { setError(failure instanceof Error ? failure.message : '字段配置保存失败'); }
finally { setSaving(false); }
if (draft.some((field) => !field.exportName.trim())) {
setError('导出表头名称不能为空');
return;
}
setSaving(true);
setError('');
try {
await onSave(draft.map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 })));
onClose();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '字段配置保存失败');
} finally {
setSaving(false);
}
}
return <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中...' : '保存配置'}</Button></>}
onClose={onClose}
open
size="xl"
title={<div className="channel-field-config-title"><h2>{reportType === 'signature' ? '配置签名报备字段' : '配置引流信息字段'}</h2><p>Excel表头的映射</p></div>}
>
<div className="channel-field-config">
<section className="channel-field-pool">
<div className="channel-field-section-head"><h3></h3><Tag tone="neutral">{available.length} </Tag></div>
<Input onChange={(event) => setSearch(event.target.value)} placeholder="搜索标准字段" prefix={<Search size={16} />} value={search} />
<div className="channel-field-pool-list">
{available.map((field) => <button key={String(field.id)} onClick={() => addField(field)} type="button"><span><strong>{String(field.name ?? field.code)}</strong><Tag tone="neutral">{fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}</Tag></span><span> <Plus size={15} /></span></button>)}
{available.length === 0 ? <p></p> : null}
return (
<Modal
footer={
<>
<Button onClick={onClose} variant="ghost">
</Button>
<Button disabled={saving} onClick={() => void save()}>
{saving ? '保存中...' : '保存配置'}
</Button>
</>
}
onClose={onClose}
open
size="xl"
title={
<div className="channel-field-config-title">
<h2>{reportType === 'signature' ? '配置签名报备字段' : '配置引流信息字段'}</h2>
<p>Excel表头的映射</p>
</div>
</section>
<section className="channel-selected-fields">
<div className="channel-field-section-head"><div><h3></h3><p>Excel从左到右的列顺序</p></div><Tag tone="info">{draft.length} </Tag></div>
<div className="channel-export-preview">{draft.map((field, index) => <span key={field.drainageFieldId}>{String.fromCharCode(65 + index)} · {field.exportName || field.name}</span>)}</div>
<div className="channel-selected-field-list">
{draft.map((field, index) => <article key={field.drainageFieldId}>
<div className="channel-selected-field-head"><span className="channel-selected-field-index">{index + 1}</span><strong>{field.name}</strong><Tag tone="neutral">{fieldTypeLabel[field.fieldType] ?? field.fieldType}</Tag><div className="channel-selected-field-order"><button disabled={index === 0} onClick={() => move(index, -1)} type="button"><ChevronUp size={16} /></button><button disabled={index === draft.length - 1} onClick={() => move(index, 1)} type="button"><ChevronDown size={16} /></button></div></div>
<div className="channel-field-mapping-grid">
<Input label="通道导出表头" onChange={(event) => patchField(index, { exportName: event.target.value })} value={field.exportName} />
<Select label="是否必填" onChange={(event) => patchField(index, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(field.required)} />
<Input label="列宽" min="6" onChange={(event) => patchField(index, { columnWidth: Number(event.target.value) })} type="number" value={String(field.columnWidth)} />
<Select label="文本转换" onChange={(event) => patchField(index, { transform: event.target.value })} options={transformOptions} value={field.transform} />
{field.fieldType !== 'string' ? <><Input label="图片宽度(px)" min="24" onChange={(event) => patchField(index, { imageWidth: Number(event.target.value) })} type="number" value={String(field.imageWidth)} /><Input label="图片高度(px)" min="24" onChange={(event) => patchField(index, { imageHeight: Number(event.target.value) })} type="number" value={String(field.imageHeight)} /></> : <Input label="缺省值" onChange={(event) => patchField(index, { defaultValue: event.target.value })} value={field.defaultValue} />}
}
>
<div className="channel-field-config">
<section className="channel-field-pool">
<div className="channel-field-section-head">
<h3></h3>
<Tag tone="neutral">{available.length} </Tag>
</div>
<Input
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索标准字段"
prefix={<Search size={16} />}
value={search}
/>
<div className="channel-field-pool-list">
{available.map((field) => (
<button key={String(field.id)} onClick={() => addField(field)} type="button">
<span>
<strong>{String(field.name ?? field.code)}</strong>
<Tag tone="neutral">{fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}</Tag>
</span>
<span>
<Plus size={15} />
</span>
</button>
))}
{available.length === 0 ? <p></p> : null}
</div>
</section>
<section className="channel-selected-fields">
<div className="channel-field-section-head">
<div>
<h3></h3>
<p>Excel从左到右的列顺序</p>
</div>
<Textarea label="通道说明" onChange={(event) => patchField(index, { description: event.target.value })} rows={2} value={field.description} />
<Button icon={<Trash2 size={15} />} onClick={() => setDraft((current) => current.filter((_, fieldIndex) => fieldIndex !== index))} size="sm" variant="danger"></Button>
</article>)}
{draft.length === 0 ? <div className="channel-report-empty"></div> : null}
</div>
</section>
</div>
{error ? <p className="form-error">{error}</p> : null}
</Modal>;
<Tag tone="info">{draft.length} </Tag>
</div>
<div className="channel-export-preview">
{draft.map((field, index) => (
<span key={field.drainageFieldId}>
{String.fromCharCode(65 + index)} · {field.exportName || field.name}
</span>
))}
</div>
<div className="channel-selected-field-list">
{draft.map((field, index) => (
<article key={field.drainageFieldId}>
<div className="channel-selected-field-head">
<span className="channel-selected-field-index">{index + 1}</span>
<strong>{field.name}</strong>
<Tag tone="neutral">{fieldTypeLabel[field.fieldType] ?? field.fieldType}</Tag>
<div className="channel-selected-field-order">
<button disabled={index === 0} onClick={() => move(index, -1)} type="button">
<ChevronUp size={16} />
</button>
<button disabled={index === draft.length - 1} onClick={() => move(index, 1)} type="button">
<ChevronDown size={16} />
</button>
</div>
</div>
<div className="channel-field-mapping-grid">
<Input
label="通道导出表头"
onChange={(event) => patchField(index, { exportName: event.target.value })}
value={field.exportName}
/>
<Select
label="是否必填"
onChange={(event) => patchField(index, { required: event.target.value === 'true' })}
options={[
{ label: '选填', value: 'false' },
{ label: '必填', value: 'true' },
]}
value={String(field.required)}
/>
<Input
label="列宽"
min="6"
onChange={(event) => patchField(index, { columnWidth: Number(event.target.value) })}
type="number"
value={String(field.columnWidth)}
/>
<Select
label="文本转换"
onChange={(event) => patchField(index, { transform: event.target.value })}
options={transformOptions}
value={field.transform}
/>
{field.fieldType !== 'string' ? (
<>
<Input
label="图片宽度(px)"
min="24"
onChange={(event) => patchField(index, { imageWidth: Number(event.target.value) })}
type="number"
value={String(field.imageWidth)}
/>
<Input
label="图片高度(px)"
min="24"
onChange={(event) => patchField(index, { imageHeight: Number(event.target.value) })}
type="number"
value={String(field.imageHeight)}
/>
</>
) : (
<Input
label="缺省值"
onChange={(event) => patchField(index, { defaultValue: event.target.value })}
value={field.defaultValue}
/>
)}
</div>
<Textarea
label="通道说明"
onChange={(event) => patchField(index, { description: event.target.value })}
rows={2}
value={field.description}
/>
<Button
className="channel-remove-field-button"
icon={<Trash2 size={15} />}
onClick={() => setDraft((current) => current.filter((_, fieldIndex) => fieldIndex !== index))}
size="sm"
variant="ghost"
>
</Button>
</article>
))}
{draft.length === 0 ? <div className="channel-report-empty"></div> : null}
</div>
</section>
</div>
{error ? <p className="form-error">{error}</p> : null}
</Modal>
);
}
@@ -17,13 +17,19 @@ vi.mock('@/api/adminApi', () => ({ adminApi }));
describe('ReportMaterialImportModal mapping profile action', () => {
beforeEach(() => {
Object.values(adminApi).forEach((method) => method.mockReset());
adminApi.listTenantOptions.mockResolvedValue([{ id: 'tenant-1', name: '测试企业', code: 'T001', status: 'active' }]);
adminApi.listEnterpriseApplicationOptions.mockResolvedValue([]);
adminApi.listTenantOptions.mockResolvedValue([
{ id: 'tenant-1', name: '测试企业', code: 'T001', status: 'active' },
]);
adminApi.listEnterpriseApplicationOptions.mockResolvedValue([
{ id: 'app-1', tenantId: 'tenant-1', name: '测试应用', status: 'active' },
]);
adminApi.listDrainageFields.mockResolvedValue([]);
adminApi.listReportImportProfiles.mockResolvedValue([]);
adminApi.analyzeReportMaterialImport.mockResolvedValue({
id: 'analysis-1',
columns: [{ sourceColumnIndex: 0, columnLetter: 'A', sourceHeader: '签名', sourceHeaderPath: '签名', imageCount: 0 }],
columns: [
{ sourceColumnIndex: 0, columnLetter: 'A', sourceHeader: '签名', sourceHeaderPath: '签名', imageCount: 0 },
],
rows: [],
suggestedMappings: [],
});
@@ -37,6 +43,9 @@ describe('ReportMaterialImportModal mapping profile action', () => {
expect(tenantSelect).not.toBeNull();
await user.click(tenantSelect!);
await user.click(screen.getByRole('option', { name: /测试企业/ }));
const applicationSelect = screen.getByText('企业应用(必选)').closest('label')?.querySelector('button');
await user.click(applicationSelect!);
await user.click(screen.getByRole('option', { name: '测试应用' }));
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).not.toBeNull();
fireEvent.change(fileInput!, { target: { files: [new File(['xlsx'], 'mapping.xlsx')] } });
@@ -46,7 +55,9 @@ describe('ReportMaterialImportModal mapping profile action', () => {
expect(toggle).toHaveClass('ui-button', 'report-import-profile__toggle');
expect(toggle).toHaveAttribute('aria-pressed', 'false');
await user.click(toggle);
await waitFor(() => expect(screen.getByRole('button', { name: '本次将保存/更新映射方案' })).toHaveAttribute('aria-pressed', 'true'));
await waitFor(() =>
expect(screen.getByRole('button', { name: '本次将保存/更新映射方案' })).toHaveAttribute('aria-pressed', 'true'),
);
expect(screen.getByLabelText('映射方案名称')).toBeVisible();
});
});
+336 -59
View File
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, FileSpreadsheet, Plus } from 'lucide-react';
import { adminApi, type DictionaryItem, type EnterpriseApplication, type ReportImportMapping, type ReportImportProfile, type TenantOption } from '@/api/adminApi';
import {
adminApi,
type DictionaryItem,
type EnterpriseApplication,
type ReportImportMapping,
type ReportImportProfile,
type TenantOption,
} from '@/api/adminApi';
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
type ReportType = 'signature' | 'drainage';
@@ -8,17 +15,36 @@ type AnalyzeResult = {
id: string;
sheetName?: string;
sheets?: string[];
columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>;
columns: Array<{
sourceColumnIndex: number;
columnLetter: string;
sourceHeader: string;
sourceHeaderPath: string;
imageCount: number;
}>;
rows: Array<{ rowNumber: number; values: Record<string, string>; imageColumns: number[] }>;
suggestedMappings: ReportImportMapping[];
};
const transforms = [{ label: '保持原值', value: '' }, { label: '去除首尾空格', value: 'trim' }, { label: '仅保留数字', value: 'digits' }, { label: '转大写', value: 'uppercase' }, { label: '转小写', value: 'lowercase' }];
const transforms = [
{ label: '保持原值', value: '' },
{ label: '去除首尾空格', value: 'trim' },
{ label: '仅保留数字', value: 'digits' },
{ label: '转大写', value: 'uppercase' },
{ label: '转小写', value: 'lowercase' },
];
function coreTargets(reportType: ReportType) {
return reportType === 'signature'
? [{ label: '短信签名', value: 'signatureName:signature_name:string' }, { label: '签名用途/依据', value: 'purpose:purpose:string' }]
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '引流 URL 或号码', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
? [
{ label: '短信签名', value: 'signatureName:signature_name:string' },
{ label: '签名用途/依据', value: 'purpose:purpose:string' },
]
: [
{ label: '所属短信签名', value: 'signatureName:signature_name:string' },
{ label: '引流 URL 或号码', value: 'url:url:string' },
{ label: '备注', value: 'remark:remark:string' },
];
}
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
@@ -41,95 +67,346 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
const [error, setError] = useState('');
useEffect(() => {
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listDrainageFields()])
.then(([tenantItems, applicationItems, fieldItems]) => { setTenants(tenantItems); setApplications(applicationItems); setLibraryFields(fieldItems.filter((item) => item.status === 'active')); })
Promise.all([
adminApi.listTenantOptions(),
adminApi.listEnterpriseApplicationOptions(),
adminApi.listDrainageFields(),
])
.then(([tenantItems, applicationItems, fieldItems]) => {
setTenants(tenantItems);
setApplications(applicationItems);
setLibraryFields(fieldItems.filter((item) => item.status === 'active'));
})
.catch((failure: Error) => setError(failure.message || '基础数据加载失败'));
}, []);
useEffect(() => {
adminApi.listReportImportProfiles(reportType).then(setProfiles).catch(() => setProfiles([]));
setProfileId(''); setAnalysis(undefined); setMappings([]);
adminApi
.listReportImportProfiles(reportType)
.then(setProfiles)
.catch(() => setProfiles([]));
}, [reportType]);
const availableApplications = useMemo(() => applications.filter((item) => !tenantId || item.tenantId === tenantId), [applications, tenantId]);
function changeReportType(next: ReportType) {
setReportType(next);
setProfileId('');
setAnalysis(undefined);
setMappings([]);
}
const availableApplications = useMemo(
() => applications.filter((item) => !tenantId || item.tenantId === tenantId),
[applications, tenantId],
);
const mappingByColumn = useMemo(() => new Map(mappings.map((item) => [item.sourceColumnIndex, item])), [mappings]);
async function analyze() {
if (!tenantId || !file) { setError('请选择企业和 XLSX 文件'); return; }
setBusy(true); setError('');
if (!tenantId || !applicationId || !file) {
setError('请选择企业、企业应用和 XLSX 文件');
return;
}
setBusy(true);
setError('');
try {
const result = await adminApi.analyzeReportMaterialImport(file, { tenantId, applicationId: applicationId || undefined, reportType, headerRowCount, dataStartRow, profileId: profileId || undefined }) as AnalyzeResult;
setAnalysis(result); setMappings(result.suggestedMappings ?? []);
const result = (await adminApi.analyzeReportMaterialImport(file, {
tenantId,
applicationId,
reportType,
headerRowCount,
dataStartRow,
profileId: profileId || undefined,
})) as AnalyzeResult;
setAnalysis(result);
setMappings(result.suggestedMappings ?? []);
const selectedProfile = profiles.find((item) => item.id === profileId);
if (selectedProfile) setProfileName(selectedProfile.name);
} catch (failure) { setError(failure instanceof Error ? failure.message : '文件解析失败'); }
finally { setBusy(false); }
} catch (failure) {
setError(failure instanceof Error ? failure.message : '文件解析失败');
} finally {
setBusy(false);
}
}
function setTarget(column: AnalyzeResult['columns'][number], encoded: string) {
setMappings((current) => {
const remaining = current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex);
if (!encoded) return remaining;
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [ReportImportMapping['targetKind'], string, ReportImportMapping['fieldType']];
return [...remaining, { sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetKind, targetFieldCode, fieldType, required: false, sortOrder: (column.sourceColumnIndex + 1) * 10 }].sort((left, right) => left.sourceColumnIndex - right.sourceColumnIndex);
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [
ReportImportMapping['targetKind'],
string,
ReportImportMapping['fieldType'],
];
return [
...remaining,
{
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath,
sourceColumnIndex: column.sourceColumnIndex,
targetKind,
targetFieldCode,
fieldType,
required: false,
sortOrder: (column.sourceColumnIndex + 1) * 10,
},
].sort((left, right) => left.sourceColumnIndex - right.sourceColumnIndex);
});
}
function patchMapping(columnIndex: number, patch: Partial<ReportImportMapping>) {
setMappings((current) => current.map((item) => item.sourceColumnIndex === columnIndex ? { ...item, ...patch } : item));
setMappings((current) =>
current.map((item) => (item.sourceColumnIndex === columnIndex ? { ...item, ...patch } : item)),
);
}
async function commit() {
if (!analysis || mappings.length === 0) { setError('请至少配置一个导入字段映射'); return; }
if (saveProfile && !profileName.trim()) { setError('请输入映射方案名称'); return; }
setBusy(true); setError('');
if (!analysis || mappings.length === 0) {
setError('请至少配置一个导入字段映射');
return;
}
if (saveProfile && !profileName.trim()) {
setError('请输入映射方案名称');
return;
}
setBusy(true);
setError('');
try {
await adminApi.commitReportMaterialImport(analysis.id, {
mappings,
profile: saveProfile ? { id: profileId || undefined, name: profileName, reportType, tenantId, applicationId: applicationId || null, sheetName: analysis.sheetName, headerRowCount, dataStartRow, columns: mappings } : undefined,
profile: saveProfile
? {
id: profileId || undefined,
name: profileName,
reportType,
tenantId,
applicationId,
sheetName: analysis.sheetName,
headerRowCount,
dataStartRow,
columns: mappings,
}
: undefined,
});
onCompleted(); onClose();
} catch (failure) { setError(failure instanceof Error ? failure.message : '导入失败'); }
finally { setBusy(false); }
onCompleted();
onClose();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '导入失败');
} finally {
setBusy(false);
}
}
const targetOptions = [
{ label: '不导入此列', value: '' },
...coreTargets(reportType),
...libraryFields.map((field) => ({ label: `报备字段 · ${String(field.name ?? field.code)}`, value: `dynamic:${String(field.code)}:${field.fieldType === 'string' ? 'string' : field.fieldType}` })),
...libraryFields.map((field) => ({
label: `报备字段 · ${String(field.name ?? field.code)}`,
value: `dynamic:${String(field.code)}:${field.fieldType === 'string' ? 'string' : field.fieldType}`,
})),
];
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button>{analysis ? <Button disabled={busy} onClick={() => void commit()}>{busy ? '提交中...' : '提交导入审核'}</Button> : <Button disabled={busy || !file || !tenantId} onClick={() => void analyze()}>{busy ? '解析中...' : '解析文件并配置映射'}</Button>}</>} onClose={onClose} open size="xl" title={<div className="channel-field-config-title"><h2></h2><p> WPS XLSX </p></div>}>
<div className="report-import-basic-grid">
<Select label="资料类型" onChange={(event) => setReportType(event.target.value as ReportType)} options={[{ label: '签名资料', value: 'signature' }, { label: '引流信息资料', value: 'drainage' }]} value={reportType} />
<Select label="所属企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: `${item.name}${item.code}`, value: item.id }))]} value={tenantId} />
<Select label="企业应用(可选)" onChange={(event) => setApplicationId(event.target.value)} options={[{ label: '不限定应用', value: '' }, ...availableApplications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} />
<Select label="复用导入映射(可选)" onChange={(event) => { const id = event.target.value; setProfileId(id); const profile = profiles.find((item) => item.id === id); if (profile) { setHeaderRowCount(profile.headerRowCount); setDataStartRow(profile.dataStartRow); } }} options={[{ label: '新建映射', value: '' }, ...profiles.map((item) => ({ label: item.name, value: item.id }))]} value={profileId} />
<Input label="表头行数" max="5" min="1" onChange={(event) => setHeaderRowCount(Number(event.target.value))} type="number" value={String(headerRowCount)} />
<Input label="数据起始行" min="2" onChange={(event) => setDataStartRow(Number(event.target.value))} type="number" value={String(dataStartRow)} />
</div>
<label className="report-import-file"><span><FileSpreadsheet size={22} /><strong>{file?.name ?? '选择 WPS 另存的 .xlsx 文件'}</strong></span><input accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => { setFile(event.target.files?.[0]); setAnalysis(undefined); }} type="file" /></label>
{analysis ? <div className="report-import-mapping">
<div className="channel-field-section-head"><div><h3></h3><p></p></div><Tag tone="info"> {analysis.columns.length} </Tag></div>
<div className="report-import-mapping-table"><div className="report-import-mapping-head"><span>/</span><span></span><span></span><span></span><span></span></div>{analysis.columns.map((column) => {
const mapping = mappingByColumn.get(column.sourceColumnIndex);
const encoded = mapping ? `${mapping.targetKind}:${mapping.targetFieldCode}:${mapping.fieldType}` : '';
return <div className="report-import-mapping-row" key={column.sourceColumnIndex}><span><strong>{column.columnLetter} · {column.sourceHeader}</strong><small>{column.sourceHeaderPath}</small>{column.imageCount ? <Tag tone="warning">{column.imageCount} </Tag> : null}</span><Select onChange={(event) => setTarget(column, event.target.value)} options={targetOptions} value={encoded} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { fieldType: event.target.value as ReportImportMapping['fieldType'] })} options={[{ label: '文本', value: 'string' }, { label: '图片', value: 'image' }, { label: '文件', value: 'file' }]} value={mapping?.fieldType ?? 'string'} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(mapping?.required ?? false)} /><Select disabled={!mapping || mapping.fieldType !== 'string'} onChange={(event) => patchMapping(column.sourceColumnIndex, { transform: event.target.value })} options={transforms} value={mapping?.transform ?? ''} /></div>;
})}</div>
<div className="report-import-profile">
<Button
aria-pressed={saveProfile}
className="report-import-profile__toggle"
icon={saveProfile ? <CheckCircle2 size={16} /> : <Plus size={16} />}
onClick={() => setSaveProfile((value) => !value)}
variant={saveProfile ? 'secondary' : 'ghost'}
>
{saveProfile ? '本次将保存/更新映射方案' : '保存为可复用映射方案'}
</Button>
{saveProfile ? <Input label="映射方案名称" onChange={(event) => setProfileName(event.target.value)} placeholder="例如:海南移动签名资料模板" value={profileName} /> : null}
return (
<Modal
footer={
<>
<Button onClick={onClose} variant="ghost">
</Button>
{analysis ? (
<Button disabled={busy} onClick={() => void commit()}>
{busy ? '提交中...' : '提交导入审核'}
</Button>
) : (
<Button disabled={busy || !file || !tenantId || !applicationId} onClick={() => void analyze()}>
{busy ? '解析中...' : '解析文件并配置映射'}
</Button>
)}
</>
}
onClose={onClose}
open
size="xl"
title={
<div className="channel-field-config-title">
<h2></h2>
<p>100MB的 Excel Drawing WPS DISPIMG XLSX</p>
</div>
}
>
<div className="report-import-basic-grid">
<Select
label="资料类型"
onChange={(event) => changeReportType(event.target.value as ReportType)}
options={[
{ label: '签名资料', value: 'signature' },
{ label: '引流信息资料', value: 'drainage' },
]}
value={reportType}
/>
<Select
label="所属企业"
onChange={(event) => {
setTenantId(event.target.value);
setApplicationId('');
}}
options={[
{ label: '请选择企业', value: '' },
...tenants.map((item) => ({ label: `${item.name}${item.code}`, value: item.id })),
]}
value={tenantId}
/>
<Select
label="企业应用(必选)"
onChange={(event) => setApplicationId(event.target.value)}
options={[
{ label: '请选择企业应用', value: '' },
...availableApplications.map((item) => ({ label: item.name, value: item.id })),
]}
value={applicationId}
/>
<Select
label="复用导入映射(可选)"
onChange={(event) => {
const id = event.target.value;
setProfileId(id);
const profile = profiles.find((item) => item.id === id);
if (profile) {
setHeaderRowCount(profile.headerRowCount);
setDataStartRow(profile.dataStartRow);
}
}}
options={[
{ label: '新建映射', value: '' },
...profiles.map((item) => ({ label: item.name, value: item.id })),
]}
value={profileId}
/>
<Input
label="表头行数"
max="5"
min="1"
onChange={(event) => setHeaderRowCount(Number(event.target.value))}
type="number"
value={String(headerRowCount)}
/>
<Input
label="数据起始行"
min="2"
onChange={(event) => setDataStartRow(Number(event.target.value))}
type="number"
value={String(dataStartRow)}
/>
</div>
{analysis.rows.length ? <details className="report-import-preview"><summary> {analysis.rows.length} </summary><pre>{JSON.stringify(analysis.rows, null, 2)}</pre></details> : null}
</div> : null}
{error ? <p className="form-error">{error}</p> : null}
</Modal>;
<label className="report-import-file">
<span>
<FileSpreadsheet size={22} />
<strong>{file?.name ?? '选择 WPS 另存的 .xlsx 文件'}</strong>
</span>
<input
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => {
setFile(event.target.files?.[0]);
setAnalysis(undefined);
}}
type="file"
/>
</label>
{analysis ? (
<div className="report-import-mapping">
<div className="channel-field-section-head">
<div>
<h3></h3>
<p></p>
</div>
<Tag tone="info"> {analysis.columns.length} </Tag>
</div>
<div className="report-import-mapping-table">
<div className="report-import-mapping-head">
<span>/</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{analysis.columns.map((column) => {
const mapping = mappingByColumn.get(column.sourceColumnIndex);
const encoded = mapping ? `${mapping.targetKind}:${mapping.targetFieldCode}:${mapping.fieldType}` : '';
return (
<div className="report-import-mapping-row" key={column.sourceColumnIndex}>
<span>
<strong>
{column.columnLetter} · {column.sourceHeader}
</strong>
<small>{column.sourceHeaderPath}</small>
{column.imageCount ? <Tag tone="warning">{column.imageCount} </Tag> : null}
</span>
<Select
onChange={(event) => setTarget(column, event.target.value)}
options={targetOptions}
value={encoded}
/>
<Select
disabled={!mapping}
onChange={(event) =>
patchMapping(column.sourceColumnIndex, {
fieldType: event.target.value as ReportImportMapping['fieldType'],
})
}
options={[
{ label: '文本', value: 'string' },
{ label: '图片', value: 'image' },
{ label: '文件', value: 'file' },
]}
value={mapping?.fieldType ?? 'string'}
/>
<Select
disabled={!mapping}
onChange={(event) =>
patchMapping(column.sourceColumnIndex, { required: event.target.value === 'true' })
}
options={[
{ label: '选填', value: 'false' },
{ label: '必填', value: 'true' },
]}
value={String(mapping?.required ?? false)}
/>
<Select
disabled={!mapping || mapping.fieldType !== 'string'}
onChange={(event) => patchMapping(column.sourceColumnIndex, { transform: event.target.value })}
options={transforms}
value={mapping?.transform ?? ''}
/>
</div>
);
})}
</div>
<div className="report-import-profile">
<Button
aria-pressed={saveProfile}
className="report-import-profile__toggle"
icon={saveProfile ? <CheckCircle2 size={16} /> : <Plus size={16} />}
onClick={() => setSaveProfile((value) => !value)}
variant={saveProfile ? 'secondary' : 'ghost'}
>
{saveProfile ? '本次将保存/更新映射方案' : '保存为可复用映射方案'}
</Button>
{saveProfile ? (
<Input
label="映射方案名称"
onChange={(event) => setProfileName(event.target.value)}
placeholder="例如:海南移动签名资料模板"
value={profileName}
/>
) : null}
</div>
{analysis.rows.length ? (
<details className="report-import-preview">
<summary> {analysis.rows.length} </summary>
<pre>{JSON.stringify(analysis.rows, null, 2)}</pre>
</details>
) : null}
</div>
) : null}
{error ? <p className="form-error">{error}</p> : null}
</Modal>
);
}
+48 -22
View File
@@ -141,18 +141,43 @@ describe('report workbench pages', () => {
application: { id: 'app-1', name: '测试应用' },
}));
const target = (eligible: boolean, blockedReasons: string[] = []) => ({ eligible, blockedReasons });
adminApi.listPendingReportMaterials.mockResolvedValue({ items: materials, total: materials.length, page: 1, pageSize: 20 });
adminApi.listPendingReportMaterials.mockResolvedValue({
items: materials,
total: materials.length,
page: 1,
pageSize: 20,
});
adminApi.preflightReportMaterialBatch.mockResolvedValue({
eligible: true,
eligibleTargetCount: 2,
skippedTargetCount: 5,
items: [
{ id: 'signature:pending', eligible: true, blockedReasons: [], targets: [target(true)] },
{ id: 'signature:partial', eligible: true, blockedReasons: ['缺少必填字段:营业执照'], targets: [target(true), target(false, ['缺少必填字段:营业执照'])] },
{ id: 'signature:incomplete', eligible: false, blockedReasons: ['缺少必填字段:营业执照'], targets: [target(false, ['缺少必填字段:营业执照'])] },
{ id: 'signature:abandoned', eligible: false, blockedReasons: ['该通道报备明细已放弃报备'], targets: [target(false, ['该通道报备明细已放弃报备'])] },
{
id: 'signature:partial',
eligible: true,
blockedReasons: ['缺少必填字段:营业执照'],
targets: [target(true), target(false, ['缺少必填字段:营业执照'])],
},
{
id: 'signature:incomplete',
eligible: false,
blockedReasons: ['缺少必填字段:营业执照'],
targets: [target(false, ['缺少必填字段:营业执照'])],
},
{
id: 'signature:abandoned',
eligible: false,
blockedReasons: ['该通道报备明细已放弃报备'],
targets: [target(false, ['该通道报备明细已放弃报备'])],
},
{ id: 'signature:no-route', eligible: false, blockedReasons: ['当前应用没有启用且可路由的通道'], targets: [] },
{ id: 'signature:generated', eligible: false, blockedReasons: ['同一资料版本已在批次 RB-1 生成'], targets: [target(false, ['同一资料版本已在批次 RB-1 生成'])] },
{
id: 'signature:generated',
eligible: false,
blockedReasons: ['同一资料版本已在批次 RB-1 生成'],
targets: [target(false, ['同一资料版本已在批次 RB-1 生成'])],
},
],
});
@@ -168,27 +193,27 @@ describe('report workbench pages', () => {
expect(screen.getByText('全部放弃')).toHaveClass('ui-tag--neutral');
expect(screen.getByText('无有效通道')).toHaveClass('ui-tag--neutral');
expect(screen.getByText('V2已生成')).toHaveClass('ui-tag--success');
screen.getAllByText('缺少必填字段:营业执照').forEach((message) =>
expect(message).toHaveClass('status-danger'),
);
screen.getAllByText('缺少必填字段:营业执照').forEach((message) => expect(message).toHaveClass('status-danger'));
});
it('keeps the report record list compact while retaining full details in the dialog', async () => {
adminApi.listReportRecordsPage.mockResolvedValue({
items: [{
id: 'record-1',
taskId: 'report-task-with-a-long-identifier-1',
channelId: 'channel-1',
action: 'manual_status_change',
statusBefore: 'pending',
statusAfter: 'approved',
reason: '通道已确认报备通过',
sourceEntry: 'report_task',
createdAt: '2026-09-03 15:30:00',
channel: { id: 'channel-1', name: '测试通道' },
operator: { id: 'operator-1', username: 'operator', displayName: '运营一' },
task: task('record-1'),
}],
items: [
{
id: 'record-1',
taskId: 'report-task-with-a-long-identifier-1',
channelId: 'channel-1',
action: 'manual_status_change',
statusBefore: 'pending',
statusAfter: 'approved',
reason: '通道已确认报备通过',
sourceEntry: 'report_task',
createdAt: '2026-09-03 15:30:00',
channel: { id: 'channel-1', name: '测试通道' },
operator: { id: 'operator-1', username: 'operator', displayName: '运营一' },
task: task('record-1'),
},
],
total: 1,
page: 1,
pageSize: 10,
@@ -258,6 +283,7 @@ describe('report workbench pages', () => {
});
await user.click(await screen.findByRole('button', { name: '报备文件导出' }));
expect(await screen.findByText('测试通道')).toBeVisible();
expect(screen.getByRole('button', { name: '导出格式' })).toHaveTextContent('系统 Excel 文件');
expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
expect(screen.getByRole('button', { name: '全部下载' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: '复制简报' }));