Files
lislgosms/src/apps/admin/AdminChannelReportPage.tsx
T

193 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
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' },
reporting: { label: '报备中', tone: 'warning' },
abandoned: { label: '已放弃', tone: 'neutral' },
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 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 [drainageFieldId, setDrainageFieldId] = useState('');
const [required, setRequired] = useState(false);
const [description, setDescription] = useState('');
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);
}
function createField() {
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 || '报备字段保存失败'));
}
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 })
.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>
<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}${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} />
</div>
</Modal>
</section>
);
}