feat: add report material workflows and gateway safeguards

This commit is contained in:
hectorzhao
2026-07-15 18:23:48 +08:00
parent cf9f4ce4cd
commit 7091a8bed4
41 changed files with 3606 additions and 71 deletions
+7 -18
View File
@@ -4,8 +4,9 @@ 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' | 'both';
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' }> = {
@@ -22,8 +23,6 @@ const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | '
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> : {};
}
@@ -81,9 +80,6 @@ export function AdminChannelReportPage() {
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() {
@@ -119,11 +115,10 @@ export function AdminChannelReportPage() {
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 || '报备字段保存失败'));
async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) {
if (!configType) return;
await adminApi.replaceChannelReportFields(channelId, configType, nextFields);
loadData();
}
function saveTaskStatus() {
@@ -180,13 +175,7 @@ export function AdminChannelReportPage() {
{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>
{configType ? <ReportFieldMappingModal fields={fields} libraryFields={libraryFields} onClose={() => setConfigType(undefined)} onSave={saveFieldMapping} reportType={configType} /> : null}
</section>
);
}
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Clock3, Eye, RefreshCw, RotateCcw, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type EnterpriseApplication, type GatewaySubmitException } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
const statusLabel: Record<string, string> = {
pending: '待处理',
requeueing: '正在入队',
requeued: '已重新入队',
resolved: '已处理',
};
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'danger',
requeueing: 'warning',
requeued: 'info',
resolved: 'success',
};
function formatTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
}
function maskPhone(value?: string | null) {
if (!value) return '-';
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
function commandValue(record: GatewaySubmitException, key: string) {
const value = record.commandPayload?.[key];
return value == null ? '' : String(value);
}
function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
record: GatewaySubmitException;
onClose: () => void;
onRequestRequeue: () => void;
}) {
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
const content = record.messageState?.content ?? commandValue(record, 'content');
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2>Gateway提交异常详情</h2><p>{record.messageId ?? record.streamMessageId}</p></div>}
footer={(
<div className="modal-footer-actions">
<Button onClick={onClose} variant="ghost"></Button>
{record.status === 'pending' ? <Button icon={<RotateCcw size={15} />} onClick={onRequestRequeue}></Button> : null}
</div>
)}
>
<div className="report-record-detail gateway-exception-detail">
<div className="admin-detail-metric-grid admin-detail-metric-grid--compact">
<div className="surface mini-status-card"><AlertTriangle size={20} /><div><span></span><strong>{statusLabel[record.status] ?? record.status}</strong></div></div>
<div className="surface mini-status-card"><RefreshCw size={20} /><div><span></span><strong>{record.attempts}/{record.maxAttempts}</strong></div></div>
<div className="surface mini-status-card"><RotateCcw size={20} /><div><span></span><strong>{record.manualRetryCount}</strong></div></div>
</div>
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? '-'}</strong></div>
<div><span></span><strong>{record.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong></div>
<div><span></span><strong>{record.channel?.status ?? '-'}</strong></div>
<div><span></span><strong>{maskPhone(phone)}</strong></div>
<div><span></span><strong>{record.messageState?.status ?? '-'}</strong></div>
<div><span></span><strong>{record.messageState?.submitStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.messageState?.receiptStatus ?? '-'}</strong></div>
<div><span>MessageId</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span>SubmitId</span><strong>{record.submitId ?? '-'}</strong></div>
<div><span>TraceId</span><strong>{record.traceId ?? '-'}</strong></div>
<div><span>Stream消息</span><strong>{record.streamMessageId}</strong></div>
<div><span></span><strong>{formatTime(record.createdAt)}</strong></div>
<div><span></span><strong>{formatTime(record.lastRetriedAt)}</strong></div>
<div><span></span><strong>{formatTime(record.resolvedAt)}</strong></div>
<div><span></span><strong>{record.resolvedStatus ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{content || '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.failureCode}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.failureMessage}</strong></div>
</div>
<div className="gateway-exception-command">
<div className="section-heading"><h3>Gateway命令</h3><p className="page-inline-hint"></p></div>
<pre>{JSON.stringify(record.commandPayload ?? {}, null, 2)}</pre>
</div>
</div>
</Modal>
);
}
function RequeueModal({ record, submitting, onClose, onSubmit }: {
record: GatewaySubmitException;
submitting: boolean;
onClose: () => void;
onSubmit: (reason: string) => void;
}) {
const [confirmed, setConfirmed] = useState(false);
const [reason, setReason] = useState('');
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
const unsafe = record.messageState?.submitStatus === 'accepted'
|| ['submitted', 'delivered', 'unknown'].includes(record.messageState?.status ?? '')
|| ['delivered', 'unknown'].includes(record.messageState?.receiptStatus ?? '');
return (
<Modal
open
onClose={onClose}
size="md"
title={<div className="template-modal-title"><h2></h2><p>{maskPhone(phone)}</p></div>}
footer={(
<div className="modal-footer-actions">
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!confirmed || reason.trim().length < 5 || unsafe || submitting} icon={<RotateCcw size={15} />} onClick={() => onSubmit(reason.trim())}>
{submitting ? '正在入队...' : '确认重新入队'}
</Button>
</div>
)}
>
<div className="page-stack gateway-requeue-confirm">
<div className={`callout ${unsafe ? 'callout--danger' : 'callout--warning'}`}>
<AlertTriangle size={18} />
<div>
<strong>{unsafe ? '当前记录禁止重新入队' : '重新提交可能产生重复短信'}</strong>
<p>{unsafe ? '系统已存在成功或不确定的上游结果。' : '请先向通道或运营商确认原短信从未被接收。'}</p>
</div>
</div>
<div className="detail-grid">
<div><span></span><strong>{record.messageState?.status ?? '-'}</strong></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.failureMessage}</strong></div>
</div>
<Textarea label="重新入队原因" maxLength={500} onChange={(event) => setReason(event.target.value)} placeholder="至少5个字,例如:已向通道确认该Submit未被接收,连接现已恢复" rows={4} value={reason} />
<label className="gateway-requeue-checkbox">
<input checked={confirmed} disabled={unsafe} onChange={(event) => setConfirmed(event.target.checked)} type="checkbox" />
<span></span>
</label>
</div>
</Modal>
);
}
export function AdminGatewaySubmitExceptionsPage() {
const [items, setItems] = useState<GatewaySubmitException[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [summary, setSummary] = useState({ pending: 0, requeueing: 0, requeued: 0, resolved: 0, oldestPendingAt: null as string | null });
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [channelId, setChannelId] = useState('all');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [detail, setDetail] = useState<GatewaySubmitException | null>(null);
const [requeueRecord, setRequeueRecord] = useState<GatewaySubmitException | null>(null);
const pageSize = 10;
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.listGatewaySubmitExceptions({ keyword, status, applicationId, channelId, page, pageSize }),
adminApi.listEnterpriseApplications(),
adminApi.listChannels(),
])
.then(([response, appItems, channelItems]) => {
setItems(response.items);
setTotal(response.total);
setSummary({ ...response.summary, oldestPendingAt: response.summary.oldestPendingAt ?? null });
setApplications(appItems);
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
setError('');
})
.catch((failure: Error) => {
setItems([]);
setTotal(0);
setError(failure.message || 'Gateway提交异常加载失败');
})
.finally(() => setLoading(false));
}, [applicationId, channelId, keyword, page, status]);
useEffect(() => { loadData(); }, [loadData]);
const columns = useMemo<Array<TableColumn<GatewaySubmitException>>>(() => [
{ key: 'createdAt', title: '异常时间', width: '170px', render: (record) => formatTime(record.createdAt) },
{ key: 'messageId', title: '消息编号', width: '170px', render: (record) => <strong className="admin-task-id">{record.messageId ?? '-'}</strong> },
{ key: 'tenant', title: '企业 / 应用', width: '190px', render: (record) => <div><strong>{record.tenant?.name ?? '-'}</strong><small className="table-cell-note">{record.application?.name ?? '-'}</small></div> },
{ key: 'channel', title: '通道', width: '180px', render: (record) => <div>{record.channel?.name ?? '-' }<small className="table-cell-note">{record.channel?.code ?? record.channelId ?? '-'}</small></div> },
{ key: 'phone', title: '手机号', width: '130px', render: (record) => maskPhone(record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber')) },
{ key: 'failure', title: '异常原因', render: (record) => <div><strong>{record.failureCode}</strong><small className="table-cell-note">{record.failureMessage}</small></div> },
{ key: 'attempts', title: '尝试', width: '80px', align: 'center', render: (record) => `${record.attempts}/${record.maxAttempts}` },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusTone[record.status] ?? 'neutral'}>{statusLabel[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '105px', align: 'right', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
], []);
async function submitRequeue(reason: string) {
if (!requeueRecord) return;
setSubmitting(true);
try {
await adminApi.requeueGatewaySubmitException(requeueRecord.id, {
confirmedNotSubmitted: true,
reason,
});
setRequeueRecord(null);
setDetail(null);
setError('');
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '重新入队失败');
} finally {
setSubmitting(false);
}
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<section className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
<div className="page-heading">
<div><Breadcrumb items={['运营概览', 'Gateway提交异常']} /><h1>Gateway提交异常</h1><p className="page-inline-hint">Gateway连续失败且尚未取得明确上游结果的提交命令</p></div>
<Button icon={<RefreshCw size={16} />} onClick={loadData} variant="secondary"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface mini-status-card"><AlertTriangle size={22} /><div><span></span><strong>{summary.pending}</strong><small></small></div></div>
<div className="surface mini-status-card"><Clock3 size={22} /><div><span></span><strong className="gateway-exception-time">{formatTime(summary.oldestPendingAt)}</strong><small></small></div></div>
<div className="surface mini-status-card"><RotateCcw size={22} /><div><span></span><strong>{summary.requeued + summary.requeueing}</strong><small>Gateway通道TPS约束</small></div></div>
<div className="surface mini-status-card"><CheckCircle2 size={22} /><div><span></span><strong>{summary.resolved}</strong><small>Gateway提交结果</small></div></div>
</div>
<div className="surface admin-task-filter">
<Input label="消息编号 / 错误" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="MessageId、SubmitId、失败原因" value={keyword} />
<Select label="状态" options={[{ label: '全部状态', value: 'all' }, { label: '待处理', value: 'pending' }, { label: '正在入队', value: 'requeueing' }, { label: '已重新入队', value: 'requeued' }, { label: '已处理', value: 'resolved' }]} value={status} onChange={(event) => { setStatus(event.target.value); setPage(1); }} />
<Select label="应用" options={[{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} />
<Select label="通道" options={[{ label: '全部通道', value: 'all' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]} value={channelId} onChange={(event) => { setChannelId(event.target.value); setPage(1); }} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}></Button></div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading"><h2></h2><p className="page-inline-hint">Gateway命令已由后端脱敏</p></div>
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无Gateway提交异常'} pagination={false} rowKey="id" />
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
</div>
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} /> : null}
{requeueRecord ? <RequeueModal record={requeueRecord} submitting={submitting} onClose={() => setRequeueRecord(null)} onSubmit={submitRequeue} /> : null}
</section>
);
}
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, FileSpreadsheet, Layers3, RefreshCw } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialPendingItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
type Batch = Record<string, unknown> & { id: string; batchNo?: string; status?: string; createdAt?: string; selectedCount?: number; channelCount?: number; exportFiles?: Array<Record<string, unknown>> };
const statusLabel: Record<string, string> = { completed: '生成完成', partial_failed: '部分资料待补充', failed: '生成失败', processing: '生成中' };
export function AdminReportMaterialsPage() {
const [items, setItems] = useState<ReportMaterialPendingItem[]>([]);
const [batches, setBatches] = useState<Batch[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [reportType, setReportType] = useState('all');
const [keyword, setKeyword] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
function loadData() {
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
.then(([pendingItems, batchItems]) => { setItems(pendingItems); setBatches(batchItems as Batch[]); setSelected((current) => new Set([...current].filter((id) => pendingItems.some((item) => item.id === id)))); setError(''); })
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
}
useEffect(loadData, [reportType]);
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
const allSelected = visibleItems.length > 0 && visibleItems.every((item) => selected.has(item.id));
function toggle(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
async function createBatch() {
const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
setBusy(true); setError(''); setMessage('');
try {
const batch = await adminApi.createReportMaterialBatch({ items: chosen.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined })) });
setMessage(`批次 ${String(batch.batchNo ?? '')} 已按应用路由生成各通道报备文件`); setSelected(new Set()); loadData();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
finally { setBusy(false); }
}
return <section className="page-stack report-material-page">
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1></h1><p> XLSX </p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"> WPS </Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void createBatch()}>{busy ? '生成中...' : `统一生成通道报备(${selected.size}`}</Button></div></div>
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
<div className="surface report-material-filter"><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} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost"></Button></div>
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span></span><span> / </span><span></span><span></span></div>{visibleItems.map((item) => <label className="report-material-row" key={item.id}><input checked={selected.has(item.id)} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><Tag tone="info">V{item.materialVersion}</Tag><span>{formatDateTime(item.changedAt)}</span></label>)}{visibleItems.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2></h2><p> XLSX</p></div><Tag tone="neutral">{batches.length} </Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · {batch.selectedCount ?? 0} · {batch.channelCount ?? 0} </small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}{String(file.rowCount ?? 0)} </a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
</section>;
}
+148
View File
@@ -0,0 +1,148 @@
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronUp, Plus, Search, Trash2 } from 'lucide-react';
import { type ChannelReportField, type DictionaryItem } from '@/api/adminApi';
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
type ReportType = 'signature' | 'drainage';
type DraftField = {
drainageFieldId: string;
code: string;
name: string;
fieldType: string;
exportName: string;
required: boolean;
description: string;
sortOrder: number;
columnWidth: number;
imageWidth: number;
imageHeight: number;
defaultValue: string;
transform: string;
status: string;
};
const fieldTypeLabel: Record<string, string> = { string: '文本', image: '图片', file: '文件' };
const transformOptions = [
{ label: '保持原值', value: '' },
{ label: '去除首尾空格', value: 'trim' },
{ label: '仅保留数字', value: 'digits' },
{ label: '转大写', value: 'uppercase' },
{ label: '转小写', value: 'lowercase' },
];
function initialDraft(fields: ChannelReportField[], reportType: ReportType): DraftField[] {
return fields
.filter((field) => field.reportType === reportType || field.reportType === 'both')
.sort((left, right) => (left.sortOrder ?? 100) - (right.sortOrder ?? 100))
.map((field, index) => ({
drainageFieldId: String(field.drainageFieldId ?? field.drainageField?.id ?? ''),
code: field.code,
name: field.name,
fieldType: field.fieldType,
exportName: field.exportName || field.name,
required: field.required,
description: String(field.description ?? ''),
sortOrder: field.sortOrder ?? (index + 1) * 10,
columnWidth: field.columnWidth ?? 18,
imageWidth: field.imageWidth ?? 120,
imageHeight: field.imageHeight ?? 80,
defaultValue: String(field.defaultValue ?? ''),
transform: String(field.transform ?? ''),
status: 'active',
}));
}
export function ReportFieldMappingModal({ fields, libraryFields, reportType, onClose, onSave }: {
fields: ChannelReportField[];
libraryFields: DictionaryItem[];
reportType: ReportType;
onClose: () => void;
onSave: (fields: DraftField[]) => Promise<void>;
}) {
const [draft, setDraft] = useState(() => initialDraft(fields, reportType));
const [search, setSearch] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const selectedIds = useMemo(() => new Set(draft.map((field) => field.drainageFieldId)), [draft]);
const available = useMemo(() => libraryFields.filter((field) => !selectedIds.has(String(field.id)) && [field.name, field.code].some((value) => String(value ?? '').toLowerCase().includes(search.trim().toLowerCase()))), [libraryFields, search, selectedIds]);
function addField(field: DictionaryItem) {
setDraft((current) => [...current, {
drainageFieldId: String(field.id),
code: String(field.code ?? field.id),
name: String(field.name ?? field.code ?? '未命名字段'),
fieldType: String(field.fieldType ?? 'string'),
exportName: String(field.name ?? field.code ?? ''),
required: Boolean(field.required),
description: String(field.description ?? ''),
sortOrder: (current.length + 1) * 10,
columnWidth: 18,
imageWidth: 120,
imageHeight: 80,
defaultValue: '',
transform: '',
status: 'active',
}]);
}
function patchField(index: number, patch: Partial<DraftField>) {
setDraft((current) => current.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field));
}
function move(index: number, offset: number) {
setDraft((current) => {
const target = index + offset;
if (target < 0 || target >= current.length) return current;
const next = [...current];
[next[index], next[target]] = [next[target], next[index]];
return next.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 }));
});
}
async function save() {
if (draft.some((field) => !field.exportName.trim())) { setError('导出表头名称不能为空'); return; }
setSaving(true); setError('');
try { await onSave(draft.map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 }))); onClose(); }
catch (failure) { setError(failure instanceof Error ? failure.message : '字段配置保存失败'); }
finally { setSaving(false); }
}
return <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中...' : '保存配置'}</Button></>}
onClose={onClose}
open
size="xl"
title={<div className="channel-field-config-title"><h2>{reportType === 'signature' ? '配置签名报备字段' : '配置引流信息字段'}</h2><p>Excel表头的映射</p></div>}
>
<div className="channel-field-config">
<section className="channel-field-pool">
<div className="channel-field-section-head"><h3></h3><Tag tone="neutral">{available.length} </Tag></div>
<Input onChange={(event) => setSearch(event.target.value)} placeholder="搜索标准字段" prefix={<Search size={16} />} value={search} />
<div className="channel-field-pool-list">
{available.map((field) => <button key={String(field.id)} onClick={() => addField(field)} type="button"><span><strong>{String(field.name ?? field.code)}</strong><Tag tone="neutral">{fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}</Tag></span><span> <Plus size={15} /></span></button>)}
{available.length === 0 ? <p></p> : null}
</div>
</section>
<section className="channel-selected-fields">
<div className="channel-field-section-head"><div><h3></h3><p>Excel从左到右的列顺序</p></div><Tag tone="info">{draft.length} </Tag></div>
<div className="channel-export-preview">{draft.map((field, index) => <span key={field.drainageFieldId}>{String.fromCharCode(65 + index)} · {field.exportName || field.name}</span>)}</div>
<div className="channel-selected-field-list">
{draft.map((field, index) => <article key={field.drainageFieldId}>
<div className="channel-selected-field-head"><span className="channel-selected-field-index">{index + 1}</span><strong>{field.name}</strong><Tag tone="neutral">{fieldTypeLabel[field.fieldType] ?? field.fieldType}</Tag><div className="channel-selected-field-order"><button disabled={index === 0} onClick={() => move(index, -1)} type="button"><ChevronUp size={16} /></button><button disabled={index === draft.length - 1} onClick={() => move(index, 1)} type="button"><ChevronDown size={16} /></button></div></div>
<div className="channel-field-mapping-grid">
<Input label="通道导出表头" onChange={(event) => patchField(index, { exportName: event.target.value })} value={field.exportName} />
<Select label="是否必填" onChange={(event) => patchField(index, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(field.required)} />
<Input label="列宽" min="6" onChange={(event) => patchField(index, { columnWidth: Number(event.target.value) })} type="number" value={String(field.columnWidth)} />
<Select label="文本转换" onChange={(event) => patchField(index, { transform: event.target.value })} options={transformOptions} value={field.transform} />
{field.fieldType !== 'string' ? <><Input label="图片宽度(px)" min="24" onChange={(event) => patchField(index, { imageWidth: Number(event.target.value) })} type="number" value={String(field.imageWidth)} /><Input label="图片高度(px)" min="24" onChange={(event) => patchField(index, { imageHeight: Number(event.target.value) })} type="number" value={String(field.imageHeight)} /></> : <Input label="缺省值" onChange={(event) => patchField(index, { defaultValue: event.target.value })} value={field.defaultValue} />}
</div>
<Textarea label="通道说明" onChange={(event) => patchField(index, { description: event.target.value })} rows={2} value={field.description} />
<Button icon={<Trash2 size={15} />} onClick={() => setDraft((current) => current.filter((_, fieldIndex) => fieldIndex !== index))} size="sm" variant="danger"></Button>
</article>)}
{draft.length === 0 ? <div className="channel-report-empty"></div> : null}
</div>
</section>
</div>
{error ? <p className="form-error">{error}</p> : null}
</Modal>;
}
@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from 'react';
import { FileSpreadsheet, Plus, Trash2 } from 'lucide-react';
import { adminApi, type DictionaryItem, type EnterpriseApplication, type ReportImportMapping, type ReportImportProfile, type TenantOption } from '@/api/adminApi';
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
type ReportType = 'signature' | 'drainage';
type AnalyzeResult = {
id: string;
sheetName?: string;
sheets?: string[];
columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>;
rows: Array<{ rowNumber: number; values: Record<string, string>; imageColumns: number[] }>;
suggestedMappings: ReportImportMapping[];
};
const transforms = [{ label: '保持原值', value: '' }, { label: '去除首尾空格', value: 'trim' }, { label: '仅保留数字', value: 'digits' }, { label: '转大写', value: 'uppercase' }, { label: '转小写', value: 'lowercase' }];
function coreTargets(reportType: ReportType) {
return reportType === 'signature'
? [{ label: '短信签名', value: 'signatureName:signature_name:string' }, { label: '签名用途/依据', value: 'purpose:purpose:string' }]
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '站点名称', value: 'siteName:site_name:string' }, { label: '引流地址', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
}
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [profiles, setProfiles] = useState<ReportImportProfile[]>([]);
const [tenantId, setTenantId] = useState('');
const [applicationId, setApplicationId] = useState('');
const [reportType, setReportType] = useState<ReportType>('signature');
const [profileId, setProfileId] = useState('');
const [file, setFile] = useState<File>();
const [headerRowCount, setHeaderRowCount] = useState(1);
const [dataStartRow, setDataStartRow] = useState(2);
const [analysis, setAnalysis] = useState<AnalyzeResult>();
const [mappings, setMappings] = useState<ReportImportMapping[]>([]);
const [profileName, setProfileName] = useState('');
const [saveProfile, setSaveProfile] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listDrainageFields()])
.then(([tenantItems, applicationItems, fieldItems]) => { setTenants(tenantItems); setApplications(applicationItems); setLibraryFields(fieldItems.filter((item) => item.status === 'active')); })
.catch((failure: Error) => setError(failure.message || '基础数据加载失败'));
}, []);
useEffect(() => {
adminApi.listReportImportProfiles(reportType).then(setProfiles).catch(() => setProfiles([]));
setProfileId(''); setAnalysis(undefined); setMappings([]);
}, [reportType]);
const availableApplications = useMemo(() => applications.filter((item) => !tenantId || item.tenantId === tenantId), [applications, tenantId]);
const mappingByColumn = useMemo(() => new Map(mappings.map((item) => [item.sourceColumnIndex, item])), [mappings]);
async function analyze() {
if (!tenantId || !file) { setError('请选择企业和 XLSX 文件'); return; }
setBusy(true); setError('');
try {
const result = await adminApi.analyzeReportMaterialImport(file, { tenantId, applicationId: applicationId || undefined, reportType, headerRowCount, dataStartRow, profileId: profileId || undefined }) as AnalyzeResult;
setAnalysis(result); setMappings(result.suggestedMappings ?? []);
const selectedProfile = profiles.find((item) => item.id === profileId);
if (selectedProfile) setProfileName(selectedProfile.name);
} catch (failure) { setError(failure instanceof Error ? failure.message : '文件解析失败'); }
finally { setBusy(false); }
}
function setTarget(column: AnalyzeResult['columns'][number], encoded: string) {
setMappings((current) => {
const remaining = current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex);
if (!encoded) return remaining;
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [ReportImportMapping['targetKind'], string, ReportImportMapping['fieldType']];
return [...remaining, { sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetKind, targetFieldCode, fieldType, required: false, sortOrder: (column.sourceColumnIndex + 1) * 10 }].sort((left, right) => left.sourceColumnIndex - right.sourceColumnIndex);
});
}
function patchMapping(columnIndex: number, patch: Partial<ReportImportMapping>) {
setMappings((current) => current.map((item) => item.sourceColumnIndex === columnIndex ? { ...item, ...patch } : item));
}
async function commit() {
if (!analysis || mappings.length === 0) { setError('请至少配置一个导入字段映射'); return; }
if (saveProfile && !profileName.trim()) { setError('请输入映射方案名称'); return; }
setBusy(true); setError('');
try {
await adminApi.commitReportMaterialImport(analysis.id, {
mappings,
profile: saveProfile ? { id: profileId || undefined, name: profileName, reportType, tenantId, applicationId: applicationId || null, sheetName: analysis.sheetName, headerRowCount, dataStartRow, columns: mappings } : undefined,
});
onCompleted(); onClose();
} catch (failure) { setError(failure instanceof Error ? failure.message : '导入失败'); }
finally { setBusy(false); }
}
const targetOptions = [
{ label: '不导入此列', value: '' },
...coreTargets(reportType),
...libraryFields.map((field) => ({ label: `报备字段 · ${String(field.name ?? field.code)}`, value: `dynamic:${String(field.code)}:${field.fieldType === 'string' ? 'string' : field.fieldType}` })),
];
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button>{analysis ? <Button disabled={busy} onClick={() => void commit()}>{busy ? '导入中...' : '确认导入待报备池'}</Button> : <Button disabled={busy || !file || !tenantId} onClick={() => void analyze()}>{busy ? '解析中...' : '解析文件并配置映射'}</Button>}</>} onClose={onClose} open size="xl" title={<div className="channel-field-config-title"><h2></h2><p> WPS XLSX </p></div>}>
<div className="report-import-basic-grid">
<Select label="资料类型" onChange={(event) => setReportType(event.target.value as ReportType)} options={[{ label: '签名资料', value: 'signature' }, { label: '引流信息资料', value: 'drainage' }]} value={reportType} />
<Select label="所属企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: `${item.name}${item.code}`, value: item.id }))]} value={tenantId} />
<Select label="企业应用(可选)" onChange={(event) => setApplicationId(event.target.value)} options={[{ label: '不限定应用', value: '' }, ...availableApplications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} />
<Select label="复用导入映射(可选)" onChange={(event) => { const id = event.target.value; setProfileId(id); const profile = profiles.find((item) => item.id === id); if (profile) { setHeaderRowCount(profile.headerRowCount); setDataStartRow(profile.dataStartRow); } }} options={[{ label: '新建映射', value: '' }, ...profiles.map((item) => ({ label: item.name, value: item.id }))]} value={profileId} />
<Input label="表头行数" max="5" min="1" onChange={(event) => setHeaderRowCount(Number(event.target.value))} type="number" value={String(headerRowCount)} />
<Input label="数据起始行" min="2" onChange={(event) => setDataStartRow(Number(event.target.value))} type="number" value={String(dataStartRow)} />
</div>
<label className="report-import-file"><span><FileSpreadsheet size={22} /><strong>{file?.name ?? '选择 WPS 另存的 .xlsx 文件'}</strong></span><input accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => { setFile(event.target.files?.[0]); setAnalysis(undefined); }} type="file" /></label>
{analysis ? <div className="report-import-mapping">
<div className="channel-field-section-head"><div><h3></h3><p></p></div><Tag tone="info"> {analysis.columns.length} </Tag></div>
<div className="report-import-mapping-table"><div className="report-import-mapping-head"><span>/</span><span></span><span></span><span></span><span></span></div>{analysis.columns.map((column) => {
const mapping = mappingByColumn.get(column.sourceColumnIndex);
const encoded = mapping ? `${mapping.targetKind}:${mapping.targetFieldCode}:${mapping.fieldType}` : '';
return <div className="report-import-mapping-row" key={column.sourceColumnIndex}><span><strong>{column.columnLetter} · {column.sourceHeader}</strong><small>{column.sourceHeaderPath}</small>{column.imageCount ? <Tag tone="warning">{column.imageCount} </Tag> : null}</span><Select onChange={(event) => setTarget(column, event.target.value)} options={targetOptions} value={encoded} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { fieldType: event.target.value as ReportImportMapping['fieldType'] })} options={[{ label: '文本', value: 'string' }, { label: '图片', value: 'image' }, { label: '文件', value: 'file' }]} value={mapping?.fieldType ?? 'string'} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(mapping?.required ?? false)} /><Select disabled={!mapping || mapping.fieldType !== 'string'} onChange={(event) => patchMapping(column.sourceColumnIndex, { transform: event.target.value })} options={transforms} value={mapping?.transform ?? ''} /></div>;
})}</div>
<div className="report-import-profile"><button className={saveProfile ? 'is-active' : ''} onClick={() => setSaveProfile((value) => !value)} type="button">{saveProfile ? <Trash2 size={15} /> : <Plus size={15} />}{saveProfile ? '本次保存/更新映射方案' : '将本次配置保存为可复用映射方案'}</button>{saveProfile ? <Input label="映射方案名称" onChange={(event) => setProfileName(event.target.value)} placeholder="例如:海南移动签名资料模板" value={profileName} /> : null}</div>
{analysis.rows.length ? <details className="report-import-preview"><summary> {analysis.rows.length} </summary><pre>{JSON.stringify(analysis.rows, null, 2)}</pre></details> : null}
</div> : null}
{error ? <p className="form-error">{error}</p> : null}
</Modal>;
}