From 1a7a7245a62229e45753bdd250853b7b2bc1b1c3 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sat, 5 Sep 2026 00:20:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=8A=A5=E5=A4=87?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=98=A0=E5=B0=84=E4=B8=8E=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report-materials/import-review.service.ts | 12 + .../report-material-import-mapping.spec.ts | 90 ++++ .../report-materials.helpers.ts | 12 +- docs/system-functional-test-cases.md | 3 + docs/testing-progress.md | 8 + src/api/admin/channels-reports.api.ts | 8 +- .../admin/AdminEnterpriseSignaturesPage.tsx | 11 +- src/apps/admin/AdminSignatureAuditPage.tsx | 356 +++++++++++++--- src/apps/admin/ReportImportAuditPanel.tsx | 383 ++++++++++++++---- src/apps/admin/ReportMaterialImportModal.css | 16 + .../admin/ReportMaterialImportModal.test.tsx | 77 ++++ src/apps/admin/ReportMaterialImportModal.tsx | 62 ++- 12 files changed, 879 insertions(+), 159 deletions(-) create mode 100644 api/src/report-materials/report-material-import-mapping.spec.ts create mode 100644 src/apps/admin/ReportMaterialImportModal.css diff --git a/api/src/report-materials/import-review.service.ts b/api/src/report-materials/import-review.service.ts index 81ae1fa..b24b008 100644 --- a/api/src/report-materials/import-review.service.ts +++ b/api/src/report-materials/import-review.service.ts @@ -18,6 +18,7 @@ import { hasValue, normalizeImageExtension, imageContentType, + duplicateCoreMappingKind, } from './report-materials.helpers'; import { ReportImportParserService } from './import-parser.service'; import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility'; @@ -36,6 +37,17 @@ export class ReportImportReviewService { if (!batch) throw new NotFoundException('导入批次不存在'); if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入'); if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射'); + const duplicateCoreKind = duplicateCoreMappingKind(data.mappings); + if (duplicateCoreKind) { + const label = { + signatureName: '短信签名', + purpose: '签名用途/依据', + siteName: '站点名称', + url: '引流 URL 或号码', + remark: '备注', + }[duplicateCoreKind]; + throw new BadRequestException(`目标字段“${label}”只能映射一个源列`); + } if (data.profile) await this.importParser.saveImportProfile({ ...data.profile, diff --git a/api/src/report-materials/report-material-import-mapping.spec.ts b/api/src/report-materials/report-material-import-mapping.spec.ts new file mode 100644 index 0000000..358b81e --- /dev/null +++ b/api/src/report-materials/report-material-import-mapping.spec.ts @@ -0,0 +1,90 @@ +import { ReportMaterialsService } from './report-materials.service'; +import { duplicateCoreMappingKind, signatureCoreMapping } from './report-materials.helpers'; + +describe('report material import mappings', () => { + it('distinguishes signature category or purpose columns from the signature name', () => { + expect(signatureCoreMapping('签名/签名')).toMatchObject({ + kind: 'signatureName', + code: 'signature_name', + }); + expect(signatureCoreMapping('签名用途/签名用途')).toMatchObject({ + kind: 'purpose', + code: 'purpose', + }); + expect(signatureCoreMapping('签名类别1营业执照2商标3APP/签名类别')).toMatchObject({ + kind: 'purpose', + code: 'purpose', + }); + }); + + it('rejects duplicate core targets without blocking distinct dynamic fields', () => { + expect( + duplicateCoreMappingKind([ + { + sourceHeader: '签名', + sourceColumnIndex: 1, + targetFieldCode: 'signature_name', + targetKind: 'signatureName', + fieldType: 'string', + }, + { + sourceHeader: '签名类别', + sourceColumnIndex: 2, + targetFieldCode: 'signature_name', + targetKind: 'signatureName', + fieldType: 'string', + }, + ]), + ).toBe('signatureName'); + expect( + duplicateCoreMappingKind([ + { + sourceHeader: '正面', + sourceColumnIndex: 1, + targetFieldCode: 'identity', + targetKind: 'dynamic', + fieldType: 'image', + }, + { + sourceHeader: '反面', + sourceColumnIndex: 2, + targetFieldCode: 'identity', + targetKind: 'dynamic', + fieldType: 'image', + }, + ]), + ).toBeUndefined(); + }); + + it('rejects duplicate core mappings before loading the workbook', async () => { + const prisma = { + reportMaterialImportBatch: { + findUnique: jest.fn().mockResolvedValue({ id: 'batch-duplicate', status: 'analyzed' }), + }, + }; + const files = { getDownload: jest.fn() }; + const service = new ReportMaterialsService(prisma as never, files as never, {} as never); + + await expect( + service.commitImport('batch-duplicate', { + mappings: [ + { + sourceHeader: '签名', + sourceColumnIndex: 1, + targetFieldCode: 'signature_name', + targetKind: 'signatureName', + fieldType: 'string', + }, + { + sourceHeader: '签名类别', + sourceColumnIndex: 2, + targetFieldCode: 'signature_name', + targetKind: 'signatureName', + fieldType: 'string', + }, + ], + }), + ).rejects.toThrow('目标字段“短信签名”只能映射一个源列'); + expect(files.getDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/report-materials/report-materials.helpers.ts b/api/src/report-materials/report-materials.helpers.ts index f392f67..a96b047 100644 --- a/api/src/report-materials/report-materials.helpers.ts +++ b/api/src/report-materials/report-materials.helpers.ts @@ -100,6 +100,16 @@ export function suggestMappings( }); } +export function duplicateCoreMappingKind(mappings: ImportMapping[]) { + const seen = new Set(); + for (const mapping of mappings) { + if (mapping.targetKind === 'dynamic') continue; + if (seen.has(mapping.targetKind)) return mapping.targetKind; + seen.add(mapping.targetKind); + } + return undefined; +} + export function remapProfileColumns( profileColumns: ImportMapping[], sourceColumns: Array<{ @@ -137,8 +147,8 @@ export function remapProfileColumns( export function signatureCoreMapping( header: string, ): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined { + if (/签名(?:用途|依据|类别|类型)|用途/.test(header)) return { code: 'purpose', kind: 'purpose' }; if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true }; - if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' }; return undefined; } diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 67b7820..7e385ee 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5049,6 +5049,9 @@ npm run verify:phase8 | TC-HFQ-008 | 获取企业筛选选项 | 使用轻量options接口,仅返回id/name/code/status且后端过滤deleted,不返回企业认证材料 | | TC-HFQ-009 | 批量导入解析后切换“保存为可复用映射方案” | 控件使用通用按钮外观、图标和清晰选中态;aria-pressed随状态切换,选中后展示方案名称输入框 | | TC-HFQ-010 | 查看签名及引流两级操作按钮 | 报备状态、编辑、删除均使用通用sm按钮高度,删除按钮不再高低不齐 | +| TC-HFQ-011 | 批量导入签名资料,源文件同时包含“签名”和“签名类别/签名用途”列 | 自动映射分别指向“短信签名”和“签名用途/依据”;同一核心目标不得被两个源列重复映射,提交后自动进入本次导入批次审核详情,签名名称不得被类别值覆盖 | +| TC-HFQ-012 | 在导入字段映射中展开或收起“查看前10行解析预览” | 预览内容在弹窗内独立滚动,目标字段下拉框及映射表列宽在展开前后保持不变,桌面及窄屏均不产生页面级横向溢出 | +| TC-HFQ-013 | 使用无扩展的干净浏览器打开批量导入流程并检查控制台 | 应用源码及产物不包含`reportAllChanges`;不得出现平台自身脚本错误。若仅扩展注入的匿名`VM*`脚本报`startTime`错误,应与平台功能错误分开记录,不得通过全局吞错掩盖 | ## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 3fc7007..8ba2574 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4503,3 +4503,11 @@ git diff --check - 新migration已执行,测试环境由95项增至96项;`cmpp-report-material-worker`已安装并保持`active/running`、`NRestarts=0`、`Result=success`。API、Gateway、其他Worker、Nginx、PostgreSQL、Redis、MinIO和Prometheus均正常,发布窗口API及新Worker无error级journal;三条短信相关Redis Stream发布前后均为`pending=0 / lag=0`,没有发送、补发、重投或重新入队短信。 - 真实HTTP上传20,976,525字节的`行业报备.xlsx`在约0.32秒内返回`202 / queued / 10%`,独立Worker随后完成解析并持久化`analyzed / 100% / 解析完成`,结果为13列、16条数据行、43张图片;错误租户查询返回404。将同一任务模拟为UTC口径的超时心跳后重启Worker,任务从70%恢复并重新完成到100%,验证了中断恢复。 - 真实Chrome页面从企业签名管理发起同一WPS文件上传,网络证据为`202 queued 10%`后轮询至`200 analyzed 100%`,映射弹窗显示13列和合计43张图片;1600×1000桌面端及390×844窄屏均无页面级横向溢出,浏览器控制台无warning/error。浏览器会话关闭后,临时管理员已从数据库删除且剩余数为0;验收任务和对应`FileObject`保留为可追溯测试证据。预生产本轮未访问或修改。 + +## 2026-09-05 签名导入映射、审核跳转及预览宽度修复(本地提交) + +- 测试环境两个真实导入批次的只读证据显示:首次批次因源文件没有映射“签名用途”而在审核阶段明确报缺少必填资料;第二次批次把“签名*”和“签名类别*(1-营业执照/2-商标/3-APP)”同时自动映射到`signatureName`,导致19条暂存签名名均被类别值`1`覆盖。解析和提交接口均已成功返回,问题根因是通用“签名”规则先于用途/类别规则匹配,以及核心字段缺少重复映射保护,不是浏览器缓存或异步任务未执行。 +- 自动映射改为优先识别签名用途、依据、类别和类型,再识别签名名称;前后端同时拒绝两个源列映射到同一核心目标字段,动态资料字段仍允许按既有兼容逻辑重复。提交成功后页面直接进入审核中心“导入批次审核”并自动打开本次批次详情,避免用户误以为没有进入审核。 +- 映射弹窗新增页面私有样式约束,解析预览和映射表都使用可收缩容器,长预览内容在自身区域滚动,展开“查看前10行解析预览”不再改变目标字段下拉框宽度;没有向`global.css`新增选择器。 +- 控制台所示`VM*:2`、`reportAllChanges`和`startTime`调用栈不在仓库源码、依赖声明或构建入口中,形态与浏览器动态注入的Web Vitals采集脚本一致。本轮没有增加全局错误吞噬或修改业务代码掩盖该外部异常;后续真实浏览器验收需用无扩展会话区分平台脚本和注入脚本。 +- 定向前端组件测试2项、定向API映射测试3项通过;前端全量13套65项、API全量55套619项通过;前后端TypeScript、API生产构建、Vite生产构建、依赖安全、部署契约、结构质量及增量ESLint通过。Vite仅保留既有Chart分块超过500kB提示,增量ESLint仅保留3条既有Hook依赖warning。本轮按用户最终要求只本地提交,不推送、不部署测试环境或预生产,不操作短信链路、余额、通道及客户配置。 diff --git a/src/api/admin/channels-reports.api.ts b/src/api/admin/channels-reports.api.ts index 7a68d11..90be39f 100644 --- a/src/api/admin/channels-reports.api.ts +++ b/src/api/admin/channels-reports.api.ts @@ -218,7 +218,13 @@ export const adminChannelsReportsApi = { id: string, body: { mappings: ReportImportMapping[]; profile?: Omit & { id?: string } }, ) => - request>(`/admin/report-materials/imports/${id}/commit`, { + request<{ + id: string; + reportType: 'signature' | 'drainage'; + status: string; + successCount: number; + failedCount: number; + }>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body), }), diff --git a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx index e10ce0e..c95d4ba 100644 --- a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx +++ b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx @@ -49,7 +49,6 @@ export function AdminEnterpriseSignaturesPage() { const [tenants, setTenants] = useState([]); const [page, setPage] = useState(1); const [importOpen, setImportOpen] = useState(false); - const [message, setMessage] = useState(''); const [materialChangedSignature, setMaterialChangedSignature] = useState(null); const listRequestSequence = useRef(0); @@ -89,7 +88,9 @@ export function AdminEnterpriseSignaturesPage() { .catch((failure: Error) => { if (!cancelled) setError(failure.message || '企业及应用选项加载失败'); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, []); useEffect(() => { @@ -326,7 +327,6 @@ export function AdminEnterpriseSignaturesPage() { {error ?

