feat: unify signature channel reporting status

This commit is contained in:
hectorzhao
2026-07-12 20:38:20 +08:00
parent 3f3ff8a793
commit eab059583f
16 changed files with 439 additions and 110 deletions
+166 -77
View File
@@ -1,112 +1,201 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField, type DictionaryItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
import { ArrowLeft, ChevronDown, ChevronRight, Eye, FileSliders, Plus, 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, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
type ReportType = 'signature' | 'drainage' | 'both';
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' },
success: { label: '报备成功', tone: 'success' },
failed: { label: '报备失败', tone: 'danger' },
rejected: { label: '报备失败', tone: 'danger' },
pending: { label: '未报备', tone: 'neutral' },
waiting_material: { label: '资料待补充', tone: 'warning' },
exporting: { label: '报备中', tone: 'warning' },
partial_success: { label: '部分成功', tone: 'warning' },
filing: { label: '报备中', tone: 'warning' },
};
const fieldTypeLabel: Record<string, string> = { string: '字符串', image: '图片', file: '文件' };
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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') : [];
}
function DateTime({ value }: { value?: unknown }) {
return value ? <span className="channel-report-date">{formatDateTime(String(value))}</span> : <span className="muted">-</span>;
}
function ReportStatus({ value }: { value?: string }) {
const meta = statusMeta[value ?? ''] ?? { label: value || '未报备', tone: 'neutral' as const };
return <Tag tone={meta.tone}>{meta.label}</Tag>;
}
function DetailModal({ drainage, signature, task, onClose }: { drainage?: DrainageItem; 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 ? '查看引流信息详情' : '查看签名详情'}>
<div className="channel-report-detail">
<strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : `${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>
{!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></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>)}
</div>
</Modal>
);
}
export function AdminChannelReportPage() {
const navigate = useNavigate();
const [channels, setChannels] = useState<AdminChannel[]>([]);
const { channelId = '' } = useParams();
const [channel, setChannel] = useState<AdminChannel>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [records, setRecords] = useState<ReportRecord[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]);
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [channelId, setChannelId] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [keyword, setKeyword] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
const [statusTask, setStatusTask] = useState<ReportTask>();
const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState('');
const [configType, setConfigType] = useState<ReportType>();
const [drainageFieldId, setDrainageFieldId] = useState('');
const [reportType, setReportType] = useState<'signature' | 'drainage' | 'both'>('signature');
const [required, setRequired] = useState(false);
const [description, setDescription] = useState('');
const [error, setError] = useState('');
function loadData(nextChannelId = channelId) {
Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined), adminApi.listDrainageFields()])
.then(([channelItems, fieldItems, libraryItems]) => {
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
function loadData() {
Promise.all([
adminApi.listChannels(),
adminApi.listReportTasks({ channelId }),
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 || '通道报备详情加载失败'));
}
useEffect(() => {
loadData('');
}, []);
useEffect(loadData, [channelId]);
const filteredFields = useMemo(() => fields.filter((field) => !keyword || [field.code, field.name, field.fieldType, field.description].join(' ').includes(keyword)), [fields, keyword]);
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 matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name].some((value) => String(value ?? '').includes(keyword.trim()));
return matchesKeyword && (status === 'all' || task.status === status);
}), [keyword, signatureMap, status, tasks]);
function lastRecord(taskId: string, action: string) {
return records.find((record) => record.taskId === taskId && record.action === action);
}
function createField() {
const selected = libraryFields.find((item) => item.id === drainageFieldId);
if (!selected) return;
adminApi.createChannelReportField({ channelId, drainageFieldId, reportType, required, description, status: 'active' })
.then(() => {
setModalOpen(false);
setDrainageFieldId('');
setReportType('signature');
setRequired(false);
setDescription('');
loadData();
})
if (!configType || !drainageFieldId) return;
adminApi.createChannelReportField({ channelId, drainageFieldId, reportType: configType, required, description, status: 'active' })
.then(() => { setConfigType(undefined); setDrainageFieldId(''); setRequired(false); setDescription(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'));
}
const columns: Array<TableColumn<ChannelReportField>> = [
{ key: 'channel', title: '通道', width: '220px', render: (record) => channels.find((item) => item.id === record.channelId)?.name ?? record.channelId },
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
{ key: 'name', title: '字段名称', width: '160px', render: (record) => record.name },
{ key: 'type', title: '字段类型', width: '120px', render: (record) => record.fieldType },
{ key: 'reportType', title: '报备用途', width: '140px', render: (record) => record.reportType === 'signature' ? '签名报备' : record.reportType === 'drainage' ? '引流信息报备' : '签名+引流' },
{ key: 'required', title: '必填', width: '90px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '是' : '否'}</Tag> },
{ key: 'description', title: '说明', render: (record) => record.description ?? '-' },
];
function toggle(signatureId: string) {
setExpanded((current) => {
const next = new Set(current);
next.has(signatureId) ? next.delete(signatureId) : next.add(signatureId);
return next;
});
}
function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, status: nextStatus }], reason: statusReason })
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
return (
<section className="page-stack admin-system-page admin-drainage-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备管理', '通道报备配置']} />
<h1></h1>
</div>
<div className="page-actions">
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<Button disabled={!channelId} icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
<section className="page-stack channel-report-page">
<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>
<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>
</div>
</div>
<div className="muted">{channel?.code ?? channelId} · {fields.length} </div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-drainage-toolbar">
<Select
onChange={(event) => {
setChannelId(event.target.value);
loadData(event.target.value);
}}
options={[{ label: '全部通道', value: '' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]}
value={channelId}
/>
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段代码、名称或说明" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Search size={16} />} onClick={() => loadData()}></Button>
<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>
</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 admin-system-table-card admin-drainage-table-card">
<Table columns={columns} data={filteredFields} emptyText="暂无通道报备字段" rowKey="id" />
<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 links = drainageItems(signature);
const isExpanded = expanded.has(task.signatureId);
const exported = lastRecord(task.id, 'export');
const imported = lastRecord(task.id, 'receipt_import');
return <div key={task.id}>
<div className="channel-report-row channel-report-row--signature">
<span />
<div className="channel-report-name"><button disabled={links.length === 0} onClick={() => toggle(task.signatureId)} type="button">{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button><span><strong>{signature?.name ?? task.signature?.name ?? '-'}</strong><small>{signature?.tenant?.name ?? task.tenantId}{links.length ? <b>{links.length}</b> : null}</small></span></div>
<ReportStatus value={task.status} />
<DateTime value={task.createdAt} />
<DateTime value={imported?.createdAt ?? exported?.createdAt} />
<DateTime value={task.updatedAt} />
<div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div>
<div className="channel-report-actions"><button onClick={() => setDetail({ task, signature })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div>
</div>
{isExpanded ? links.map((link, index) => <div className="channel-report-row channel-report-row--drainage" key={String(link.id ?? index)}>
<span />
<div className="channel-report-name channel-report-name--flow"><i /><span><strong>{String(link.siteName || link.url || `引流信息 ${index + 1}`)}</strong><small>{String(link.url ?? '')}</small></span></div>
<ReportStatus value={task.status} /><DateTime value={link.submittedAt ?? signature?.updatedAt} /><DateTime value={imported?.createdAt} /><DateTime value={signature?.updatedAt} />
<div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div>
<div className="channel-report-actions"><button onClick={() => setDetail({ task, signature, drainage: link })} type="button"><Eye size={16} /></button></div>
</div>) : null}
</div>;
})}
</div>
<Modal
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost"></Button><Button disabled={!channelId || !drainageFieldId} onClick={createField}></Button></>}
onClose={() => setModalOpen(false)}
open={modalOpen}
title="新增通道报备字段"
>
{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>
<Modal footer={<><Button onClick={() => setConfigType(undefined)} variant="ghost"></Button><Button disabled={!drainageFieldId} onClick={createField}></Button></>} onClose={() => setConfigType(undefined)} open={Boolean(configType)} title={configType === 'drainage' ? '配置引流信息报备字段' : '配置签名报备字段'}>
<div className="admin-system-modal-form">
<Select
label="报备字段库字段"
onChange={(event) => setDrainageFieldId(event.target.value)}
options={[{ label: '请选择字段', value: '' }, ...libraryFields.map((field) => ({ label: `${field.name ?? field.code}${field.code}`, value: field.id }))]}
value={drainageFieldId}
/>
<Select label="报备用途" onChange={(event) => setReportType(event.target.value as typeof reportType)} options={[{ label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }, { label: '签名+引流', value: 'both' }]} value={reportType} />
<Select label="报备字段库字段" onChange={(event) => setDrainageFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...libraryFields.map((field) => ({ label: `${field.name ?? field.code}${fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}`, value: field.id }))]} value={drainageFieldId} />
<Select label="是否必填" onChange={(event) => setRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(required)} />
<Textarea label="说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} />
<Textarea label="通道报备说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} />
</div>
</Modal>
</section>