fix: 修复报备导入映射与审核跳转
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,16 @@ export function suggestMappings(
|
||||
});
|
||||
}
|
||||
|
||||
export function duplicateCoreMappingKind(mappings: ImportMapping[]) {
|
||||
const seen = new Set<ImportMapping['targetKind']>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 运营看板与配置交互增强
|
||||
|
||||
|
||||
@@ -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。本轮按用户最终要求只本地提交,不推送、不部署测试环境或预生产,不操作短信链路、余额、通道及客户配置。
|
||||
|
||||
@@ -218,7 +218,13 @@ export const adminChannelsReportsApi = {
|
||||
id: string,
|
||||
body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } },
|
||||
) =>
|
||||
request<Record<string, unknown>>(`/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),
|
||||
}),
|
||||
|
||||
@@ -49,7 +49,6 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [materialChangedSignature, setMaterialChangedSignature] = useState<ClientSmsSignature | null>(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() {
|
||||
</div>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
|
||||
<div className="surface section-stack">
|
||||
<Tabs
|
||||
@@ -386,9 +386,8 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
{importOpen ? (
|
||||
<ReportMaterialImportModal
|
||||
onClose={() => setImportOpen(false)}
|
||||
onCompleted={() => {
|
||||
setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
|
||||
void loadData();
|
||||
onCompleted={(result) => {
|
||||
navigate(`/admin/signatures?tab=import&batchId=${encodeURIComponent(result.id)}&imported=1`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -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<string, { label: string; tone: 'neutral' | 'info' | 'su
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function fileRef(value: unknown): FileRef | null {
|
||||
const item = asRecord(value);
|
||||
return item.fileObjectId && item.fileName ? {
|
||||
fileObjectId: String(item.fileObjectId),
|
||||
fileName: String(item.fileName),
|
||||
contentType: item.contentType ? String(item.contentType) : undefined,
|
||||
} : null;
|
||||
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 <Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="签名审核详情">
|
||||
<div className="page-stack">
|
||||
<div className="detail-grid">
|
||||
<div><span>企业</span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{item.application?.name ?? '-'}</strong></div>
|
||||
<div><span>签名</span><strong>{item.name}</strong></div>
|
||||
<div><span>审核状态</span><Tag tone={(statusMeta[item.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[item.auditStatus] ?? statusMeta.draft).label}</Tag></div>
|
||||
<div><span>签名依据</span><strong>{String(profile.basis ?? '-')}</strong></div>
|
||||
<div><span>公司名称</span><strong>{String(profile.companyName ?? '-')}</strong></div>
|
||||
<div><span>统一社会信用代码</span><strong>{String(profile.creditCode ?? '-')}</strong></div>
|
||||
<div><span>法人</span><strong>{String(profile.legalPersonName ?? '-')}</strong></div>
|
||||
<div><span>责任人</span><strong>{String(profile.responsibleName ?? '-')}</strong></div>
|
||||
<div><span>责任人手机</span><strong>{String(profile.responsiblePhone ?? '-')}</strong></div>
|
||||
<AuditReviewInfo targetId={item.id} targetType="sms_signature" />
|
||||
{item.rejectReason ? <div className="detail-grid__wide"><span>驳回原因</span><strong>{item.rejectReason}</strong></div> : null}
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="签名审核详情">
|
||||
<div className="page-stack">
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<span>企业</span>
|
||||
<strong>{item.tenant?.name ?? item.tenantId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>应用</span>
|
||||
<strong>{item.application?.name ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>签名</span>
|
||||
<strong>{item.name}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>审核状态</span>
|
||||
<Tag tone={(statusMeta[item.auditStatus] ?? statusMeta.draft).tone}>
|
||||
{(statusMeta[item.auditStatus] ?? statusMeta.draft).label}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span>签名依据</span>
|
||||
<strong>{String(profile.basis ?? '-')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>公司名称</span>
|
||||
<strong>{String(profile.companyName ?? '-')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>统一社会信用代码</span>
|
||||
<strong>{String(profile.creditCode ?? '-')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>法人</span>
|
||||
<strong>{String(profile.legalPersonName ?? '-')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>责任人</span>
|
||||
<strong>{String(profile.responsibleName ?? '-')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>责任人手机</span>
|
||||
<strong>{String(profile.responsiblePhone ?? '-')}</strong>
|
||||
</div>
|
||||
<AuditReviewInfo targetId={item.id} targetType="sms_signature" />
|
||||
{item.rejectReason ? (
|
||||
<div className="detail-grid__wide">
|
||||
<span>驳回原因</span>
|
||||
<strong>{item.rejectReason}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="surface" style={{ padding: 16 }}>
|
||||
<strong>资质文件</strong>
|
||||
<div className="table-actions" style={{ marginTop: 12 }}>
|
||||
{files.length ? (
|
||||
files.map((file) => <FileActions file={file} key={file.fileObjectId} />)
|
||||
) : (
|
||||
<span className="muted">无</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface" style={{ padding: 16 }}>
|
||||
<strong>通道动态报备资料</strong>
|
||||
<div className="detail-grid" style={{ marginTop: 12 }}>
|
||||
{Object.entries(values).length ? (
|
||||
Object.entries(values).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span>{key}</span>
|
||||
<strong>{fileRef(value) ? <FileActions file={fileRef(value)} /> : String(value ?? '-')}</strong>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="muted">无</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface" style={{ padding: 16 }}><strong>资质文件</strong><div className="table-actions" style={{ marginTop: 12 }}>{files.length ? files.map((file) => <FileActions file={file} key={file.fileObjectId} />) : <span className="muted">无</span>}</div></div>
|
||||
<div className="surface" style={{ padding: 16 }}><strong>通道动态报备资料</strong><div className="detail-grid" style={{ marginTop: 12 }}>{Object.entries(values).length ? Object.entries(values).map(([key, value]) => <div key={key}><span>{key}</span><strong>{fileRef(value) ? <FileActions file={fileRef(value)} /> : String(value ?? '-')}</strong></div>) : <span className="muted">无</span>}</div></div>
|
||||
</div>
|
||||
</Modal>;
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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<ClientSmsSignature[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'pending', submittedDateRange: {} as DateRangeValue });
|
||||
const [appliedFilters, setAppliedFilters] = useState({
|
||||
keyword: '',
|
||||
status: 'pending',
|
||||
submittedDateRange: {} as DateRangeValue,
|
||||
});
|
||||
const [detail, setDetail] = useState<ClientSmsSignature>();
|
||||
const [rejectTarget, setRejectTarget] = useState<ClientSmsSignature>();
|
||||
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<Array<TableColumn<ClientSmsSignature>>>(() => [
|
||||
{ key: 'name', title: '签名', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'tenant', title: '企业', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><RiskAction disabled={!canReviewSignature(record)} onCompleted={() => loadData()} targetId={record.id} targetType="signature" /><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
||||
], []);
|
||||
const columns = useMemo<Array<TableColumn<ClientSmsSignature>>>(
|
||||
() => [
|
||||
{ key: 'name', title: '签名', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'tenant', title: '企业', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => (
|
||||
<Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>
|
||||
{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">
|
||||
详情
|
||||
</Button>
|
||||
<RiskAction
|
||||
disabled={!canReviewSignature(record)}
|
||||
onCompleted={() => loadData()}
|
||||
targetId={record.id}
|
||||
targetType="signature"
|
||||
/>
|
||||
<Button
|
||||
disabled={!canReviewSignature(record)}
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setRejectTarget(record)}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return <section className="page-stack admin-template-audit-page">
|
||||
<div className="page-heading"><div><Breadcrumb items={['审核中心', '短信签名审核']} /><h1>短信签名审核</h1></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Tabs items={[
|
||||
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={() => setAppliedFilters({ keyword: keyword.trim(), status, submittedDateRange })}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); setAppliedFilters({ keyword: '', status: 'pending', submittedDateRange: {} }); }} variant="ghost">重置</Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> },
|
||||
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="signature" /> },
|
||||
]} />
|
||||
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
|
||||
<Modal footer={<><Button onClick={() => setRejectTarget(undefined)} variant="ghost">取消</Button><Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger">确认驳回</Button></>} onClose={() => setRejectTarget(undefined)} open={Boolean(rejectTarget)} title="驳回签名审核"><Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} /></Modal>
|
||||
</section>;
|
||||
return (
|
||||
<section className="page-stack admin-template-audit-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['审核中心', '短信签名审核']} />
|
||||
<h1>短信签名审核</h1>
|
||||
</div>
|
||||
</div>
|
||||
{searchParams.get('imported') === '1' ? (
|
||||
<p className="form-success" role="status">
|
||||
导入资料已提交审核,已为你打开本次导入批次。
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Tabs
|
||||
onChange={(value) => {
|
||||
setActiveTab(value);
|
||||
setSearchParams(value === 'import' ? { tab: 'import' } : {});
|
||||
}}
|
||||
value={activeTab}
|
||||
items={[
|
||||
{
|
||||
label: '单条签名审核',
|
||||
value: 'single',
|
||||
content: (
|
||||
<div className="page-stack">
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="ui-filter-row">
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索企业、应用或签名"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Select
|
||||
label="审核状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
{ label: '草稿', value: 'draft' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="audit-filter-actions ui-filter-actions">
|
||||
<Button
|
||||
icon={<Search size={17} />}
|
||||
onClick={() => setAppliedFilters({ keyword: keyword.trim(), status, submittedDateRange })}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setStatus('pending');
|
||||
setSubmittedDateRange({});
|
||||
setAppliedFilters({ keyword: '', status: 'pending', submittedDateRange: {} });
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '导入批次审核',
|
||||
value: 'import',
|
||||
content: <ReportImportAuditPanel initialBatchId={requestedImportBatchId} reportType="signature" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setRejectTarget(undefined)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger">
|
||||
确认驳回
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setRejectTarget(undefined)}
|
||||
open={Boolean(rejectTarget)}
|
||||
title="驳回签名审核"
|
||||
>
|
||||
<Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} />
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi';
|
||||
import { Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
Textarea,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -32,8 +44,17 @@ function tone(status: string): 'neutral' | 'info' | 'success' | 'warning' | 'dan
|
||||
return status === 'pending_review' ? 'info' : 'neutral';
|
||||
}
|
||||
|
||||
export function ReportImportAuditPanel({ reportType }: { reportType: 'signature' | 'drainage' }) {
|
||||
const [data, setData] = useState<{ items: ReportImportReviewBatch[]; total: number; page: number; pageSize: number }>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
export function ReportImportAuditPanel({
|
||||
reportType,
|
||||
initialBatchId,
|
||||
}: {
|
||||
reportType: 'signature' | 'drainage';
|
||||
initialBatchId?: string;
|
||||
}) {
|
||||
const initialBatchHandled = useRef(false);
|
||||
const [data, setData] = useState<{ items: ReportImportReviewBatch[]; total: number; page: number; pageSize: number }>(
|
||||
{ items: [], total: 0, page: 1, pageSize: 20 },
|
||||
);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const [keyword, setKeyword] = useState('');
|
||||
@@ -47,19 +68,25 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function load(targetPage = page) {
|
||||
adminApi.listReportImportReviewBatches({
|
||||
reportType,
|
||||
status: status === 'all' ? undefined : status,
|
||||
keyword: keyword.trim() || undefined,
|
||||
startAt: submittedDateRange.start,
|
||||
endAt: submittedDateRange.end,
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
}).then((result) => {
|
||||
setData(result);
|
||||
setError('');
|
||||
if (detail) setDetail(result.items.find((item) => item.id === detail.id));
|
||||
}).catch((failure: Error) => setError(failure.message || '导入审核批次加载失败'));
|
||||
adminApi
|
||||
.listReportImportReviewBatches({
|
||||
reportType,
|
||||
status: status === 'all' ? undefined : status,
|
||||
keyword: keyword.trim() || undefined,
|
||||
startAt: submittedDateRange.start,
|
||||
endAt: submittedDateRange.end,
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
})
|
||||
.then((result) => {
|
||||
setData(result);
|
||||
setError('');
|
||||
if (!initialBatchHandled.current && initialBatchId) {
|
||||
setDetail(result.items.find((item) => item.id === initialBatchId));
|
||||
initialBatchHandled.current = true;
|
||||
} else if (detail) setDetail(result.items.find((item) => item.id === detail.id));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '导入审核批次加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,78 +125,268 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ReportImportReviewBatch>>>(() => [
|
||||
{ key: 'file', title: '导入批次/文件', render: (record) => <div><strong>{record.id}</strong><div className="muted">{record.fileName}</div></div> },
|
||||
{ key: 'tenant', title: '企业/应用', render: (record) => <div><strong>{record.tenant?.name ?? record.tenantId}</strong><div className="muted">{record.application?.name ?? '未指定应用'}</div></div> },
|
||||
{ key: 'summary', title: '明细统计', render: (record) => {
|
||||
const approved = record.items.filter((item) => item.status === 'approved').length;
|
||||
const pending = record.items.filter((item) => item.status === 'pending_review').length;
|
||||
const abnormal = record.items.filter((item) => item.status === 'invalid').length;
|
||||
return `共${record.items.length} · 待审${pending} · 通过${approved} · 异常${abnormal}`;
|
||||
} },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={tone(record.status)}>{statusLabels[record.status] ?? record.status}</Tag> },
|
||||
{ key: 'createdAt', title: '导入时间', render: (record) => formatDateTime(record.createdAt) },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <Button icon={<Eye size={15} />} onClick={() => {
|
||||
setDetail(record);
|
||||
setSelected(new Set());
|
||||
}} size="sm" variant="ghost">详情</Button> },
|
||||
], []);
|
||||
const columns = useMemo<Array<TableColumn<ReportImportReviewBatch>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'file',
|
||||
title: '导入批次/文件',
|
||||
render: (record) => (
|
||||
<div>
|
||||
<strong>{record.id}</strong>
|
||||
<div className="muted">{record.fileName}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tenant',
|
||||
title: '企业/应用',
|
||||
render: (record) => (
|
||||
<div>
|
||||
<strong>{record.tenant?.name ?? record.tenantId}</strong>
|
||||
<div className="muted">{record.application?.name ?? '未指定应用'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
title: '明细统计',
|
||||
render: (record) => {
|
||||
const approved = record.items.filter((item) => item.status === 'approved').length;
|
||||
const pending = record.items.filter((item) => item.status === 'pending_review').length;
|
||||
const abnormal = record.items.filter((item) => item.status === 'invalid').length;
|
||||
return `共${record.items.length} · 待审${pending} · 通过${approved} · 异常${abnormal}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => <Tag tone={tone(record.status)}>{statusLabels[record.status] ?? record.status}</Tag>,
|
||||
},
|
||||
{ key: 'createdAt', title: '导入时间', render: (record) => formatDateTime(record.createdAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() => {
|
||||
setDetail(record);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const itemColumns: Array<TableColumn<ReportImportReviewItem>> = [
|
||||
{ key: 'select', title: '', width: '48px', render: (item) => <input aria-label={`选择第${item.rowNumber}行`} checked={selected.has(item.id)} disabled={item.status !== 'pending_review'} onChange={() => setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
})} type="checkbox" /> },
|
||||
{
|
||||
key: 'select',
|
||||
title: '',
|
||||
width: '48px',
|
||||
render: (item) => (
|
||||
<input
|
||||
aria-label={`选择第${item.rowNumber}行`}
|
||||
checked={selected.has(item.id)}
|
||||
disabled={item.status !== 'pending_review'}
|
||||
onChange={() =>
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
else next.add(item.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'row', title: '文件行', width: '80px', render: (item) => `第${item.rowNumber}行` },
|
||||
{ key: 'name', title: reportType === 'signature' ? '签名' : '引流url或号码', render: (item) => <strong>{itemName(item)}</strong> },
|
||||
{ key: 'operation', title: '变更类型', width: '100px', render: (item) => item.operation === 'create' ? '新增' : item.operation === 'update' ? '修改' : '无效' },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (item) => <Tag tone={tone(item.status)}>{itemStatusLabels[item.status] ?? item.status}</Tag> },
|
||||
{
|
||||
key: 'name',
|
||||
title: reportType === 'signature' ? '签名' : '引流url或号码',
|
||||
render: (item) => <strong>{itemName(item)}</strong>,
|
||||
},
|
||||
{
|
||||
key: 'operation',
|
||||
title: '变更类型',
|
||||
width: '100px',
|
||||
render: (item) => (item.operation === 'create' ? '新增' : item.operation === 'update' ? '修改' : '无效'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '110px',
|
||||
render: (item) => <Tag tone={tone(item.status)}>{itemStatusLabels[item.status] ?? item.status}</Tag>,
|
||||
},
|
||||
{ key: 'reason', title: '说明', render: (item) => item.errorMessage || item.reviewReason || '-' },
|
||||
];
|
||||
|
||||
return <div className="page-stack">
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="ui-filter-row">
|
||||
<Input label="批次号/文件名" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索导入批次或文件" value={keyword} />
|
||||
<Select label="批次状态" onChange={(event) => { setStatus(event.target.value); setPage(1); }} options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending_review' },
|
||||
{ label: '部分已审核', value: 'partially_reviewed' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '部分通过', value: 'partially_approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
]} value={status} />
|
||||
<DateRangeInput label="提交时间" onChange={(value) => { setSubmittedDateRange(value); setPage(1); }} value={submittedDateRange} />
|
||||
<div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); load(1); }}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('all'); setSubmittedDateRange({}); setPage(1); }} variant="ghost">重置</Button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface"><Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" /></div>
|
||||
<Pagination
|
||||
nextDisabled={page * pageSize >= data.total}
|
||||
onNext={() => setPage((current) => current + 1)}
|
||||
onPageChange={setPage}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
total={data.total}
|
||||
totalPages={Math.max(1, Math.ceil(data.total / pageSize))}
|
||||
/>
|
||||
{detail ? <Modal footer={<><Button onClick={() => setDetail(undefined)} variant="ghost">关闭</Button><Button disabled={busy || pendingItems.length === 0} icon={<X size={15} />} onClick={() => setRejectOpen(true)} variant="danger">{selected.size ? `驳回所选(${selected.size})` : `驳回全部可审核项(${pendingItems.length})`}</Button><Button disabled={busy || pendingItems.length === 0} icon={<Check size={15} />} onClick={() => void review('approve')} variant="success">{selected.size ? `通过所选(${selected.size})` : `通过全部可审核项(${pendingItems.length})`}</Button></>} onClose={() => setDetail(undefined)} open size="xl" title={`${reportType === 'signature' ? '签名' : '引流信息'}导入批次详情`}>
|
||||
<div className="page-stack">
|
||||
<div className="detail-grid">
|
||||
<div><span>导入文件</span><strong>{detail.fileName}</strong></div>
|
||||
<div><span>企业</span><strong>{detail.tenant?.name ?? detail.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{detail.application?.name ?? '未指定应用'}</strong></div>
|
||||
<div><span>导入时间</span><strong>{formatDateTime(detail.createdAt)}</strong></div>
|
||||
return (
|
||||
<div className="page-stack">
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="ui-filter-row">
|
||||
<Input
|
||||
label="批次号/文件名"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索导入批次或文件"
|
||||
value={keyword}
|
||||
/>
|
||||
<Select
|
||||
label="批次状态"
|
||||
onChange={(event) => {
|
||||
setStatus(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending_review' },
|
||||
{ label: '部分已审核', value: 'partially_reviewed' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '部分通过', value: 'partially_approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<DateRangeInput
|
||||
label="提交时间"
|
||||
onChange={(value) => {
|
||||
setSubmittedDateRange(value);
|
||||
setPage(1);
|
||||
}}
|
||||
value={submittedDateRange}
|
||||
/>
|
||||
<div className="audit-filter-actions ui-filter-actions">
|
||||
<Button
|
||||
icon={<Search size={16} />}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
load(1);
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setStatus('all');
|
||||
setSubmittedDateRange({});
|
||||
setPage(1);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="table-actions"><input checked={allPendingSelected} onChange={() => setSelected(allPendingSelected ? new Set() : new Set(pendingItems.map((item) => item.id)))} type="checkbox" />选择全部待审核项</label>
|
||||
<Table columns={itemColumns} data={detail.items} emptyText="暂无导入明细" pagination={false} rowKey="id" />
|
||||
</div>
|
||||
</Modal> : null}
|
||||
{rejectOpen ? <Modal footer={<><Button onClick={() => setRejectOpen(false)} variant="ghost">取消</Button><Button disabled={busy} onClick={() => void review('reject')} variant="danger">确认驳回</Button></>} onClose={() => setRejectOpen(false)} open title="驳回导入资料">
|
||||
<Textarea label="驳回原因(选填)" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} />
|
||||
</Modal> : null}
|
||||
</div>;
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" />
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={page * pageSize >= data.total}
|
||||
onNext={() => setPage((current) => current + 1)}
|
||||
onPageChange={setPage}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
total={data.total}
|
||||
totalPages={Math.max(1, Math.ceil(data.total / pageSize))}
|
||||
/>
|
||||
{detail ? (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDetail(undefined)} variant="ghost">
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || pendingItems.length === 0}
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
variant="danger"
|
||||
>
|
||||
{selected.size ? `驳回所选(${selected.size})` : `驳回全部可审核项(${pendingItems.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || pendingItems.length === 0}
|
||||
icon={<Check size={15} />}
|
||||
onClick={() => void review('approve')}
|
||||
variant="success"
|
||||
>
|
||||
{selected.size ? `通过所选(${selected.size})` : `通过全部可审核项(${pendingItems.length})`}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setDetail(undefined)}
|
||||
open
|
||||
size="xl"
|
||||
title={`${reportType === 'signature' ? '签名' : '引流信息'}导入批次详情`}
|
||||
>
|
||||
<div className="page-stack">
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<span>导入文件</span>
|
||||
<strong>{detail.fileName}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>企业</span>
|
||||
<strong>{detail.tenant?.name ?? detail.tenantId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>应用</span>
|
||||
<strong>{detail.application?.name ?? '未指定应用'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>导入时间</span>
|
||||
<strong>{formatDateTime(detail.createdAt)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<label className="table-actions">
|
||||
<input
|
||||
checked={allPendingSelected}
|
||||
onChange={() =>
|
||||
setSelected(allPendingSelected ? new Set() : new Set(pendingItems.map((item) => item.id)))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
选择全部待审核项
|
||||
</label>
|
||||
<Table columns={itemColumns} data={detail.items} emptyText="暂无导入明细" pagination={false} rowKey="id" />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
{rejectOpen ? (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setRejectOpen(false)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void review('reject')} variant="danger">
|
||||
确认驳回
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
open
|
||||
title="驳回导入资料"
|
||||
>
|
||||
<Textarea
|
||||
label="驳回原因(选填)"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
rows={4}
|
||||
value={reason}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.report-material-import-modal .report-import-mapping,
|
||||
.report-material-import-modal .report-import-mapping-table,
|
||||
.report-material-import-modal .report-import-preview {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-material-import-modal .report-import-mapping-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.report-material-import-modal .report-import-preview pre {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
||||
const { adminApi } = vi.hoisted(() => ({
|
||||
adminApi: {
|
||||
analyzeReportMaterialImport: vi.fn(),
|
||||
commitReportMaterialImport: vi.fn(),
|
||||
getReportMaterialImportAnalysisStatus: vi.fn(),
|
||||
listDrainageFields: vi.fn(),
|
||||
listEnterpriseApplicationOptions: vi.fn(),
|
||||
@@ -43,6 +44,13 @@ describe('ReportMaterialImportModal mapping profile action', () => {
|
||||
rows: [],
|
||||
suggestedMappings: [],
|
||||
});
|
||||
adminApi.commitReportMaterialImport.mockResolvedValue({
|
||||
id: 'analysis-1',
|
||||
reportType: 'signature',
|
||||
status: 'pending_review',
|
||||
successCount: 1,
|
||||
failedCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the reusable mapping choice as a clear pressed-state shared button', async () => {
|
||||
@@ -70,4 +78,73 @@ describe('ReportMaterialImportModal mapping profile action', () => {
|
||||
);
|
||||
expect(screen.getByLabelText('映射方案名称')).toBeVisible();
|
||||
}, 10_000);
|
||||
|
||||
it('keeps signature category separate from the signature name and submits the review batch', async () => {
|
||||
adminApi.getReportMaterialImportAnalysisStatus.mockResolvedValue({
|
||||
id: 'analysis-1',
|
||||
status: 'analyzed',
|
||||
progress: 100,
|
||||
progressStage: '解析完成',
|
||||
columns: [
|
||||
{ sourceColumnIndex: 1, columnLetter: 'A', sourceHeader: '签名*', sourceHeaderPath: '签名*', imageCount: 0 },
|
||||
{
|
||||
sourceColumnIndex: 2,
|
||||
columnLetter: 'B',
|
||||
sourceHeader: '签名类别*',
|
||||
sourceHeaderPath: '签名类别*\n1-营业执照\n2-商标\n3-APP',
|
||||
imageCount: 0,
|
||||
},
|
||||
],
|
||||
rows: [{ rowNumber: 2, values: { 1: '【真实签名】', 2: '1' }, imageColumns: [] }],
|
||||
suggestedMappings: [
|
||||
{
|
||||
sourceHeader: '签名*',
|
||||
sourceHeaderPath: '签名*',
|
||||
sourceColumnIndex: 1,
|
||||
targetFieldCode: 'signature_name',
|
||||
targetKind: 'signatureName',
|
||||
fieldType: 'string',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
sourceHeader: '签名类别*',
|
||||
sourceHeaderPath: '签名类别*\n1-营业执照\n2-商标\n3-APP',
|
||||
sourceColumnIndex: 2,
|
||||
targetFieldCode: 'purpose',
|
||||
targetKind: 'purpose',
|
||||
fieldType: 'string',
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const onCompleted = vi.fn();
|
||||
render(<ReportMaterialImportModal onClose={vi.fn()} onCompleted={onCompleted} />);
|
||||
await waitFor(() => expect(adminApi.listTenantOptions).toHaveBeenCalledTimes(1));
|
||||
await user.click(screen.getByText('所属企业').closest('label')!.querySelector('button')!);
|
||||
await user.click(screen.getByRole('option', { name: /测试企业/ }));
|
||||
await user.click(screen.getByText('企业应用(必选)').closest('label')!.querySelector('button')!);
|
||||
await user.click(screen.getByRole('option', { name: '测试应用' }));
|
||||
fireEvent.change(document.querySelector('input[type="file"]')!, {
|
||||
target: { files: [new File(['xlsx'], 'mapping.xlsx')] },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '解析文件并配置映射' }));
|
||||
|
||||
const preview = await screen.findByText('查看前 1 行解析预览');
|
||||
await user.click(preview);
|
||||
expect(preview.closest('details')).toHaveAttribute('open');
|
||||
await user.click(screen.getByRole('button', { name: '提交导入审核' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.commitReportMaterialImport).toHaveBeenCalledWith(
|
||||
'analysis-1',
|
||||
expect.objectContaining({
|
||||
mappings: expect.arrayContaining([
|
||||
expect.objectContaining({ targetKind: 'signatureName', targetFieldCode: 'signature_name' }),
|
||||
expect.objectContaining({ targetKind: 'purpose', targetFieldCode: 'purpose' }),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(onCompleted).toHaveBeenCalledWith(expect.objectContaining({ id: 'analysis-1', status: 'pending_review' }));
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type TenantOption,
|
||||
} from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import './ReportMaterialImportModal.css';
|
||||
|
||||
type ReportType = 'signature' | 'drainage';
|
||||
type AnalyzeResult = {
|
||||
@@ -26,6 +27,21 @@ type AnalyzeResult = {
|
||||
suggestedMappings: ReportImportMapping[];
|
||||
};
|
||||
type AnalysisProgress = { id: string; status: string; progress: number; progressStage?: string; errorMessage?: string };
|
||||
type ImportCompleted = {
|
||||
id: string;
|
||||
reportType: ReportType;
|
||||
status: string;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
|
||||
const coreTargetLabels: Record<Exclude<ReportImportMapping['targetKind'], 'dynamic'>, string> = {
|
||||
signatureName: '短信签名',
|
||||
purpose: '签名用途/依据',
|
||||
siteName: '站点名称',
|
||||
url: '引流 URL 或号码',
|
||||
remark: '备注',
|
||||
};
|
||||
|
||||
const transforms = [
|
||||
{ label: '保持原值', value: '' },
|
||||
@@ -48,7 +64,13 @@ function coreTargets(reportType: ReportType) {
|
||||
];
|
||||
}
|
||||
|
||||
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
|
||||
export function ReportMaterialImportModal({
|
||||
onClose,
|
||||
onCompleted,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCompleted: (result: ImportCompleted) => void;
|
||||
}) {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
|
||||
@@ -169,14 +191,26 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
|
||||
}
|
||||
|
||||
function setTarget(column: AnalyzeResult['columns'][number], encoded: string) {
|
||||
if (!encoded) {
|
||||
setMappings((current) => current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex));
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [
|
||||
ReportImportMapping['targetKind'],
|
||||
string,
|
||||
ReportImportMapping['fieldType'],
|
||||
];
|
||||
if (
|
||||
targetKind !== 'dynamic' &&
|
||||
mappings.some((item) => item.sourceColumnIndex !== column.sourceColumnIndex && item.targetKind === targetKind)
|
||||
) {
|
||||
setError(`目标字段“${coreTargetLabels[targetKind]}”只能映射一个源列`);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
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,
|
||||
{
|
||||
@@ -208,10 +242,19 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
|
||||
setError('请输入映射方案名称');
|
||||
return;
|
||||
}
|
||||
const duplicateCoreKind = mappings.find(
|
||||
(mapping, index) =>
|
||||
mapping.targetKind !== 'dynamic' &&
|
||||
mappings.findIndex((candidate) => candidate.targetKind === mapping.targetKind) !== index,
|
||||
)?.targetKind;
|
||||
if (duplicateCoreKind && duplicateCoreKind !== 'dynamic') {
|
||||
setError(`目标字段“${coreTargetLabels[duplicateCoreKind]}”只能映射一个源列`);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await adminApi.commitReportMaterialImport(analysis.id, {
|
||||
const result = await adminApi.commitReportMaterialImport(analysis.id, {
|
||||
mappings,
|
||||
profile: saveProfile
|
||||
? {
|
||||
@@ -227,7 +270,7 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
onCompleted();
|
||||
onCompleted(result);
|
||||
onClose();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '导入失败');
|
||||
@@ -247,6 +290,7 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="report-material-import-modal"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
|
||||
Reference in New Issue
Block a user