feat: build reporting workbench workflow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user