feat: build reporting workbench workflow

This commit is contained in:
hectorzhao
2026-09-02 14:30:15 +08:00
parent 53d3668306
commit 9b8196ecab
31 changed files with 7651 additions and 1973 deletions
+497 -72
View File
@@ -1,14 +1,29 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Eye, FileSliders, Search } from 'lucide-react';
import { ArrowLeft, Download, Eye, FileSliders, Search } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import {
adminApi,
type AdminChannel,
type ChannelReportField,
type ClientSmsSignature,
type DictionaryItem,
type ReportRecord,
type ReportTask,
type SingleReportMaterialDetail,
} from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { successRateClassName } from '@/utils/successRate';
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
type ReportType = 'signature' | 'drainage';
type DrainageItem = Record<string, unknown> & { id?: string; url?: string; siteName?: string; submittedAt?: string; remark?: string };
type DrainageItem = Record<string, unknown> & {
id?: string;
url?: string;
siteName?: string;
submittedAt?: string;
remark?: string;
};
const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
approved: { label: '报备成功', tone: 'success' },
@@ -25,21 +40,29 @@ const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | '
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function formatSignatureName(value?: string | null) {
const name = String(value ?? '-').trim().replace(/^[【\[]+|[】\]]+$/g, '');
const name = String(value ?? '-')
.trim()
.replace(/^[【[]+|[】\]]+$/g, '');
return `${name || '-'}`;
}
function drainageItems(signature?: ClientSmsSignature) {
const payload = asRecord(signature?.drainageInfo);
return Array.isArray(payload.links) ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') : [];
return Array.isArray(payload.links)
? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object')
: [];
}
function DateTime({ value }: { value?: unknown }) {
return value ? <span className="channel-report-date">{formatDateTime(String(value))}</span> : <span className="muted">-</span>;
return value ? (
<span className="channel-report-date">{formatDateTime(String(value))}</span>
) : (
<span className="muted">-</span>
);
}
function ReportStatus({ value }: { value?: string }) {
@@ -58,31 +81,118 @@ function DeliveryStats({ task }: { task: ReportTask }) {
failureCount: 0,
failureRate: 0,
};
return <div className="channel-report-stats">
<span><strong className={successRateClassName(stats.successRate)}>{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.submitFailureRate}%</strong><b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b></span>
</div>;
return (
<div className="channel-report-stats">
<span>
<strong className={successRateClassName(stats.successRate)}>{stats.successRate}%</strong>
<b>{stats.successCount.toLocaleString('zh-CN')}</b>
</span>
<span>
<strong>{stats.unknownRate}%</strong>
<b>{stats.unknownCount.toLocaleString('zh-CN')}</b>
</span>
<span>
<strong>{stats.failureRate}%</strong>
<b>{stats.failureCount.toLocaleString('zh-CN')}</b>
</span>
<span>
<strong>{stats.submitFailureRate}%</strong>
<b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b>
</span>
</div>
);
}
function DetailModal({ drainage, reportedAt, signature, task, onClose }: { drainage?: DrainageItem; reportedAt?: string | null; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) {
function DetailModal({
drainage,
reportedAt,
signature,
task,
onClose,
}: {
drainage?: DrainageItem;
reportedAt?: string | null;
signature?: ClientSmsSignature;
task: ReportTask;
onClose: () => void;
}) {
const payload = asRecord(signature?.drainageInfo);
const profile = asRecord(payload.signatureProfile);
const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues);
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}>
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={drainage ? '查看引流信息详情' : '查看签名详情'}
>
<div className="channel-report-detail">
<strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
<p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p>
<p><span></span><span>{signature?.application?.name ?? '-'}</span></p>
<p><span></span><DateTime value={drainage?.submittedAt ?? task.createdAt} /></p>
<p><span></span><DateTime value={reportedAt} /></p>
<p><span></span><DateTime value={task.lastSuccessfulSentAt} /></p>
{!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null}
{drainage ? <><p><span> URL </span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
{Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)}
<section><h3></h3><DeliveryStats task={task} /></section>
<strong>
{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}
</strong>
<p>
<span></span>
<span>{signature?.tenant?.name ?? task.tenantId}</span>
</p>
<p>
<span></span>
<span>{signature?.application?.name ?? '-'}</span>
</p>
<p>
<span></span>
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
</p>
<p>
<span></span>
<DateTime value={reportedAt} />
</p>
<p>
<span></span>
<DateTime value={task.lastSuccessfulSentAt} />
</p>
{!drainage ? (
<>
<p>
<span></span>
<span>{String(profile.basis ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(profile.companyName ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(profile.creditCode ?? '-')}</span>
</p>
</>
) : null}
{drainage ? (
<>
<p>
<span> URL </span>
<span>{String(drainage.url ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(drainage.remark ?? '-')}</span>
</p>
</>
) : null}
{Object.entries(reportValues).map(([key, value]) => (
<p key={key}>
<span>{key}</span>
<span>
{typeof value === 'object'
? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-')
: String(value ?? '-')}
</span>
</p>
))}
<section>
<h3></h3>
<DeliveryStats task={task} />
</section>
</div>
</Modal>
);
@@ -99,7 +209,19 @@ export function AdminChannelReportPage() {
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<{ task: ReportTask; reportedAt?: string | null; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
const [carrier, setCarrier] = useState('all');
const [todaySendMin, setTodaySendMin] = useState('');
const [todaySendMax, setTodaySendMax] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 10;
const [material, setMaterial] = useState<SingleReportMaterialDetail>();
const [detail, setDetail] = useState<{
task: ReportTask;
reportedAt?: string | null;
signature?: ClientSmsSignature;
drainage?: DrainageItem;
}>();
const [statusTask, setStatusTask] = useState<ReportTask>();
const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState('');
@@ -109,31 +231,77 @@ export function AdminChannelReportPage() {
function loadData() {
Promise.all([
adminApi.listChannels(),
adminApi.listReportTasks({ channelId }),
adminApi.listReportTasksPage({
channelId,
keyword: keyword.trim() || undefined,
status: status === 'all' ? undefined : status,
carrier: carrier === 'all' ? undefined : carrier,
todaySendMin: todaySendMin ? Number(todaySendMin) : undefined,
todaySendMax: todaySendMax ? Number(todaySendMax) : undefined,
sort: 'todaySendDesc',
page,
pageSize,
}),
adminApi.listReportRecords({ channelId }),
adminApi.listEnterpriseSignatures(),
adminApi.listChannelReportFields(channelId),
adminApi.listDrainageFields(),
]).then(([channelItems, taskItems, recordItems, signatureItems, fieldItems, libraryItems]) => {
setChannel(channelItems.find((item) => item.id === channelId));
setTasks(taskItems);
setRecords(recordItems);
setSignatures(signatureItems);
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError('');
}).catch((failure: Error) => setError(failure.message || '通道报备详情加载失败'));
])
.then(([channelItems, taskPage, recordItems, signatureItems, fieldItems, libraryItems]) => {
setChannel(channelItems.find((item) => item.id === channelId));
setTasks(taskPage.items);
setTotal(taskPage.total);
setRecords(recordItems);
setSignatures(signatureItems);
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道报备详情加载失败'));
}
useEffect(loadData, [channelId]);
useEffect(loadData, [channelId, page]);
const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]);
const visibleTasks = useMemo(() => tasks.filter((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage = drainageItems(signature).find((item) => String(item.id) === task.drainageItemId);
const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name, drainage?.siteName, drainage?.url].some((value) => String(value ?? '').includes(keyword.trim()));
return matchesKeyword && (status === 'all' || task.status === status);
}), [keyword, signatureMap, status, tasks]);
const visibleTasks = tasks;
async function openMaterial(task: ReportTask) {
try {
setMaterial(
await adminApi.getSingleReportMaterialDetail({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
}),
);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备资料加载失败');
}
}
async function exportMaterial(task: ReportTask) {
try {
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
}
}
function approvedRecord(taskId: string) {
return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved');
@@ -147,8 +315,26 @@ export function AdminChannelReportPage() {
function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, carrier: statusTask.carrier ?? undefined, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' })
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
adminApi
.changeReportTaskStatuses({
items: [
{
signatureId: statusTask.signatureId,
channelId: statusTask.channelId,
carrier: statusTask.carrier ?? undefined,
reportType: statusTask.reportType,
drainageItemId: statusTask.drainageItemId ?? undefined,
status: nextStatus,
},
],
reason: statusReason,
sourceEntry: 'channel_report',
})
.then(() => {
setStatusTask(undefined);
setStatusReason('');
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
@@ -157,48 +343,287 @@ export function AdminChannelReportPage() {
<div className="surface channel-report-hero">
<Breadcrumb items={['通道管理', '短信通道', '报备详情']} />
<div className="channel-report-heading">
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">
</Button>
<h1>{channel?.name ?? '通道报备详情'}</h1>
<div className="channel-report-config-actions">
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost"></Button>
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('drainage')} variant="ghost"></Button>
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost">
</Button>
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('drainage')} variant="ghost">
</Button>
</div>
</div>
<div className="muted">{channel?.code ?? channelId} · {fields.length} </div>
<div className="muted">
{channel?.code ?? channelId} · {fields.length}
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface channel-report-filter">
<div className="channel-report-filter-grid">
<Input label="签名/企业/应用" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键词" prefix={<Search size={16} />} value={keyword} />
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).filter(([value]) => ['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value)).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} />
<div><strong></strong><p className="muted"></p></div>
<Input
label="签名/企业/应用"
onChange={(event) => setKeyword(event.target.value)}
placeholder="请输入关键词"
prefix={<Search size={16} />}
value={keyword}
/>
<Select
label="报备状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
...Object.entries(statusMeta)
.filter(([value]) =>
['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value),
)
.map(([value, meta]) => ({ label: meta.label, value })),
]}
value={status}
/>
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={carrier}
/>
<Input
label="今日发送最小条数"
min="0"
onChange={(event) => setTodaySendMin(event.target.value)}
type="number"
value={todaySendMin}
/>
<Input
label="今日发送最大条数"
min="0"
onChange={(event) => setTodaySendMax(event.target.value)}
type="number"
value={todaySendMax}
/>
</div>
<div className="channel-report-filter-footer">
<span> {total} </span>
<div>
<Button
onClick={() => {
setKeyword('');
setStatus('all');
setCarrier('all');
setTodaySendMin('');
setTodaySendMax('');
}}
variant="ghost"
>
</Button>
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData();
}}
>
</Button>
</div>
</div>
<div className="channel-report-filter-footer"><span> {visibleTasks.length} </span><div><Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost"></Button><Button icon={<Search size={16} />} onClick={loadData}></Button></div></div>
</div>
<div className="surface channel-report-table">
<div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div>
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined;
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? formatSignatureName(signature?.name ?? task.signature?.name) : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div>
<ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={reportedAt} />
<DateTime value={task.lastSuccessfulSentAt} />
<DeliveryStats task={task} />
<div className="channel-report-actions"><button onClick={() => setDetail({ task, reportedAt, signature, drainage })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div>
</div>;
})}
<div className="channel-report-table__head">
<span />
<span> / </span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleTasks.length === 0 ? (
<div className="channel-report-empty"></div>
) : (
visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage =
task.reportType === 'drainage'
? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId)
: undefined;
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
return (
<div
className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`}
key={task.id}
>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>
{drainage ? <i /> : null}
<span>
<strong>
{drainage
? String(drainage.url || '引流信息')
: formatSignatureName(signature?.name ?? task.signature?.name)}
</strong>
<small>
{drainage ? (
formatSignatureName(signature?.name ?? task.signature?.name)
) : (
<>
{signature?.tenant?.name ?? task.tenantId} ·{' '}
{task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}
</>
)}
</small>
</span>
</div>
<ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={reportedAt} />
<DateTime value={task.lastSuccessfulSentAt} />
<DeliveryStats task={task} />
<div className="channel-report-actions">
<button onClick={() => void openMaterial(task)} type="button">
<Eye size={16} />
</button>
{task.reportType !== 'drainage' ? (
<button onClick={() => void exportMaterial(task)} type="button">
<Download size={16} />
</button>
) : null}
<button
className="is-warning"
onClick={() => {
setStatusTask(task);
setNextStatus(task.status);
}}
type="button"
>
</button>
</div>
</div>
);
})
)}
</div>
<Pagination
nextDisabled={page * pageSize >= total}
onNext={() => setPage((value) => value + 1)}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={page}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
/>
{detail ? <DetailModal {...detail} onClose={() => setDetail(undefined)} /> : null}
<Modal footer={<><Button onClick={() => setStatusTask(undefined)} variant="ghost"></Button><Button onClick={saveTaskStatus}></Button></>} onClose={() => setStatusTask(undefined)} open={Boolean(statusTask)} title="修改当前通道报备状态"><div className="admin-system-modal-form"><Select label="报备状态" onChange={(event) => setNextStatus(event.target.value)} options={[{label:'未报备',value:'pending'},{label:'资料待补充',value:'waiting_material'},{label:'报备中',value:'reporting'},{label:'报备通过',value:'approved'},{label:'报备失败',value:'failed'},{label:'放弃报备',value:'abandoned'}]} value={nextStatus}/><Textarea label="修改原因" onChange={(event) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal>
{configType ? <ReportFieldMappingModal fields={fields} libraryFields={libraryFields} onClose={() => setConfigType(undefined)} onSave={saveFieldMapping} reportType={configType} /> : null}
{material ? (
<Modal
footer={<Button onClick={() => setMaterial(undefined)}></Button>}
onClose={() => setMaterial(undefined)}
open
size="xl"
title="查看报备资料"
>
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{material.signatureName}</strong>
</div>
<div>
<span></span>
<strong>
{material.tenant.name} · {material.application?.name ?? '-'}
</strong>
</div>
<div>
<span>/</span>
<strong>
{material.channel.name} · V{material.materialVersion}
</strong>
</div>
</div>
<div className="report-material-detail-list">
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.exportName || field.name}
{field.required ? ' *' : ''}
</span>
<strong>
{typeof field.value === 'object'
? String((field.value as Record<string, unknown>)?.fileName ?? '-')
: String(field.value ?? '-')}
</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>{field.name}</span>
<strong>{String(field.value ?? '-')}</strong>
</div>
))}
</div>
</div>
</Modal>
) : null}
<Modal
footer={
<>
<Button onClick={() => setStatusTask(undefined)} variant="ghost">
</Button>
<Button onClick={saveTaskStatus}></Button>
</>
}
onClose={() => setStatusTask(undefined)}
open={Boolean(statusTask)}
title="修改当前通道报备状态"
>
<div className="admin-system-modal-form">
<Select
label="报备状态"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea
label="修改原因"
onChange={(event) => setStatusReason(event.target.value)}
rows={3}
value={statusReason}
/>
</div>
</Modal>
{configType ? (
<ReportFieldMappingModal
fields={fields}
libraryFields={libraryFields}
onClose={() => setConfigType(undefined)}
onSave={saveFieldMapping}
reportType={configType}
/>
) : null}
</section>
);
}
+193 -51
View File
@@ -1,22 +1,36 @@
import { useEffect, useState } from 'react';
import { FileSpreadsheet, Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Tabs } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Tabs } from '@/components/ui';
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal';
import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable';
import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal';
import { ChannelReportStatusModal, ConfirmModal, DrainageReportStatusModal } from './enterprise-signatures/SignatureReportModals';
import {
ChannelReportStatusModal,
ConfirmModal,
DrainageReportStatusModal,
} from './enterprise-signatures/SignatureReportModals';
import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers';
import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types';
/** R4 page container: owns query state and coordinates focused presentation components. */
export function AdminEnterpriseSignaturesPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{
kind: 'drainage';
signatureId: string;
id: string;
name: string;
} | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{
signature: ClientSmsSignature;
item: DrainageInfo;
} | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState('');
@@ -36,10 +50,20 @@ export function AdminEnterpriseSignaturesPage() {
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const [message, setMessage] = useState('');
const [materialChangedSignature, setMaterialChangedSignature] = useState<ClientSmsSignature | null>(null);
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page, targetSort = signatureSort) {
async function loadData(
filters = {
enterpriseKeyword: appliedEnterpriseKeyword,
applicationKeyword: appliedApplicationKeyword,
signatureKeyword: appliedSignatureKeyword,
drainageKeyword: appliedDrainageKeyword,
},
targetPage = page,
targetSort = signatureSort,
) {
try {
const [signatureResult, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize }),
@@ -57,7 +81,7 @@ export function AdminEnterpriseSignaturesPage() {
}
useEffect(() => {
void loadData(undefined, page);
queueMicrotask(() => void loadData(undefined, page));
}, [page]);
const filteredSignatures = signatures;
@@ -68,21 +92,27 @@ export function AdminEnterpriseSignaturesPage() {
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
const existingPayload = existing ? readDrainagePayload(existing) : { links: [], signatureProfile: undefined };
const drainageInfo = buildDrainagePayload({
mobile: state.mobile,
unicom: state.unicom,
telecom: state.telecom,
}, existingPayload.links, existingPayload.signatureProfile, state.reportValues);
const drainageInfo = buildDrainagePayload(
{
mobile: state.mobile,
unicom: state.unicom,
telecom: state.telecom,
},
existingPayload.links,
existingPayload.signatureProfile,
state.reportValues,
);
try {
let saved: ClientSmsSignature;
if (existing) {
await adminApi.updateEnterpriseSignature(existing.id, {
saved = await adminApi.updateEnterpriseSignature(existing.id, {
applicationId: state.applicationId || null,
drainageInfo,
name: state.name,
purpose: state.purpose,
});
} else {
await adminApi.createEnterpriseSignature({
saved = await adminApi.createEnterpriseSignature({
applicationId: state.applicationId || undefined,
drainageInfo,
name: state.name,
@@ -91,6 +121,7 @@ export function AdminEnterpriseSignaturesPage() {
});
}
setSignatureModal(null);
if (saved.reportMaterialChanged) setMaterialChangedSignature(saved);
await loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业签名保存失败');
@@ -154,39 +185,87 @@ export function AdminEnterpriseSignaturesPage() {
<h1></h1>
</div>
<div className="page-heading-actions">
<Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"></Button>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}></Button>
<Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">
</Button>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}>
</Button>
</div>
</div>
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="签名" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名" prefix={<Search size={16} />} value={signatureKeyword} />
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入引流信息、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
<Input
label="企业名称"
onChange={(event) => setEnterpriseKeyword(event.target.value)}
placeholder="请输入企业名称"
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Input
label="企业应用"
onChange={(event) => setApplicationKeyword(event.target.value)}
placeholder="请输入企业应用名称"
prefix={<Search size={16} />}
value={applicationKeyword}
/>
<Input
label="签名"
onChange={(event) => setSignatureKeyword(event.target.value)}
placeholder="请输入签名"
prefix={<Search size={16} />}
value={signatureKeyword}
/>
<Input
label="引流信息"
onChange={(event) => setDrainageKeyword(event.target.value)}
placeholder="请输入引流信息、URL 或备注"
prefix={<Search size={16} />}
value={drainageKeyword}
/>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), signatureKeyword: signatureKeyword.trim(), drainageKeyword: drainageKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
setAppliedDrainageKeyword(filters.drainageKeyword);
setPage(1);
void loadData(filters, 1);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', signatureKeyword: '', drainageKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
setSignatureKeyword('');
setDrainageKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedSignatureKeyword('');
setAppliedDrainageKeyword('');
setPage(1);
void loadData(filters, 1);
}} variant="ghost"></Button>
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = {
enterpriseKeyword: enterpriseKeyword.trim(),
applicationKeyword: applicationKeyword.trim(),
signatureKeyword: signatureKeyword.trim(),
drainageKeyword: drainageKeyword.trim(),
};
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
setAppliedDrainageKeyword(filters.drainageKeyword);
setPage(1);
void loadData(filters, 1);
}}
>
</Button>
<Button
onClick={() => {
const filters = {
enterpriseKeyword: '',
applicationKeyword: '',
signatureKeyword: '',
drainageKeyword: '',
};
setEnterpriseKeyword('');
setApplicationKeyword('');
setSignatureKeyword('');
setDrainageKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedSignatureKeyword('');
setAppliedDrainageKeyword('');
setPage(1);
void loadData(filters, 1);
}}
variant="ghost"
>
</Button>
</div>
</div>
@@ -199,7 +278,12 @@ export function AdminEnterpriseSignaturesPage() {
value={activeTab}
items={[
{ label: '短信签名', value: 'sms', content: smsSignatureContent },
{ label: '彩信签名', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> },
{
label: '彩信签名',
pending: true,
value: 'mms',
content: <div className="ui-table__empty"></div>,
},
]}
/>
</div>
@@ -209,29 +293,87 @@ export function AdminEnterpriseSignaturesPage() {
applications={applications}
item={signatureModal === 'new' ? undefined : signatureModal}
onClose={() => setSignatureModal(null)}
onSubmit={(state) => { void saveSignature(state); }}
onSubmit={(state) => {
void saveSignature(state);
}}
tenants={tenants}
/>
) : null}
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
void loadData();
}} /> : null}
{reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null}
{materialChangedSignature ? (
<Modal
footer={
<>
<Button onClick={() => setMaterialChangedSignature(null)} variant="ghost">
</Button>
<Button
onClick={() => {
const id = materialChangedSignature.id;
setMaterialChangedSignature(null);
navigate(`/admin/report-materials?signatureId=${encodeURIComponent(id)}`);
}}
>
</Button>
</>
}
onClose={() => setMaterialChangedSignature(null)}
open
title="签名资料已更新"
>
<div className="signature-alert">
<FileSpreadsheet size={20} />
<span></span>
</div>
</Modal>
) : null}
{importOpen ? (
<ReportMaterialImportModal
onClose={() => setImportOpen(false)}
onCompleted={() => {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
void loadData();
}}
/>
) : null}
{reportStatusTarget ? (
<ChannelReportStatusModal
item={reportStatusTarget}
onClose={() => setReportStatusTarget(null)}
onSaved={() => {
setReportStatusTarget(null);
void loadData();
}}
/>
) : null}
{drainageModal ? (
<DrainageFormModal
applicationId={signatures.find((item) => item.id === drainageModal.signatureId)?.applicationId}
item={drainageModal.item}
onClose={() => setDrainageModal(null)}
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
onSubmit={(item) => {
void saveDrainage(drainageModal.signatureId, item);
}}
/>
) : null}
{drainageStatusTarget ? (
<DrainageReportStatusModal
item={drainageStatusTarget.item}
onClose={() => setDrainageStatusTarget(null)}
onSaved={() => {
setDrainageStatusTarget(null);
void loadData();
}}
signature={drainageStatusTarget.signature}
/>
) : null}
{drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null}
{deleteTarget ? (
<ConfirmModal
message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => { void confirmDelete(); }}
onConfirm={() => {
void confirmDelete();
}}
/>
) : null}
</section>
+339
View File
@@ -0,0 +1,339 @@
import { useEffect, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import {
Breadcrumb,
Button,
CarrierTag,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabels: Record<string, string> = {
completed: '生成完成',
partial_failed: '部分生成',
failed: '生成失败',
generating: '生成中',
pending: '未报备',
waiting_material: '资料待补充',
reporting: '报备中',
approved: '报备通过',
rejected: '报备失败',
abandoned: '已放弃',
};
export function AdminReportBatchesPage() {
const [items, setItems] = useState<ReportMaterialBatch[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [detail, setDetail] = useState<ReportMaterialBatch>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [nextStatus, setNextStatus] = useState('reporting');
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const pageSize = 20;
function load(target = page) {
adminApi
.listReportMaterialBatches({
keyword: keyword.trim() || undefined,
startAt: dateRange.start,
endAt: dateRange.end,
page: target,
pageSize,
})
.then((result) => {
setItems(result.items);
setTotal(result.total);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备批次加载失败'));
}
useEffect(() => {
load(page);
}, [page]);
async function openBatch(batch: ReportMaterialBatch) {
try {
const [batchDetail, taskPage] = await Promise.all([
adminApi.getReportMaterialBatch(batch.id),
adminApi.listReportMaterialBatchTasks(batch.id, { page: 1, pageSize: 100 }),
]);
setDetail(batchDetail);
setTasks(taskPage.items);
setSelected(new Set());
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次明细加载失败');
}
}
async function saveStatuses() {
const chosen = tasks.filter((task) => selected.has(task.id));
if (!chosen.length) return;
try {
await adminApi.changeReportTaskStatuses({
items: chosen.map((task) => ({
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
reportType: task.reportType,
drainageItemId: task.drainageItemId ?? undefined,
status: nextStatus,
})),
reason: reason.trim() || undefined,
sourceEntry: 'report_task',
});
if (detail) await openBatch(detail);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批量状态修改失败');
}
}
async function exportOne(task: ReportTask) {
try {
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
}
}
const columns: Array<TableColumn<ReportMaterialBatch>> = [
{ key: 'batchNo', title: '报备批次号', render: (item) => <strong>{item.batchNo}</strong> },
{ key: 'time', title: '生成时间', render: (item) => formatDateTime(item.createdAt) },
{ key: 'count', title: '明细进度', render: (item) => `${item.successCount}/${item.reportTotal}` },
{ key: 'channels', title: '通道/文件', render: (item) => `${item.channelCount}个通道 · ${item.fileCount}份文件` },
{
key: 'status',
title: '生成状态',
render: (item) => (
<Tag tone={item.status === 'completed' ? 'success' : item.status === 'failed' ? 'danger' : 'warning'}>
{statusLabels[item.status] ?? item.status}
</Tag>
),
},
{
key: 'files',
title: '文件',
render: (item) => (
<div className="table-actions">
{item.exportFiles.map((file) =>
file.fileObjectId ? (
<a href={fileDownloadUrl(file.fileObjectId)} key={file.id}>
<Download size={14} />
{file.fileName}
</a>
) : null,
)}
</div>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button icon={<Eye size={14} />} onClick={() => void openBatch(item)} size="sm" variant="ghost">
</Button>
),
},
];
const taskColumns: Array<TableColumn<ReportTask>> = [
{
key: 'select',
title: '',
width: '44px',
render: (task) => (
<input
aria-label={`选择${task.signature?.name ?? task.id}`}
checked={selected.has(task.id)}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(task.id)) next.delete(task.id);
else next.add(task.id);
return next;
})
}
type="checkbox"
/>
),
},
{
key: 'target',
title: '企业/应用/签名',
render: (task) => (
<div>
<strong>{task.signature?.name ?? '-'}</strong>
<div className="muted">
{task.signature?.tenant?.name ?? '-'} · {task.signature?.application?.name ?? '-'}
</div>
</div>
),
},
{
key: 'channel',
title: '通道/运营商',
render: (task) => (
<div>
{task.channel?.name ?? '-'}
{task.carrier ? (
<div>
<CarrierTag carrier={task.carrier} />
</div>
) : null}
</div>
),
},
{
key: 'version',
title: '资料版本',
render: (task) => `V${task.exportItems?.[0]?.batchItem.materialVersion ?? '-'}`,
},
{
key: 'status',
title: '报备状态',
render: (task) => (
<Tag
tone={
task.status === 'approved'
? 'success'
: task.status === 'failed' || task.status === 'rejected'
? 'danger'
: 'warning'
}
>
{statusLabels[task.status] ?? task.status}
</Tag>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (task) =>
task.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportOne(task)} size="sm" variant="ghost">
</Button>
) : (
'-'
),
},
];
return (
<section className="page-stack report-batch-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备工作台', '报备批次']} />
<h1></h1>
<p></p>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="报备批次号" onChange={(event) => setKeyword(event.target.value)} value={keyword} />
<DateRangeInput label="生成时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else load(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="surface">
<Table columns={columns} data={items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" />
</div>
<Pagination
nextDisabled={page * pageSize >= total}
onNext={() => setPage((value) => value + 1)}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={page}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
/>
{detail ? (
<Modal
footer={<Button onClick={() => setDetail(undefined)}></Button>}
onClose={() => setDetail(undefined)}
open
size="xl"
title={`批次明细 · ${detail.batchNo}`}
>
<div className="page-stack">
<div className="report-batch-toolbar">
<strong>
{tasks.length} {selected.size}
</strong>
<Select
aria-label="批量修改状态"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea
aria-label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="修改原因"
rows={2}
value={reason}
/>
<Button disabled={!selected.size} onClick={() => void saveStatuses()}>
</Button>
</div>
<Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" />
</div>
</Modal>
) : null}
</section>
);
}
+301 -146
View File
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Download, Layers3, Search, ShieldCheck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, Layers3, Search, ShieldCheck } from 'lucide-react';
import {
adminApi,
fileDownloadUrl,
type ReportMaterialBatch,
type ReportMaterialBatchPreflight,
type ReportMaterialBatchResult,
type ReportMaterialPendingItem,
@@ -18,7 +17,6 @@ import {
Pagination,
Select,
Table,
Tabs,
Tag,
type DateRangeValue,
type TableColumn,
@@ -26,29 +24,24 @@ import {
import { formatDateTime } from '@/utils/dateTime';
import { createUuid } from '@/utils/randomId';
const batchStatusLabels: Record<string, string> = {
completed: '生成完成',
partial_failed: '部分生成',
failed: '生成失败',
generating: '生成中',
processing: '生成中',
};
export function AdminReportMaterialsPage() {
const [activeTab, setActiveTab] = useState<'pending' | 'batches'>('pending');
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({ items: [], total: 0 });
const [batchData, setBatchData] = useState<{ items: ReportMaterialBatch[]; total: number }>({ items: [], total: 0 });
const navigate = useNavigate();
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({
items: [],
total: 0,
});
const [selected, setSelected] = useState<Set<string>>(new Set());
const [reportType, setReportType] = useState('all');
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [pendingPage, setPendingPage] = useState(1);
const [batchPage, setBatchPage] = useState(1);
const pageSize = 20;
const [busy, setBusy] = useState(false);
const [preflightBusy, setPreflightBusy] = useState(false);
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(new Map());
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(
new Map(),
);
const [operationKey, setOperationKey] = useState('');
const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
@@ -68,7 +61,7 @@ export function AdminReportMaterialsPage() {
const nextReportType = filters.reportType ?? reportType;
try {
const result = await adminApi.listPendingReportMaterials({
reportType: nextReportType === 'all' ? undefined : nextReportType as 'signature' | 'drainage',
reportType: nextReportType === 'all' ? undefined : (nextReportType as 'signature' | 'drainage'),
keyword: nextKeyword.trim() || undefined,
startAt: nextDateRange.start,
endAt: nextDateRange.end,
@@ -76,7 +69,9 @@ export function AdminReportMaterialsPage() {
pageSize,
});
setPendingData({ items: result.items, total: result.total });
const eligibility = result.items.length ? await adminApi.preflightReportMaterialBatch({ items: result.items.map(toBatchItem) }) : null;
const eligibility = result.items.length
? await adminApi.preflightReportMaterialBatch({ items: result.items.map(toBatchItem) })
: null;
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
setPoolEligibility(eligibilityMap);
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
@@ -86,38 +81,9 @@ export function AdminReportMaterialsPage() {
}
}
async function loadBatches(
page = batchPage,
filters: {
keyword?: string;
dateRange?: DateRangeValue;
} = {},
) {
const nextKeyword = filters.keyword ?? keyword;
const nextDateRange = filters.dateRange ?? dateRange;
try {
const result = await adminApi.listReportMaterialBatches({
keyword: nextKeyword.trim() || undefined,
startAt: nextDateRange.start,
endAt: nextDateRange.end,
page,
pageSize,
});
setBatchData({ items: result.items, total: result.total });
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '已生成批次加载失败');
}
}
function loadActive() {
if (activeTab === 'pending') void loadPending(pendingPage);
else void loadBatches(batchPage);
}
useEffect(() => {
loadActive();
}, [activeTab, pendingPage, batchPage, reportType]);
queueMicrotask(() => void loadPending(pendingPage));
}, [pendingPage, reportType]);
const eligibleItems = pendingData.items.filter((item) => poolEligibility.get(item.id)?.eligible);
const allSelected = eligibleItems.length > 0 && eligibleItems.every((item) => selected.has(item.id));
@@ -126,7 +92,8 @@ export function AdminReportMaterialsPage() {
if (!poolEligibility.get(id)?.eligible) return;
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
@@ -164,9 +131,11 @@ export function AdminReportMaterialsPage() {
items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })),
});
setBatchResult(batch);
setMessage(`批次 ${batch.batchNo} 已生成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`);
setMessage(
`批次 ${batch.batchNo} 已生成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`,
);
setSelected(new Set());
await Promise.all([loadPending(pendingPage), loadBatches(1)]);
await loadPending(pendingPage);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备批次生成失败');
} finally {
@@ -174,103 +143,289 @@ export function AdminReportMaterialsPage() {
}
}
const pendingColumns = useMemo<Array<TableColumn<ReportMaterialPendingItem>>>(() => [
{
key: 'select',
title: '',
width: '48px',
render: (item) => {
const eligible = poolEligibility.get(item.id)?.eligible;
return <input aria-label={`选择${item.name}`} checked={selected.has(item.id)} disabled={!eligible} onChange={() => toggle(item.id)} type="checkbox" />;
const pendingColumns = useMemo<Array<TableColumn<ReportMaterialPendingItem>>>(
() => [
{
key: 'select',
title: '',
width: '48px',
render: (item) => {
const eligible = poolEligibility.get(item.id)?.eligible;
return (
<input
aria-label={`选择${item.name}`}
checked={selected.has(item.id)}
disabled={!eligible}
onChange={() => toggle(item.id)}
type="checkbox"
/>
);
},
},
},
{ key: 'name', title: '资料', render: (item) => <div><strong>{item.name}</strong><div className="muted">{item.reportType === 'signature' ? '签名资料' : `引流信息 · ${item.signatureName ?? '-'}`} · {item.detail || '-'}</div></div> },
{ key: 'tenant', title: '企业/应用', render: (item) => <div><strong>{item.tenant?.name ?? '-'}</strong><div className="muted">{item.application?.name ?? '未指定应用'}</div></div> },
{ key: 'eligibility', title: '版本/资格', render: (item) => {
const eligibility = poolEligibility.get(item.id);
const eligible = eligibility?.eligible;
return <div><Tag tone={eligible ? 'success' : 'warning'}>V{item.materialVersion} · {eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}</Tag>{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}</div>;
} },
{ key: 'changedAt', title: '资料变更时间', render: (item) => formatDateTime(item.changedAt) },
], [poolEligibility, selected]);
const batchColumns = useMemo<Array<TableColumn<ReportMaterialBatch>>>(() => [
{ key: 'batchNo', title: '报备批次号', render: (batch) => <strong>{batch.batchNo}</strong> },
{ key: 'time', title: '生成时间', render: (batch) => formatDateTime(batch.createdAt) },
{ key: 'reportTotal', title: '报备总数', render: (batch) => batch.reportTotal.toLocaleString('zh-CN') },
{ key: 'successCount', title: '成功数', render: (batch) => batch.successCount.toLocaleString('zh-CN') },
{ key: 'successRate', title: '成功率', render: (batch) => `${(batch.successRate * 100).toFixed(2)}%` },
{ key: 'channels', title: '通道/文件', render: (batch) => `${batch.channelCount}个通道 · ${batch.fileCount}份文件` },
{ key: 'status', title: '生成状态', render: (batch) => <Tag tone={batch.status === 'completed' ? 'success' : batch.status === 'failed' ? 'danger' : 'warning'}>{batchStatusLabels[batch.status] ?? batch.status}</Tag> },
{ key: 'files', title: '报备文件', align: 'right', render: (batch) => <div className="table-actions">{batch.exportFiles.map((file) => file.fileObjectId ? <a href={fileDownloadUrl(file.fileObjectId)} key={file.id}><Download size={15} />{file.fileName}{file.rowCount}</a> : null)}</div> },
], []);
const filter = <div className={`surface report-material-filter report-material-filter--${activeTab}`}>
{activeTab === 'pending' ? <Select label="资料类型" onChange={(event) => { setReportType(event.target.value); setPendingPage(1); }} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /> : null}
<Input label={activeTab === 'pending' ? '企业/应用/签名/站点' : '报备批次号'} onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'pending' ? '搜索待生成资料' : '搜索报备批次号'} value={keyword} />
<DateRangeInput label={activeTab === 'pending' ? '资料变更时间' : '批次生成时间'} onChange={setDateRange} value={dateRange} />
<div className="ui-query-actions">
<Button icon={<Search size={16} />} onClick={() => {
if (activeTab === 'pending') {
setPendingPage(1);
void loadPending(1);
} else {
setBatchPage(1);
void loadBatches(1);
}
}}></Button>
<Button onClick={() => {
setKeyword('');
setDateRange({});
if (activeTab === 'pending') {
setReportType('all');
setPendingPage(1);
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
} else {
setBatchPage(1);
void loadBatches(1, { keyword: '', dateRange: {} });
}
}} variant="ghost"></Button>
</div>
</div>;
return <section className="page-stack report-material-page">
<div className="surface page-heading">
<div><Breadcrumb items={['报备任务', '待生成报备批次']} /><h1></h1><p></p></div>
{activeTab === 'pending' ? <Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size}`}</Button> : null}
</div>
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
<Tabs
onChange={(value) => {
setActiveTab(value as 'pending' | 'batches');
setKeyword('');
setDateRange({});
}}
value={activeTab}
items={[
{
label: '待生成资料',
value: 'pending',
content: <div className="page-stack">{filter}<div className="surface"><label className="table-actions"><input checked={allSelected} onChange={() => setSelected(allSelected ? new Set() : new Set(eligibleItems.map((item) => item.id)))} type="checkbox" /></label><Table columns={pendingColumns} data={pendingData.items} emptyText="暂无符合条件的待生成资料" pagination={false} rowKey="id" /></div><Pagination nextDisabled={pendingPage * pageSize >= pendingData.total} onNext={() => setPendingPage((page) => page + 1)} onPageChange={setPendingPage} onPrevious={() => setPendingPage((page) => Math.max(1, page - 1))} page={pendingPage} previousDisabled={pendingPage <= 1} total={pendingData.total} totalPages={Math.max(1, Math.ceil(pendingData.total / pageSize))} /></div>,
{
key: 'name',
title: '资料',
render: (item) => (
<div>
<strong>{item.name}</strong>
<div className="muted">
{item.reportType === 'signature' ? '签名资料' : `引流信息 · ${item.signatureName ?? '-'}`} ·{' '}
{item.detail || '-'}
</div>
</div>
),
},
{
key: 'tenant',
title: '企业/应用',
render: (item) => (
<div>
<strong>{item.tenant?.name ?? '-'}</strong>
<div className="muted">{item.application?.name ?? '未指定应用'}</div>
</div>
),
},
{
key: 'eligibility',
title: '版本/资格',
render: (item) => {
const eligibility = poolEligibility.get(item.id);
const eligible = eligibility?.eligible;
return (
<div>
<Tag tone={eligible ? 'success' : 'warning'}>
V{item.materialVersion} ·{' '}
{eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}
</Tag>
{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}
</div>
);
},
{
label: '已生成批次',
value: 'batches',
content: <div className="page-stack">{filter}<div className="surface"><Table columns={batchColumns} data={batchData.items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" /></div><Pagination nextDisabled={batchPage * pageSize >= batchData.total} onNext={() => setBatchPage((page) => page + 1)} onPageChange={setBatchPage} onPrevious={() => setBatchPage((page) => Math.max(1, page - 1))} page={batchPage} previousDisabled={batchPage <= 1} total={batchData.total} totalPages={Math.max(1, Math.ceil(batchData.total / pageSize))} /></div>,
},
]}
/>
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}></Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost"></Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
<div className="report-batch-preflight">
{preflightBusy ? <p role="status"></p> : null}
{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} /> {preflight.eligibleTargetCount} </span><span><AlertTriangle size={17} /> {preflight.skippedTargetCount} </span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}><span>{target.name} · <CarrierTag carrier={target.carrier} /></span><small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join('')}</p>}</article>)}</> : null}
{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong> {batchResult.batchNo} </strong><span> {batchResult.result.successCount} · {batchResult.result.skippedCount} · {batchResult.result.failedCount}</span><span>{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
},
{
key: 'summary',
title: '通道明细汇总',
render: (item) =>
item.statusSummary ? (
<div className="report-material-summary">
<strong>{item.statusSummary.total} </strong>
<span>
{item.statusSummary.pending} · {item.statusSummary.reporting} · {' '}
{item.statusSummary.approved} · {item.statusSummary.failed} · {item.statusSummary.abandoned}
</span>
</div>
) : (
'-'
),
},
{ key: 'changedAt', title: '资料变更时间', render: (item) => formatDateTime(item.changedAt) },
{
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button
onClick={() => navigate(`/admin/report-tasks?signatureId=${encodeURIComponent(item.signatureId)}`)}
size="sm"
variant="ghost"
>
</Button>
),
},
],
[navigate, poolEligibility, selected],
);
const filter = (
<div className="surface report-material-filter report-material-filter--pending">
<Select
label="资料类型"
onChange={(event) => {
setReportType(event.target.value);
setPendingPage(1);
}}
options={[
{ label: '全部资料', value: 'all' },
{ label: '签名资料', value: 'signature' },
{ label: '引流信息', value: 'drainage' },
]}
value={reportType}
/>
<Input
label="企业/应用/签名/站点"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索报备资料池"
value={keyword}
/>
<DateRangeInput label="资料变更时间" onChange={setDateRange} value={dateRange} />
<div className="ui-query-actions">
<Button
icon={<Search size={16} />}
onClick={() => {
setPendingPage(1);
void loadPending(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
setPendingPage(1);
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
}}
variant="ghost"
>
</Button>
</div>
</Modal>
</section>;
</div>
);
return (
<section className="page-stack report-material-page">
<div className="surface page-heading">
<div>
<Breadcrumb items={['报备工作台', '报备资料池']} />
<h1></h1>
<p> × × </p>
</div>
<Button
disabled={busy || selected.size === 0}
icon={<Layers3 size={16} />}
onClick={() => void beginCreateBatch()}
>
{busy ? '生成中...' : `预检并生成(${selected.size}`}
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
<div className="page-stack">
{filter}
<div className="surface">
<label className="table-actions">
<input
checked={allSelected}
onChange={() => setSelected(allSelected ? new Set() : new Set(eligibleItems.map((item) => item.id)))}
type="checkbox"
/>
</label>
<Table
columns={pendingColumns}
data={pendingData.items}
emptyText="暂无符合条件的报备资料"
pagination={false}
rowKey="id"
/>
</div>
<Pagination
nextDisabled={pendingPage * pageSize >= pendingData.total}
onNext={() => setPendingPage((page) => page + 1)}
onPageChange={setPendingPage}
onPrevious={() => setPendingPage((page) => Math.max(1, page - 1))}
page={pendingPage}
previousDisabled={pendingPage <= 1}
total={pendingData.total}
totalPages={Math.max(1, Math.ceil(pendingData.total / pageSize))}
/>
</div>
<Modal
footer={
batchResult ? (
<Button onClick={() => setConfirmOpen(false)}></Button>
) : (
<>
<Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost">
</Button>
<Button
disabled={preflightBusy || busy || !preflight?.eligible}
icon={<ShieldCheck size={16} />}
onClick={() => void createBatch()}
>
{busy ? '生成处理中…' : '确认生成'}
</Button>
</>
)
}
onClose={() => {
if (!busy) setConfirmOpen(false);
}}
open={confirmOpen}
size="xl"
title="报备生成资格预检"
>
<div className="report-batch-preflight">
{preflightBusy ? <p role="status"></p> : null}
{preflight ? (
<>
<div className="report-batch-summary">
<span>
<CheckCircle2 size={17} />
{preflight.eligibleTargetCount}
</span>
<span>
<AlertTriangle size={17} />
{preflight.skippedTargetCount}
</span>
</div>
{preflight.items.map((item) => (
<article key={item.id}>
<div>
<strong>{item.name}</strong>
<small>
{item.tenantName} · {item.applicationName} · V{item.materialVersion}
</small>
</div>
{item.targets.length ? (
<ul>
{item.targets.map((target) => (
<li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}>
<span>
{target.name} · <CarrierTag carrier={target.carrier} />
</span>
<small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small>
</li>
))}
</ul>
) : (
<p className="form-error">{item.blockedReasons.join('')}</p>
)}
</article>
))}
</>
) : null}
{batchResult ? (
<div className="risk-action-result" role="status">
<ShieldCheck size={20} />
<div>
<strong> {batchResult.batchNo} </strong>
<span>
{batchResult.result.successCount} · {batchResult.result.skippedCount} · {' '}
{batchResult.result.failedCount}
</span>
<span>
{batchResult.operationId}
{batchResult.replayed ? '(幂等重放)' : ''}
</span>
</div>
</div>
) : null}
</div>
</Modal>
</section>
);
}
function toBatchItem(item: ReportMaterialPendingItem) {
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion };
return {
reportType: item.reportType,
signatureId: item.signatureId,
drainageItemId: item.drainageItemId ?? undefined,
materialVersion: item.materialVersion,
};
}
+260 -36
View File
@@ -1,7 +1,19 @@
import { useEffect, useState } from 'react';
import { Clock3, Eye, Search } from 'lucide-react';
import { adminApi, type ReportRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import {
Breadcrumb,
Button,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'neutral',
@@ -17,15 +29,40 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
};
const statusLabel: Record<string, string> = {
pending: '待报备', waiting_material: '待补充资料', waiting_review: '待重新审核', reporting: '报备中', exporting: '导出中', partial: '部分通过', approved: '已通过', success: '成功', completed: '已完成', failed: '失败', rejected: '已驳回', abandoned: '已废弃', imported: '已导入', deleted: '已删除',
pending: '待报备',
waiting_material: '待补充资料',
waiting_review: '待重新审核',
reporting: '报备中',
exporting: '导出中',
partial: '部分通过',
approved: '已通过',
success: '成功',
completed: '已完成',
failed: '失败',
rejected: '已驳回',
abandoned: '已废弃',
imported: '已导入',
deleted: '已删除',
};
const actionLabel: Record<string, string> = {
create: '创建报备任务', manual_status_change: '人工修改状态', export: '导出报备资料', receipt_import: '导入回执', audit_approved_create: '引流审核通过后创建', audit_approved_reset: '引流审核通过后重置', audit_resubmit_freeze: '引流修改后冻结', audit_rejected_freeze: '引流审核驳回后冻结', drainage_deleted: '引流信息删除',
create: '创建报备任务',
manual_status_change: '人工修改状态',
export: '导出报备资料',
receipt_import: '导入回执',
audit_approved_create: '引流审核通过后创建',
audit_approved_reset: '引流审核通过后重置',
audit_resubmit_freeze: '引流修改后冻结',
audit_rejected_freeze: '引流审核驳回后冻结',
drainage_deleted: '引流信息删除',
};
const sourceEntryLabel: Record<string, string> = {
enterprise_signature: '企业签名修改', report_task: '报备任务修改', channel_report: '通道信息修改', system: '系统自动处理', legacy: '历史记录(入口未记录)',
enterprise_signature: '企业签名修改',
report_task: '报备任务修改',
channel_report: '通道信息修改',
system: '系统自动处理',
legacy: '历史记录(入口未记录)',
};
function translateStatus(value?: string | null) {
@@ -42,22 +79,67 @@ function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose:
const isDrainage = record.task?.reportType === 'drainage';
const target = isDrainage ? record.task?.drainageInfo?.url : record.task?.signature?.name;
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}>
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={
<div className="template-modal-title">
<h2></h2>
<p>{record.id}</p>
</div>
}
>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.taskId}</strong></div>
<div><span></span><strong>{record.channel?.name ?? '-'}</strong></div>
<div><span></span><strong>{isDrainage ? '引流信息' : '签名'}</strong></div>
<div><span></span><strong>{target ?? '-'}</strong></div>
<div><span></span><strong>{actionLabel[record.action] ?? record.action}</strong></div>
<div><span></span><strong>{recordSource(record)}</strong></div>
<div><span></span><strong>{translateStatus(record.statusBefore)}</strong></div>
<div><span></span><strong>{translateStatus(record.statusAfter)}</strong></div>
<div className="detail-grid__wide"><span>/</span><strong>{record.reason ?? '-'}</strong></div>
<div>
<span></span>
<strong>{record.taskId}</strong>
</div>
<div>
<span></span>
<strong>{record.channel?.name ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{isDrainage ? '引流信息' : '签名'}</strong>
</div>
<div>
<span></span>
<strong>{target ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{actionLabel[record.action] ?? record.action}</strong>
</div>
<div>
<span></span>
<strong>{recordSource(record)}</strong>
</div>
<div>
<span></span>
<strong>{translateStatus(record.statusBefore)}</strong>
</div>
<div>
<span></span>
<strong>{translateStatus(record.statusAfter)}</strong>
</div>
<div className="detail-grid__wide">
<span>/</span>
<strong>{record.reason ?? '-'}</strong>
</div>
</div>
<section className="report-history">
<h3><Clock3 size={17} /></h3>
<div><span>{record.createdAt ?? '-'}</span><strong>{actionLabel[record.action] ?? record.action}</strong><em>{record.reason ?? `修改入口:${recordSource(record)}`}</em></div>
<h3>
<Clock3 size={17} />
</h3>
<div>
<span>{record.createdAt ?? '-'}</span>
<strong>{actionLabel[record.action] ?? record.action}</strong>
<em>{record.reason ?? `修改入口:${recordSource(record)}`}</em>
</div>
</section>
</div>
</Modal>
@@ -69,6 +151,10 @@ export function AdminReportRecordsPage() {
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [batchNo, setBatchNo] = useState('');
const [operatorKeyword, setOperatorKeyword] = useState('');
const [statusAfter, setStatusAfter] = useState('all');
const [sourceEntry, setSourceEntry] = useState('all');
const [detail, setDetail] = useState<ReportRecord | null>(null);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
@@ -76,14 +162,19 @@ export function AdminReportRecordsPage() {
const pageSize = 10;
function loadData(targetPage = page) {
adminApi.listReportRecordsPage({
keyword: keyword || undefined,
reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage',
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
adminApi
.listReportRecordsPage({
keyword: keyword || undefined,
reportType: reportType === 'all' ? undefined : (reportType as 'signature' | 'drainage'),
batchNo: batchNo.trim() || undefined,
operatorKeyword: operatorKeyword.trim() || undefined,
statusAfter: statusAfter === 'all' ? undefined : statusAfter,
sourceEntry: sourceEntry === 'all' ? undefined : sourceEntry,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
.then((result) => {
setRecords(result.items);
setTotal(result.total);
@@ -97,35 +188,168 @@ export function AdminReportRecordsPage() {
}, [page]);
const columns: Array<TableColumn<ReportRecord>> = [
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
{
key: 'task',
title: '报备任务号',
width: '190px',
render: (record) => <strong className="admin-task-id">{record.taskId}</strong>,
},
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> },
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
{
key: 'targetType',
title: '变更主体',
width: '110px',
render: (record) => (
<Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>
{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}
</Tag>
),
},
{
key: 'target',
title: '主体内容',
width: '260px',
render: (record) =>
record.task?.reportType === 'drainage' ? (
<div className="admin-task-enterprise">
<strong>{record.task?.signature?.name ?? '-'}</strong>
<span>{record.task?.drainageInfo?.url ?? '-'}</span>
{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}
</div>
) : (
<div className="admin-task-enterprise">
<strong>{record.task?.signature?.name ?? '-'}</strong>
{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}
</div>
),
},
{ key: 'source', title: '修改入口', width: '150px', render: (record) => recordSource(record) },
{
key: 'operator',
title: '操作人',
width: '140px',
render: (record) => record.operator?.displayName ?? record.operator?.username ?? '系统',
},
{ key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action },
{ key: 'status', title: '状态变化', width: '210px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag> },
{
key: 'status',
title: '状态变化',
width: '210px',
render: (record) => (
<Tag
tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}
>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag>
),
},
{ key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '备注', width: '320px', render: (record) => <span className="ui-table__long-text">{record.reason ?? '-'}</span> },
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
{
key: 'reason',
title: '备注',
width: '320px',
render: (record) => <span className="ui-table__long-text">{record.reason ?? '-'}</span>,
},
{
key: 'actions',
title: '操作',
align: 'right',
width: '120px',
render: (record) => (
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">
</Button>
),
},
];
return (
<section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备任务', '报备记录']} />
<h1></h1>
<Breadcrumb items={['报备工作台', '状态记录']} />
<h1></h1>
</div>
</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} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<Input
label="报备任务号/通道/动作/备注"
onChange={(event) => setKeyword(event.target.value)}
placeholder="请输入报备任务号、通道、动作或备注"
value={keyword}
/>
<Select
label="报备类型"
onChange={(event) => setReportType(event.target.value)}
options={[
{ label: '全部类型', value: 'all' },
{ label: '签名报备', value: 'signature' },
{ label: '引流信息报备', value: 'drainage' },
]}
value={reportType}
/>
<Input
label="报备批次号"
onChange={(event) => setBatchNo(event.target.value)}
placeholder="输入批次号"
value={batchNo}
/>
<Input
label="操作人"
onChange={(event) => setOperatorKeyword(event.target.value)}
placeholder="姓名或账号"
value={operatorKeyword}
/>
<Select
label="变更后状态"
onChange={(event) => setStatusAfter(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
{ label: '未报备', value: 'pending' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '已放弃', value: 'abandoned' },
]}
value={statusAfter}
/>
<Select
label="修改入口"
onChange={(event) => setSourceEntry(event.target.value)}
options={[
{ label: '全部入口', value: 'all' },
{ label: '企业签名修改', value: 'enterprise_signature' },
{ label: '通道报备明细', value: 'report_task' },
{ label: '通道详情', value: 'channel_report' },
{ label: '系统自动处理', value: 'system' },
]}
value={sourceEntry}
/>
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost"></Button>
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
setBatchNo('');
setOperatorKeyword('');
setStatusAfter('all');
setSourceEntry('all');
}}
variant="ghost"
>
</Button>
</div>
</div>
+428 -93
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi';
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 { formatDateTime } from '@/utils/dateTime';
@@ -31,45 +32,122 @@ function taskTargetLabel(task: ReportTask) {
function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) {
const status = statusMeta[task.status] ?? { label: task.status, tone: 'info' as const };
const source = task.exportItems?.[0];
return <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="报备明细详情">
<div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{taskTargetLabel(task)}</strong></div>
<div><span></span><strong>{task.reportType === 'drainage' ? '引流信息' : '签名'}</strong></div>
<div><span></span><strong>{task.signature?.tenant?.name ?? task.tenantId}</strong></div>
<div><span></span><strong>{task.signature?.application?.name ?? '未指定应用'}</strong></div>
<div><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div>
{task.reportType !== 'drainage' ? <div><span></span>{task.carrier ? <CarrierTag carrier={task.carrier} /> : <strong></strong>}</div> : null}
{task.reportType !== 'drainage' ? <div><span></span><strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong></div> : null}
<div><span></span><Tag tone={status.tone}>{status.label}</Tag></div>
<div><span></span><strong>{formatDateTime(task.createdAt)}</strong></div>
<div><span></span><strong>{formatDateTime(task.updatedAt)}</strong></div>
<div><span></span><strong>{source ? `V${source.batchItem.materialVersion}` : '-'}</strong></div>
<div><span></span><strong>{source?.batchItem.batch.batchNo ?? '-'}</strong></div>
<div><span></span><strong>{source ? `${source.rowNumber}` : '-'}</strong></div>
<div><span></span><strong>{task.reason || '-'}</strong></div>
</div>
{source?.exportFile.fileObjectId ? <div className="surface" style={{ padding: 16 }}><a href={fileDownloadUrl(source.exportFile.fileObjectId)}>{source.exportFile.fileName}</a></div> : null}
<div className="surface" style={{ padding: 16 }}>
<h3></h3>
<div className="page-stack" style={{ marginTop: 12 }}>
{(task.records ?? []).length ? task.records!.map((record) => <div className="detail-grid" key={record.id}>
<div><span></span><strong>{formatDateTime(record.createdAt)}</strong></div>
<div><span></span><strong>{actionLabels[record.action] ?? record.action}</strong></div>
<div><span></span><strong>{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {statusMeta[record.statusAfter]?.label ?? record.statusAfter}</strong></div>
<div><span></span><strong>{record.reason || '-'}</strong></div>
</div>) : <p className="muted"></p>}
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="报备明细详情">
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{taskTargetLabel(task)}</strong>
</div>
<div>
<span></span>
<strong>{task.reportType === 'drainage' ? '引流信息' : '签名'}</strong>
</div>
<div>
<span></span>
<strong>{task.signature?.tenant?.name ?? task.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{task.signature?.application?.name ?? '未指定应用'}</strong>
</div>
<div>
<span></span>
<strong>{task.channel?.name ?? task.channelId}</strong>
</div>
{task.reportType !== 'drainage' ? (
<div>
<span></span>
{task.carrier ? <CarrierTag carrier={task.carrier} /> : <strong></strong>}
</div>
) : null}
{task.reportType !== 'drainage' ? (
<div>
<span></span>
<strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong>
</div>
) : null}
<div>
<span></span>
<Tag tone={status.tone}>{status.label}</Tag>
</div>
<div>
<span></span>
<strong>{formatDateTime(task.createdAt)}</strong>
</div>
<div>
<span></span>
<strong>{formatDateTime(task.updatedAt)}</strong>
</div>
<div>
<span></span>
<strong>{source ? `V${source.batchItem.materialVersion}` : '-'}</strong>
</div>
<div>
<span></span>
<strong>{source?.batchItem.batch.batchNo ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{source ? `${source.rowNumber}` : '-'}</strong>
</div>
<div>
<span></span>
<strong>{task.reason || '-'}</strong>
</div>
</div>
{source?.exportFile.fileObjectId ? (
<div className="surface" style={{ padding: 16 }}>
<a href={fileDownloadUrl(source.exportFile.fileObjectId)}>{source.exportFile.fileName}</a>
</div>
) : null}
<div className="surface" style={{ padding: 16 }}>
<h3></h3>
<div className="page-stack" style={{ marginTop: 12 }}>
{(task.records ?? []).length ? (
task.records!.map((record) => (
<div className="detail-grid" key={record.id}>
<div>
<span></span>
<strong>{formatDateTime(record.createdAt)}</strong>
</div>
<div>
<span></span>
<strong>{actionLabels[record.action] ?? record.action}</strong>
</div>
<div>
<span></span>
<strong>
{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {statusMeta[record.statusAfter]?.label ?? record.statusAfter}
</strong>
</div>
<div>
<span></span>
<strong>{record.reason || '-'}</strong>
</div>
</div>
))
) : (
<p className="muted"></p>
)}
</div>
</div>
</div>
</div>
</Modal>;
</Modal>
);
}
export function AdminReportTasksPage() {
const [searchParams] = useSearchParams();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [status, setStatus] = useState(searchParams.get('scope') === 'pending' ? 'pending' : 'all');
const [carrier, setCarrier] = useState('all');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [material, setMaterial] = useState<SingleReportMaterialDetail | null>(null);
const [detailTask, setDetailTask] = useState<ReportTask | null>(null);
const [statusTask, setStatusTask] = useState<ReportTask | null>(null);
const [nextStatus, setNextStatus] = useState('approved');
@@ -81,14 +159,18 @@ export function AdminReportTasksPage() {
const pageSize = 10;
function loadData(targetPage = page) {
adminApi.listReportTasksPage({
reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage',
keyword: keyword || undefined,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
adminApi
.listReportDetailsPage({
signatureId: searchParams.get('signatureId') || undefined,
reportType: reportType === 'all' ? undefined : (reportType as 'signature' | 'drainage'),
status: status === 'all' ? undefined : status,
carrier: carrier === 'all' ? undefined : carrier,
keyword: keyword || undefined,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
.then((result) => {
setTasks(result.items);
setTotal(result.total);
@@ -103,22 +185,24 @@ export function AdminReportTasksPage() {
async function saveTaskStatus() {
if (!statusTask) return;
const chosen = selected.size ? tasks.filter((task) => selected.has(task.id)) : [statusTask];
setBusy(true);
try {
await adminApi.changeReportTaskStatuses({
items: [{
signatureId: statusTask.signatureId,
channelId: statusTask.channelId,
carrier: statusTask.carrier ?? undefined,
reportType: statusTask.reportType,
drainageItemId: statusTask.drainageItemId ?? undefined,
items: chosen.map((task) => ({
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
reportType: task.reportType,
drainageItemId: task.drainageItemId ?? undefined,
status: nextStatus,
}],
})),
reason: statusReason.trim() || undefined,
sourceEntry: 'report_task',
});
setStatusTask(null);
setStatusReason('');
setSelected(new Set());
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
@@ -127,56 +211,307 @@ export function AdminReportTasksPage() {
}
}
async function openMaterial(task: ReportTask) {
try {
setMaterial(
await adminApi.getSingleReportMaterialDetail({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
}),
);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备资料加载失败');
}
}
async function exportMaterial(task: ReportTask) {
try {
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
}
}
const columns: Array<TableColumn<ReportTask>> = [
{ key: 'target', title: '报备对象', render: (record) => <div><strong>{taskTargetLabel(record)}</strong><div className="muted">{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}</div></div> },
{
key: 'select',
title: '',
width: '44px',
render: (record) => (
<input
aria-label={`选择${taskTargetLabel(record)}`}
checked={selected.has(record.id)}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(record.id)) next.delete(record.id);
else next.add(record.id);
return next;
})
}
type="checkbox"
/>
),
},
{
key: 'target',
title: '报备对象',
render: (record) => (
<div>
<strong>{taskTargetLabel(record)}</strong>
<div className="muted">
{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}
</div>
</div>
),
},
{ key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' },
{ key: 'channel', title: '通道/运营商', render: (record) => <div><strong>{record.channel?.name ?? record.channelId}</strong>{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}</div> },
{ key: 'batch', title: '批次/版本', render: (record) => {
const source = record.exportItems?.[0];
return source ? <div><strong>{source.batchItem.batch.batchNo}</strong><div className="muted">V{source.batchItem.materialVersion} · {source.rowNumber}</div></div> : '-';
} },
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{
key: 'channel',
title: '通道/运营商',
render: (record) => (
<div>
<strong>{record.channel?.name ?? record.channelId}</strong>
{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}
</div>
),
},
{
key: 'batch',
title: '批次/版本',
render: (record) => {
const source = record.exportItems?.[0];
return source ? (
<div>
<strong>{source.batchItem.batch.batchNo}</strong>
<div className="muted">
V{source.batchItem.materialVersion} · {source.rowNumber}
</div>
</div>
) : (
'-'
);
},
},
{
key: 'status',
title: '状态',
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) },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost"></Button><Button onClick={() => {
setStatusTask(record);
setNextStatus(record.status);
setStatusReason('');
}} size="sm" variant="ghost"></Button></div> },
{
key: 'actions',
title: '操作',
align: 'right',
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={14} />} onClick={() => void openMaterial(record)} size="sm" variant="ghost">
</Button>
{record.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportMaterial(record)} size="sm" variant="ghost">
</Button>
) : null}
<Button
onClick={() => {
setSelected(new Set());
setStatusTask(record);
setNextStatus(record.status);
setStatusReason('');
}}
size="sm"
variant="ghost"
>
</Button>
</div>
),
},
];
return <section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading"><div><Breadcrumb items={['报备任务', '报备明细']} /><h1></h1><p></p></div></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} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button><Button onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
}} variant="ghost"></Button></div>
</div>
<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))} />
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
<Modal footer={<><Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost"></Button><Button disabled={busy} onClick={() => void saveTaskStatus()}>{busy ? '保存中…' : '保存'}</Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="修改报备状态">
{statusTask ? <div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{taskTargetLabel(statusTask)}</strong></div>
<div><span></span><strong>{statusTask.channel?.name ?? statusTask.channelId}</strong></div>
<div><span></span><strong>{statusMeta[statusTask.status]?.label ?? statusTask.status}</strong></div>
return (
<section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备工作台', '通道报备明细']} />
<h1></h1>
<p> × / × × </p>
</div>
<Select label="修改为" onChange={(event) => setNextStatus(event.target.value)} options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]} value={nextStatus} />
<Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
</div> : null}
</Modal>
</section>;
<Button
disabled={!selected.size}
onClick={() => {
const first = tasks.find((task) => selected.has(task.id));
if (first) {
setStatusTask(first);
setNextStatus('reporting');
}
}}
>
{selected.size}
</Button>
</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} />
<Select
label="报备类型"
onChange={(event) => setReportType(event.target.value)}
options={[
{ label: '全部类型', value: 'all' },
{ label: '签名报备', value: 'signature' },
{ label: '引流信息报备', value: 'drainage' },
]}
value={reportType}
/>
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
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} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
setCarrier('all');
setStatus('all');
}}
variant="ghost"
>
</Button>
</div>
</div>
<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))} />
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
{material ? (
<Modal footer={<Button onClick={() => setMaterial(null)}></Button>} onClose={() => setMaterial(null)} open size="xl" title="查看报备资料">
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{material.signatureName}</strong>
</div>
<div>
<span>/</span>
<strong>
{material.tenant.name} · {material.application?.name ?? '-'}
</strong>
</div>
<div>
<span>/</span>
<strong>
{material.channel.name} · V{material.materialVersion}
</strong>
</div>
</div>
<div className="report-material-detail-list">
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.exportName || field.name}
{field.required ? ' *' : ''}
</span>
<strong>{typeof field.value === 'object' ? String((field.value as Record<string, unknown>)?.fileName ?? '-') : String(field.value ?? '-')}</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>{field.name}</span>
<strong>{String(field.value ?? '-')}</strong>
</div>
))}
</div>
</div>
</Modal>
) : null}
<Modal
footer={
<>
<Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost">
</Button>
<Button disabled={busy} onClick={() => void saveTaskStatus()}>
{busy ? '保存中…' : '保存'}
</Button>
</>
}
onClose={() => setStatusTask(null)}
open={Boolean(statusTask)}
title="修改报备状态"
>
{statusTask ? (
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{selected.size ? `已选择 ${selected.size} 条明细` : taskTargetLabel(statusTask)}</strong>
</div>
<div>
<span></span>
<strong>{statusTask.channel?.name ?? statusTask.channelId}</strong>
</div>
<div>
<span></span>
<strong>{statusMeta[statusTask.status]?.label ?? statusTask.status}</strong>
</div>
</div>
<Select
label="修改为"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
</div>
) : null}
</Modal>
</section>
);
}
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { ClientSmsSignature } from '@/api/adminApi';
import { EnterpriseSignaturesTable } from './EnterpriseSignaturesTable';
@@ -12,6 +13,7 @@ const signature = {
auditStatus: 'approved',
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T01:00:00.000Z',
pendingReportDetailCount: 4,
tenant: { id: 'tenant-1', name: '深圳市聆界科技有限公司', status: 'active' },
application: { id: 'app-1', tenantId: 'tenant-1', name: '营销通知应用', status: 'active' },
carrierReportSummary: {
@@ -19,7 +21,9 @@ const signature = {
unicom: { status: 'reporting', approved: 2, total: 3 },
telecom: { status: 'abandoned', approved: 0, total: 3 },
},
drainageInfo: { links: [{ id: 'drainage-1', siteName: 'www.lisglo.com', url: 'www.lisglo.com', auditStatus: 'approved' }] },
drainageInfo: {
links: [{ id: 'drainage-1', siteName: 'www.lisglo.com', url: 'www.lisglo.com', auditStatus: 'approved' }],
},
drainageCarrierReportSummary: {
'drainage-1': {
mobile: { status: 'approved', approved: 3, total: 3 },
@@ -31,14 +35,33 @@ const signature = {
function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTable>[0]> = {}) {
const props: Parameters<typeof EnterpriseSignaturesTable>[0] = {
appliedDrainageKeyword: '', currentPage: 1, expandedSignatureId: signature.id,
filteredSignatures: [signature], visibleSignatures: [signature], total: 1, totalPages: 1,
loadData: vi.fn().mockResolvedValue(undefined), setDeleteTarget: vi.fn(), setDrainageModal: vi.fn(),
setDrainageStatusTarget: vi.fn(), setExpandedSignatureId: vi.fn(), setPage: vi.fn(),
setReportStatusTarget: vi.fn(), setSignatureModal: vi.fn(), setSignatureSort: vi.fn(), signatureSort: 'asc',
appliedDrainageKeyword: '',
currentPage: 1,
expandedSignatureId: signature.id,
filteredSignatures: [signature],
visibleSignatures: [signature],
total: 1,
totalPages: 1,
loadData: vi.fn().mockResolvedValue(undefined),
setDeleteTarget: vi.fn(),
setDrainageModal: vi.fn(),
setDrainageStatusTarget: vi.fn(),
setExpandedSignatureId: vi.fn(),
setPage: vi.fn(),
setReportStatusTarget: vi.fn(),
setSignatureModal: vi.fn(),
setSignatureSort: vi.fn(),
signatureSort: 'asc',
...overrides,
};
return { ...render(<EnterpriseSignaturesTable {...props} />), props };
return {
...render(
<MemoryRouter>
<EnterpriseSignaturesTable {...props} />
</MemoryRouter>,
),
props,
};
}
describe('EnterpriseSignaturesTable dense presentation', () => {
@@ -56,6 +79,7 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
expect(screen.getAllByRole('button', { name: '报备状态' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
expect(screen.getByRole('button', { name: '4 条' })).toBeVisible();
});
it('requests a real descending sort from the signature column control', async () => {
@@ -64,7 +88,11 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
await userEvent.click(screen.getByRole('button', { name: '签名降序' }));
expect(setSignatureSort).toHaveBeenCalledWith('desc');
expect(screen.getByRole('button', { name: '签名升序' }).querySelector('.lucide-triangle')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '签名降序' }).querySelector('.enterprise-signature-table__sort-triangle--down')).toBeInTheDocument();
expect(
screen
.getByRole('button', { name: '签名降序' })
.querySelector('.enterprise-signature-table__sort-triangle--down'),
).toBeInTheDocument();
expect(document.querySelector('.lucide-arrow-up, .lucide-arrow-down')).not.toBeInTheDocument();
});
});
@@ -1,8 +1,15 @@
import type { Dispatch, SetStateAction } from 'react';
import { ChevronDown, ChevronRight, Edit3, Plus, Triangle } from 'lucide-react';
import type { ClientSmsSignature } from '@/api/adminApi';
import { useNavigate } from 'react-router-dom';
import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
import { AuditStatusTag, CarrierReportCount, formatSignatureName, readDrainagePayload, signatureCardVisual } from './signature.helpers';
import {
AuditStatusTag,
CarrierReportCount,
formatSignatureName,
readDrainagePayload,
signatureCardVisual,
} from './signature.helpers';
import type { DrainageInfo } from './signature.types';
type EnterpriseSignaturesTableProps = {
@@ -44,6 +51,7 @@ export function EnterpriseSignaturesTable({
totalPages,
visibleSignatures,
}: EnterpriseSignaturesTableProps) {
const navigate = useNavigate();
return (
<div className="signature-list admin-enterprise-signature-list">
<div className="enterprise-signature-table__head" role="row">
@@ -51,8 +59,33 @@ export function EnterpriseSignaturesTable({
<span className="enterprise-signature-table__sortable">
<span className="enterprise-signature-table__sort-actions">
<Button aria-pressed={signatureSort === 'asc'} icon={<Triangle aria-hidden="true" fill="currentColor" size={11} />} iconOnly onClick={() => setSignatureSort('asc')} size="sm" variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}></Button>
<Button aria-pressed={signatureSort === 'desc'} icon={<Triangle aria-hidden="true" className="enterprise-signature-table__sort-triangle--down" fill="currentColor" size={11} />} iconOnly onClick={() => setSignatureSort('desc')} size="sm" variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}></Button>
<Button
aria-pressed={signatureSort === 'asc'}
icon={<Triangle aria-hidden="true" fill="currentColor" size={11} />}
iconOnly
onClick={() => setSignatureSort('asc')}
size="sm"
variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}
>
</Button>
<Button
aria-pressed={signatureSort === 'desc'}
icon={
<Triangle
aria-hidden="true"
className="enterprise-signature-table__sort-triangle--down"
fill="currentColor"
size={11}
/>
}
iconOnly
onClick={() => setSignatureSort('desc')}
size="sm"
variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}
>
</Button>
</span>
</span>
<span></span>
@@ -62,33 +95,92 @@ export function EnterpriseSignaturesTable({
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature);
const visibleDrainageLinks = appliedDrainageKeyword
? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword))
? payload.links.filter((item) =>
`${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword),
)
: payload.links;
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
return (
<article aria-label={`签名总体状态:${cardVisual.label}`} className={`signature-card signature-card--${cardVisual.tone}`} key={signature.id} title={`总体状态:${cardVisual.label}`}>
<article
aria-label={`签名总体状态:${cardVisual.label}`}
className={`signature-card signature-card--${cardVisual.tone}`}
key={signature.id}
title={`总体状态:${cardVisual.label}`}
>
<div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
<button
aria-label="展开签名"
onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)}
type="button"
>
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div className="enterprise-signature-table__signature" data-label="签名"><strong>{formatSignatureName(signature.name)}</strong></div>
<div data-label="企业"><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div>
<div data-label="应用"><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div>
<div data-label="审核状态"><AuditStatusTag status={signature.auditStatus} /></div>
<div data-label="移动"><CarrierReportCount summary={signature.carrierReportSummary?.mobile} /></div>
<div data-label="联通"><CarrierReportCount summary={signature.carrierReportSummary?.unicom} /></div>
<div data-label="电信"><CarrierReportCount summary={signature.carrierReportSummary?.telecom} /></div>
<div data-label="引流信息"><strong>{payload.links.length} </strong></div>
<div className="enterprise-signature-table__signature" data-label="签名">
<strong>{formatSignatureName(signature.name)}</strong>
</div>
<div data-label="企业">
<span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span>
</div>
<div data-label="应用">
<span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span>
</div>
<div data-label="审核状态">
<AuditStatusTag status={signature.auditStatus} />
</div>
<div data-label="移动">
<CarrierReportCount summary={signature.carrierReportSummary?.mobile} />
</div>
<div data-label="联通">
<CarrierReportCount summary={signature.carrierReportSummary?.unicom} />
</div>
<div data-label="电信">
<CarrierReportCount summary={signature.carrierReportSummary?.telecom} />
</div>
<div data-label="引流信息">
<strong>{payload.links.length} </strong>
</div>
<div data-label="待生成明细">
<Button
disabled={!signature.pendingReportDetailCount}
onClick={() =>
navigate(`/admin/report-tasks?signatureId=${encodeURIComponent(signature.id)}&scope=pending`)
}
size="sm"
variant="ghost"
>
{signature.pendingReportDetailCount ?? 0}
</Button>
</div>
<div className="signature-actions">
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
<Button
icon={<Edit3 size={16} />}
onClick={() => setReportStatusTarget(signature)}
size="sm"
variant="ghost"
>
</Button>
<Button
icon={<Edit3 size={16} />}
onClick={() => setSignatureModal(signature)}
size="sm"
variant="ghost"
>
</Button>
<DeleteRiskAction
onCompleted={() => void loadData()}
portal="admin"
targetId={signature.id}
targetType="signature"
/>
</div>
</div>
{expanded ? (
@@ -107,25 +199,61 @@ export function EnterpriseSignaturesTable({
{visibleDrainageLinks.map((item) => {
const summary = signature.drainageCarrierReportSummary?.[item.id];
return (
<div className="drainage-table__row" key={item.id}>
<span className="drainage-table__url" title={item.url}>{item.url}</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportCount summary={summary?.mobile} />
<CarrierReportCount summary={summary?.unicom} />
<CarrierReportCount summary={summary?.telecom} />
<span className="drainage-row-actions">
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.url })} size="sm" variant="danger"></Button>
</span>
</div>
);})}
<div className="drainage-table__row" key={item.id}>
<span className="drainage-table__url" title={item.url}>
{item.url}
</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportCount summary={summary?.mobile} />
<CarrierReportCount summary={summary?.unicom} />
<CarrierReportCount summary={summary?.telecom} />
<span className="drainage-row-actions">
<Button
disabled={item.auditStatus !== 'approved'}
onClick={() => setDrainageStatusTarget({ signature, item })}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() => setDrainageModal({ signatureId: signature.id, item })}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() =>
setDeleteTarget({
kind: 'drainage',
signatureId: signature.id,
id: item.id,
name: item.url,
})
}
size="sm"
variant="danger"
>
</Button>
</span>
</div>
);
})}
</div>
) : (
<p className="muted"></p>
)}
<div className="drainage-panel__footer">
<Button icon={<Plus size={16} />} onClick={() => setDrainageModal({ signatureId: signature.id })} size="sm" variant="ghost"></Button>
<Button
icon={<Plus size={16} />}
onClick={() => setDrainageModal({ signatureId: signature.id })}
size="sm"
variant="ghost"
>
</Button>
</div>
</div>
) : null}