feat: add report material workflows and gateway safeguards
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user