182 lines
13 KiB
TypeScript
182 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { ArrowLeft, 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, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
|
import { formatDateTime } from '@/utils/dateTime';
|
|
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
|
|
|
|
type ReportType = 'signature' | 'drainage';
|
|
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' },
|
|
reporting: { label: '报备中', tone: 'warning' },
|
|
abandoned: { label: '已放弃', tone: 'neutral' },
|
|
partial_success: { label: '部分成功', tone: 'warning' },
|
|
filing: { label: '报备中', tone: 'warning' },
|
|
};
|
|
|
|
function asRecord(value: unknown): 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, '');
|
|
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') : [];
|
|
}
|
|
|
|
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 || '引流信息') : 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>
|
|
{!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 { 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 [keyword, setKeyword] = useState('');
|
|
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 [error, setError] = useState('');
|
|
|
|
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, [channelId]);
|
|
|
|
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]);
|
|
|
|
function lastRecord(taskId: string, action: string) {
|
|
return records.find((record) => record.taskId === taskId && record.action === action);
|
|
}
|
|
|
|
async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) {
|
|
if (!configType) return;
|
|
await adminApi.replaceChannelReportFields(channelId, configType, nextFields);
|
|
loadData();
|
|
}
|
|
|
|
function saveTaskStatus() {
|
|
if (!statusTask) return;
|
|
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, 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 || '报备状态保存失败'));
|
|
}
|
|
|
|
return (
|
|
<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 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 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 exported = lastRecord(task.id, 'export');
|
|
const imported = lastRecord(task.id, 'receipt_import');
|
|
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.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : signature?.tenant?.name ?? task.tenantId}</small></span></div>
|
|
<ReportStatus value={task.status} />
|
|
<DateTime value={drainage?.submittedAt ?? 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, drainage })} type="button"><Eye size={16} />查看详情</button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button">修改状态</button></div>
|
|
</div>;
|
|
})}
|
|
</div>
|
|
|
|
{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}
|
|
</section>
|
|
);
|
|
}
|