feat: complete signature and drainage reporting workflows
This commit is contained in:
+14
-5
@@ -285,6 +285,8 @@ export type ClientSmsSignature = {
|
||||
reportTasks?: Array<ReportTask & { channel?: AdminChannel }>;
|
||||
reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>;
|
||||
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
|
||||
drainageReportTargets?: Record<string, Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>>;
|
||||
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
|
||||
};
|
||||
|
||||
export type ClientSmsTemplate = {
|
||||
@@ -301,7 +303,7 @@ export type ClientSmsTemplate = {
|
||||
updatedAt: string;
|
||||
variables?: Array<{ name: string; example?: string | null; required?: boolean }>;
|
||||
application?: { id: string; name: string };
|
||||
signature?: { id: string; name: string };
|
||||
signature?: { id: string; name: string; drainageInfo?: Record<string, unknown> | null };
|
||||
tenant?: TenantOption;
|
||||
};
|
||||
|
||||
@@ -539,8 +541,10 @@ export type ReportTask = DictionaryItem & {
|
||||
tenantId: string;
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
reportType?: 'signature' | 'drainage';
|
||||
drainageItemId?: string | null;
|
||||
status: string;
|
||||
signature?: { id: string; name: string };
|
||||
signature?: { id: string; name: string; drainageInfo?: Record<string, unknown> | null };
|
||||
channel?: { id: string; name: string; code: string };
|
||||
};
|
||||
|
||||
@@ -930,7 +934,12 @@ export const adminApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string } = {}) =>
|
||||
approveSignature: (id: string) => request<ClientSmsSignature>(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
rejectSignature: (id: string, reason = '运营审核驳回') => request<ClientSmsSignature>(`/admin/signatures/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
|
||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
|
||||
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -981,9 +990,9 @@ export const adminApi = {
|
||||
createChannelReportField: (body: Record<string, unknown>) =>
|
||||
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; createdById?: string }) =>
|
||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
||||
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
||||
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string }>; reason?: string; operatorId?: string }) =>
|
||||
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string }) =>
|
||||
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
|
||||
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
|
||||
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Eye, FileSliders, Plus, Search } from 'lucide-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';
|
||||
@@ -16,6 +16,8 @@ const statusMeta: Record<string, { label: string; tone: 'success' | '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' },
|
||||
};
|
||||
@@ -72,7 +74,6 @@ export function AdminChannelReportPage() {
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
const [fields, setFields] = useState<ChannelReportField[]>([]);
|
||||
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
|
||||
@@ -109,7 +110,8 @@ export function AdminChannelReportPage() {
|
||||
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()));
|
||||
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]);
|
||||
|
||||
@@ -124,17 +126,9 @@ export function AdminChannelReportPage() {
|
||||
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'));
|
||||
}
|
||||
|
||||
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 })
|
||||
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 || '报备状态保存失败'));
|
||||
}
|
||||
@@ -159,37 +153,27 @@ export function AdminChannelReportPage() {
|
||||
<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><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 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 links = drainageItems(signature);
|
||||
const isExpanded = expanded.has(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 key={task.id}>
|
||||
<div className="channel-report-row channel-report-row--signature">
|
||||
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
|
||||
<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>{formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{signature?.tenant?.name ?? task.tenantId}{links.length ? <b>{links.length}</b> : null}</small></span></div>
|
||||
<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={task.createdAt} />
|
||||
<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 })} 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 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>
|
||||
|
||||
@@ -595,22 +595,47 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) {
|
||||
function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo; onClose: () => void; signature: ClientSmsSignature }) {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="引流信息报备详情">
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
|
||||
<div className="detail-grid">
|
||||
<div><span>站名称</span><strong>{item.siteName}</strong></div>
|
||||
<div><span>引流信息</span><strong>{item.url}</strong></div>
|
||||
<div><span>移动</span><StatusTag status={item.mobile} /></div>
|
||||
<div><span>联通</span><StatusTag status={item.unicom} /></div>
|
||||
<div><span>电信</span><StatusTag status={item.telecom} /></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={summary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={summary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={summary?.telecom} /></div>
|
||||
<div><span>提交时间</span><strong>{item.submittedAt}</strong></div>
|
||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||
</div>
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({target.channel.carrier})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>修改的是当前引流信息在具体通道上的真实报备任务,保存后会同步通道报备详情和报备任务页。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 220px', padding: 16 }}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
@@ -634,7 +659,8 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
|
||||
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
|
||||
const [drainageReport, setDrainageReport] = useState<DrainageInfo | null>(null);
|
||||
const [drainageReport, setDrainageReport] = 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 [error, setError] = useState('');
|
||||
@@ -793,21 +819,24 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<span>提交时间</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{payload.links.map((item) => (
|
||||
{payload.links.map((item) => {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
return (
|
||||
<div className="drainage-table__row" key={item.id}>
|
||||
<strong>{item.siteName}</strong>
|
||||
<span className="drainage-table__url" title={item.url}>{item.url}</span>
|
||||
<StatusTag status={item.mobile} />
|
||||
<StatusTag status={item.unicom} />
|
||||
<StatusTag status={item.telecom} />
|
||||
<CarrierReportTag summary={summary?.mobile} />
|
||||
<CarrierReportTag summary={summary?.unicom} />
|
||||
<CarrierReportTag summary={summary?.telecom} />
|
||||
<span className="muted">{item.submittedAt}</span>
|
||||
<span className="drainage-row-actions">
|
||||
<Button onClick={() => setDrainageReport(item)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button 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.siteName })} size="sm" variant="danger">删除</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">暂无引流信息</p>
|
||||
@@ -897,7 +926,8 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
|
||||
/>
|
||||
) : null}
|
||||
{drainageReport ? <DrainageReportModal item={drainageReport} onClose={() => setDrainageReport(null)} /> : null}
|
||||
{drainageReport ? <DrainageReportModal item={drainageReport.item} onClose={() => setDrainageReport(null)} signature={drainageReport.signature} /> : null}
|
||||
{drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null}
|
||||
{deleteTarget ? (
|
||||
<ConfirmModal
|
||||
message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`}
|
||||
|
||||
@@ -10,6 +10,10 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
|
||||
reporting: { label: '报备中', tone: 'warning' },
|
||||
partial: { label: '部分完成', tone: 'info' },
|
||||
completed: { label: '已完成', tone: 'success' },
|
||||
approved: { label: '报备通过', tone: 'success' },
|
||||
waiting_material: { label: '资料待补充', tone: 'warning' },
|
||||
exporting: { label: '报备中', tone: 'warning' },
|
||||
abandoned: { label: '已放弃', tone: 'neutral' },
|
||||
failed: { label: '有失败', tone: 'danger' },
|
||||
};
|
||||
|
||||
@@ -21,6 +25,14 @@ type ReceiptImportPayload = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function taskTargetLabel(task: ReportTask) {
|
||||
if (task.reportType !== 'drainage') return task.signature?.name ?? task.signatureId;
|
||||
const payload = task.signature?.drainageInfo;
|
||||
const links = payload && Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
|
||||
const item = links.find((link) => String(link.id ?? '') === task.drainageItemId);
|
||||
return String(item?.siteName ?? item?.url ?? task.drainageItemId ?? '引流信息');
|
||||
}
|
||||
|
||||
function ReceiptImportModal({ onClose, onSubmit, task }: { onClose: () => void; onSubmit: (payload: ReceiptImportPayload) => void; task: ReportTask }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileObject, setFileObject] = useState<FileObject | null>(null);
|
||||
@@ -97,7 +109,7 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>报备任务详情</h2><p>{task.id}</p></div>}>
|
||||
<div className="admin-task-detail report-task-detail">
|
||||
<div className="admin-task-metrics">
|
||||
<div className="admin-task-metric"><span>签名</span><strong>{task.signature?.name ?? task.signatureId}</strong></div>
|
||||
<div className="admin-task-metric"><span>{task.reportType === 'drainage' ? '引流信息' : '签名'}</span><strong>{taskTargetLabel(task)}</strong></div>
|
||||
<div className="admin-task-metric admin-task-metric--primary"><span>通道</span><strong>{task.channel?.name ?? task.channelId}</strong></div>
|
||||
<div className="admin-task-metric admin-task-metric--success"><span>当前状态</span><strong>{status.label}</strong></div>
|
||||
</div>
|
||||
@@ -131,7 +143,7 @@ export function AdminReportTasksPage() {
|
||||
}, []);
|
||||
|
||||
const filteredTasks = useMemo(() => tasks.filter((task) => {
|
||||
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}`;
|
||||
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageItemId ?? ''}`;
|
||||
const date = task.createdAt?.slice(0, 10) ?? '';
|
||||
return (!keyword || text.includes(keyword))
|
||||
&& (!dateRange.start || date >= dateRange.start)
|
||||
@@ -162,14 +174,14 @@ export function AdminReportTasksPage() {
|
||||
|
||||
function saveTaskStatus() {
|
||||
if (!statusTask) return;
|
||||
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, status: nextStatus }], reason: statusReason })
|
||||
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason })
|
||||
.then(() => { setStatusTask(null); setStatusReason(''); loadData(); })
|
||||
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<ReportTask>> = [
|
||||
{ key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
|
||||
{ key: 'scope', title: '通道/签名', width: '280px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.signature?.name ?? record.signatureId}</span></div> },
|
||||
{ key: 'scope', title: '通道/报备对象', width: '300px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.reportType === 'drainage' ? `引流信息 · ${taskTargetLabel(record)}` : `签名 · ${taskTargetLabel(record)}`}</span></div> },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
|
||||
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
||||
{
|
||||
@@ -192,14 +204,14 @@ export function AdminReportTasksPage() {
|
||||
<section className="page-stack admin-sms-task-page report-task-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} />
|
||||
<Input label="任务/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
||||
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||
draft: { label: '草稿', tone: 'neutral' },
|
||||
pending: { label: '待审核', tone: 'info' },
|
||||
approved: { label: '已通过', tone: 'success' },
|
||||
rejected: { label: '已驳回', tone: 'danger' },
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function fileRef(value: unknown): FileRef | null {
|
||||
const item = asRecord(value);
|
||||
return item.fileObjectId && item.fileName ? {
|
||||
fileObjectId: String(item.fileObjectId),
|
||||
fileName: String(item.fileName),
|
||||
contentType: item.contentType ? String(item.contentType) : undefined,
|
||||
} : null;
|
||||
}
|
||||
|
||||
function SignatureDetail({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
|
||||
const payload = asRecord(item.drainageInfo);
|
||||
const profile = asRecord(payload.signatureProfile);
|
||||
const values = asRecord(payload.signatureReportValues);
|
||||
const profileFiles = ['credentialFile', 'legalFrontFile', 'legalBackFile', 'responsibleFrontFile', 'responsibleBackFile']
|
||||
.map((key) => fileRef(profile[key]))
|
||||
.filter((value): value is FileRef => Boolean(value));
|
||||
const materialFiles: FileRef[] = (item.materials ?? []).flatMap((value) => {
|
||||
const material = asRecord(value);
|
||||
return material.fileObjectId ? [{
|
||||
fileObjectId: String(material.fileObjectId),
|
||||
fileName: String(material.title ?? material.fileName ?? material.fileObjectId),
|
||||
contentType: material.contentType ? String(material.contentType) : undefined,
|
||||
}] : [];
|
||||
});
|
||||
const files = [...new Map([...profileFiles, ...materialFiles].map((file) => [file.fileObjectId, file])).values()];
|
||||
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>{item.tenant?.name ?? item.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{item.application?.name ?? '-'}</strong></div>
|
||||
<div><span>签名</span><strong>{item.name}</strong></div>
|
||||
<div><span>审核状态</span><Tag tone={(statusMeta[item.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[item.auditStatus] ?? statusMeta.draft).label}</Tag></div>
|
||||
<div><span>签名依据</span><strong>{String(profile.basis ?? '-')}</strong></div>
|
||||
<div><span>公司名称</span><strong>{String(profile.companyName ?? '-')}</strong></div>
|
||||
<div><span>统一社会信用代码</span><strong>{String(profile.creditCode ?? '-')}</strong></div>
|
||||
<div><span>法人</span><strong>{String(profile.legalPersonName ?? '-')}</strong></div>
|
||||
<div><span>责任人</span><strong>{String(profile.responsibleName ?? '-')}</strong></div>
|
||||
<div><span>责任人手机</span><strong>{String(profile.responsiblePhone ?? '-')}</strong></div>
|
||||
{item.rejectReason ? <div className="detail-grid__wide"><span>驳回原因</span><strong>{item.rejectReason}</strong></div> : null}
|
||||
</div>
|
||||
<div className="surface" style={{ padding: 16 }}><strong>资质文件</strong><div className="table-actions" style={{ marginTop: 12 }}>{files.length ? files.map((file) => <FileActions file={file} key={file.fileObjectId} />) : <span className="muted">无</span>}</div></div>
|
||||
<div className="surface" style={{ padding: 16 }}><strong>通道动态报备资料</strong><div className="detail-grid" style={{ marginTop: 12 }}>{Object.entries(values).length ? Object.entries(values).map(([key, value]) => <div key={key}><span>{key}</span><strong>{fileRef(value) ? <FileActions file={fileRef(value)} /> : String(value ?? '-')}</strong></div>) : <span className="muted">无</span>}</div></div>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export function AdminSignatureAuditPage() {
|
||||
const [items, setItems] = useState<ClientSmsSignature[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [detail, setDetail] = useState<ClientSmsSignature>();
|
||||
const [rejectTarget, setRejectTarget] = useState<ClientSmsSignature>();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
adminApi.listEnterpriseSignatures({ keyword, status: status === 'all' ? undefined : status })
|
||||
.then((records) => { setItems(records); setError(''); })
|
||||
.catch((failure: Error) => setError(failure.message || '签名审核列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(loadData, [keyword, status]);
|
||||
|
||||
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
|
||||
|
||||
async function approve(item: ClientSmsSignature) {
|
||||
try { await adminApi.approveSignature(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核通过失败'); }
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!rejectTarget || !reason.trim()) return;
|
||||
try { await adminApi.rejectSignature(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); }
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ClientSmsSignature>>>(() => [
|
||||
{ key: 'name', title: '签名', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'tenant', title: '企业', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><Button disabled={record.auditStatus !== 'pending'} icon={<Check size={15} />} onClick={() => void approve(record)} size="sm" variant="success">通过</Button><Button disabled={record.auditStatus !== 'pending'} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
||||
], []);
|
||||
|
||||
return <section className="page-stack admin-template-audit-page">
|
||||
<div className="page-heading"><div><Breadcrumb items={['审核中心', '短信签名审核']} /><h1>短信签名审核</h1></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><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' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost">重置</Button></div></div></div>
|
||||
<div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div>
|
||||
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
|
||||
<Modal footer={<><Button onClick={() => setRejectTarget(undefined)} variant="ghost">取消</Button><Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger">确认驳回</Button></>} onClose={() => setRejectTarget(undefined)} open={Boolean(rejectTarget)} title="驳回签名审核"><Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} /></Modal>
|
||||
</section>;
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export function AdminLayout() {
|
||||
{ label: '企业认证审核', to: '/admin/enterprise-audit', icon: ShieldCheck },
|
||||
{ label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare },
|
||||
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
|
||||
{ label: '彩信模板审核', to: '/admin/signatures', icon: ImageIcon, pending: true },
|
||||
{ label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFor
|
||||
import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
|
||||
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
|
||||
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
|
||||
import { AdminSignatureAuditPage } from '@/apps/admin/AdminSignatureAuditPage';
|
||||
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
|
||||
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
|
||||
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
|
||||
@@ -95,7 +96,7 @@ export function AppRoutes() {
|
||||
<Route path="enterprise-signatures" element={<AdminEnterpriseSignaturesPage />} />
|
||||
<Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} />
|
||||
<Route path="templates" element={<AdminTemplateAuditPage />} />
|
||||
<Route path="signatures" element={<PagePlaceholder />} />
|
||||
<Route path="signatures" element={<AdminSignatureAuditPage />} />
|
||||
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
||||
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
|
||||
<Route path="report-tasks" element={<AdminReportTasksPage />} />
|
||||
|
||||
Reference in New Issue
Block a user