{error}

: null} - {message ?

{message}

: null}
setImportOpen(false)} - onCompleted={() => { - setMessage('导入解析完成,合格资料已进入审核中心的导入批次'); - void loadData(); + onCompleted={(result) => { + navigate(`/admin/signatures?tab=import&batchId=${encodeURIComponent(result.id)}&imported=1`); }} /> ) : null} diff --git a/src/apps/admin/AdminSignatureAuditPage.tsx b/src/apps/admin/AdminSignatureAuditPage.tsx index 4327f58..fbaab61 100644 --- a/src/apps/admin/AdminSignatureAuditPage.tsx +++ b/src/apps/admin/AdminSignatureAuditPage.tsx @@ -1,7 +1,24 @@ import { useEffect, useMemo, useState } from 'react'; import { Eye, Search, X } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi'; -import { AuditReviewInfo, Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, RiskAction, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; +import { + AuditReviewInfo, + Breadcrumb, + Button, + DateRangeInput, + FileActions, + Input, + Modal, + RiskAction, + Select, + Table, + Tabs, + Tag, + Textarea, + type DateRangeValue, + type TableColumn, +} from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; import { ReportImportAuditPanel } from './ReportImportAuditPanel'; @@ -13,16 +30,18 @@ const statusMeta: Record { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } 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; + return item.fileObjectId && item.fileName + ? { + fileObjectId: String(item.fileObjectId), + fileName: String(item.fileName), + contentType: item.contentType ? String(item.contentType) : undefined, + } + : null; } function canReviewSignature(item: ClientSmsSignature) { @@ -33,59 +52,142 @@ function SignatureDetail({ item, onClose }: { item: ClientSmsSignature; onClose: const payload = asRecord(item.drainageInfo); const profile = asRecord(payload.signatureProfile); const values = asRecord(payload.signatureReportValues); - const profileFiles = ['credentialFile', 'legalFrontFile', 'legalBackFile', 'responsibleFrontFile', 'responsibleBackFile'] + 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, - }] : []; + 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 关闭} onClose={onClose} open size="xl" title="签名审核详情"> -
-
-
企业{item.tenant?.name ?? item.tenantId}
-
应用{item.application?.name ?? '-'}
-
签名{item.name}
-
审核状态{(statusMeta[item.auditStatus] ?? statusMeta.draft).label}
-
签名依据{String(profile.basis ?? '-')}
-
公司名称{String(profile.companyName ?? '-')}
-
统一社会信用代码{String(profile.creditCode ?? '-')}
-
法人{String(profile.legalPersonName ?? '-')}
-
责任人{String(profile.responsibleName ?? '-')}
-
责任人手机{String(profile.responsiblePhone ?? '-')}
- - {item.rejectReason ?
驳回原因{item.rejectReason}
: null} + return ( + 关闭} onClose={onClose} open size="xl" title="签名审核详情"> +
+
+
+ 企业 + {item.tenant?.name ?? item.tenantId} +
+
+ 应用 + {item.application?.name ?? '-'} +
+
+ 签名 + {item.name} +
+
+ 审核状态 + + {(statusMeta[item.auditStatus] ?? statusMeta.draft).label} + +
+
+ 签名依据 + {String(profile.basis ?? '-')} +
+
+ 公司名称 + {String(profile.companyName ?? '-')} +
+
+ 统一社会信用代码 + {String(profile.creditCode ?? '-')} +
+
+ 法人 + {String(profile.legalPersonName ?? '-')} +
+
+ 责任人 + {String(profile.responsibleName ?? '-')} +
+
+ 责任人手机 + {String(profile.responsiblePhone ?? '-')} +
+ + {item.rejectReason ? ( +
+ 驳回原因 + {item.rejectReason} +
+ ) : null} +
+
+ 资质文件 +
+ {files.length ? ( + files.map((file) => ) + ) : ( + + )} +
+
+
+ 通道动态报备资料 +
+ {Object.entries(values).length ? ( + Object.entries(values).map(([key, value]) => ( +
+ {key} + {fileRef(value) ? : String(value ?? '-')} +
+ )) + ) : ( + + )} +
+
-
资质文件
{files.length ? files.map((file) => ) : }
-
通道动态报备资料
{Object.entries(values).length ? Object.entries(values).map(([key, value]) =>
{key}{fileRef(value) ? : String(value ?? '-')}
) : }
-
- ; + + ); } export function AdminSignatureAuditPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const requestedImportBatchId = searchParams.get('batchId') ?? undefined; + const [activeTab, setActiveTab] = useState(searchParams.get('tab') === 'import' ? 'import' : 'single'); const [items, setItems] = useState([]); const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState('pending'); const [submittedDateRange, setSubmittedDateRange] = useState({}); - const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'pending', submittedDateRange: {} as DateRangeValue }); + const [appliedFilters, setAppliedFilters] = useState({ + keyword: '', + status: 'pending', + submittedDateRange: {} as DateRangeValue, + }); const [detail, setDetail] = useState(); const [rejectTarget, setRejectTarget] = useState(); const [reason, setReason] = useState(''); const [error, setError] = useState(''); function loadData(filters = appliedFilters) { - adminApi.listEnterpriseSignatures({ - keyword: filters.keyword, - status: filters.status === 'all' ? undefined : filters.status, - submittedAtFrom: filters.submittedDateRange.start, - submittedAtTo: filters.submittedDateRange.end, - }) - .then((records) => { setItems(records); setError(''); }) + adminApi + .listEnterpriseSignatures({ + keyword: filters.keyword, + status: filters.status === 'all' ? undefined : filters.status, + submittedAtFrom: filters.submittedDateRange.start, + submittedAtTo: filters.submittedDateRange.end, + }) + .then((records) => { + setItems(records); + setError(''); + }) .catch((failure: Error) => setError(failure.message || '签名审核列表加载失败')); } @@ -95,26 +197,162 @@ export function AdminSignatureAuditPage() { 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 : '签名审核驳回失败'); } + try { + await adminApi.rejectSignature(rejectTarget.id, reason.trim()); + setRejectTarget(undefined); + setReason(''); + loadData(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); + } } - const columns = useMemo>>(() => [ - { key: 'name', title: '签名', render: (record) => {record.name} }, - { 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) => {(statusMeta[record.auditStatus] ?? statusMeta.draft).label} }, - { key: 'actions', title: '操作', align: 'right', render: (record) =>
loadData()} targetId={record.id} targetType="signature" />
}, - ], []); + const columns = useMemo>>( + () => [ + { key: 'name', title: '签名', render: (record) => {record.name} }, + { 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) => ( + + {(statusMeta[record.auditStatus] ?? statusMeta.draft).label} + + ), + }, + { + key: 'actions', + title: '操作', + align: 'right', + render: (record) => ( +
+ + loadData()} + targetId={record.id} + targetType="signature" + /> + +
+ ), + }, + ], + [], + ); - return
-

短信签名审核

- {error ?

{error}

: null} -
setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={} value={keyword} />