feat(cleanup): 历史数据整理器(§13.2,V13)—— 解压解析、体检报告、导入为正式任务
从「人长出来的」历史路径里解析姓名/证件号/材料/班级 → 与名册比对 → 按模板重打包 → 体检报告(autoHandled / needManual 由服务端算,前端只展示,不自己减)→ 幂等导入。 F-48 的教训:中文人名与业务短词无法用「包含」区分——噪声词只做精确匹配, 候选名按「完整中文串 2~4 字 + 不含数字 + 不含班级词」判定。 F-49:参数校验必须排在幂等判断之前(否则不合法的请求会因「之前导过一次」变成成功)。 验证:e2e-m7-cleanup 54/54(含「坏包要明确失败」与「无名单时串档恒为 0」)。
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 历史数据整理器(《03-接口设计》§13.2)。
|
||||
*
|
||||
* ## 这个功能的"卖点"就是报告里的一句话
|
||||
*
|
||||
* 契约里写明:**报告里最重要的一句话是 `autoHandled / needManual`** ——
|
||||
* 「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。
|
||||
* 所以这一屏的核心不是"进度条",而是**那两个数字与重点清单**。
|
||||
* 进度只是等待时的安抚,报告才是客户付钱的理由。
|
||||
*
|
||||
* ## 几个界面必须区分清楚的状态
|
||||
*
|
||||
* | status | 界面该显示什么 |
|
||||
* |---|---|
|
||||
* | `0` 排队 / `1` 解析中 / `2` 处理中 | 进度条 + 已处理的文件数;**不给"下载"按钮**(还没有产物) |
|
||||
* | `3` 已完成 | 报告 + 下载结果包 + 导入为正式任务 |
|
||||
* | `4` 失败 | **`errorMsg` 必须显示出来** |
|
||||
*
|
||||
* ⚠️ 失败时 `errorMsg` 是唯一能让用户知道"为什么"的东西。常见原因是
|
||||
* "传了不是 zip 的文件"与"包里单个文件超过 50MB" —— 这两条都在服务端有明确文案,
|
||||
* 界面要做的是**把它原样显示**,而不是笼统说"整理失败"。
|
||||
*
|
||||
* ## 为什么 `rosterTaskId` 这个参数要在界面上强调
|
||||
*
|
||||
* 契约 §13.2 的原话:**没有名册就只能做文件名规范化;有名单才能做证件号校验、
|
||||
* 疑似串档检测** —— 而"疑似串档 1 例"正是报告里最有价值的一条。
|
||||
* 也就是说:关联名册与不关联,"体检报告"是两份完全不同的东西。
|
||||
* 界面必须把这件事说在**上传之前**,而不是等报告出来发现那两项全是 0。
|
||||
*/
|
||||
|
||||
import { getToken } from '@/utils/auth'
|
||||
import http from '@/utils/http'
|
||||
|
||||
/** 整理任务状态。 */
|
||||
export type CleanupStatus = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
export interface CleanupJobVO {
|
||||
id: number
|
||||
jobName: string
|
||||
/** `1` 压缩包 / `2` 文件夹 / `3` 云盘链接(一期只支持 `1`)。 */
|
||||
sourceType: number
|
||||
/** 关联的已有名册任务。**有关联才有证件号校验与串档检测**。 */
|
||||
rosterTaskId?: number | null
|
||||
status: CleanupStatus
|
||||
statusText: string
|
||||
progress: number
|
||||
totalFiles?: number | null
|
||||
processedFiles?: number | null
|
||||
parsedStudents?: number | null
|
||||
parsedMaterials?: number | null
|
||||
rawSizeBytes?: number | null
|
||||
finalSizeBytes?: number | null
|
||||
/** 形如 `91.5%`;没算出来时是 `—`(**不是 0%** —— 0% 看起来像"没压缩")。 */
|
||||
compressRatio?: string
|
||||
resultPath?: string | null
|
||||
/** 导入成正式任务后的任务 id(有值就说明已导入,导入是幂等的)。 */
|
||||
targetTaskId?: number | null
|
||||
errorMsg?: string | null
|
||||
createTime?: string
|
||||
finishTime?: string | null
|
||||
}
|
||||
|
||||
export interface CleanupJobPage {
|
||||
list: CleanupJobVO[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 报告汇总。 */
|
||||
export interface ReportSummary {
|
||||
totalFiles: number
|
||||
nameInvalid: number
|
||||
sizeExceeded: number
|
||||
formatConverted: number
|
||||
idcardMismatch: number
|
||||
idcardAutoFixed: number
|
||||
idcardNeedConfirm: number
|
||||
/** 疑似串档:某人的**多项**材料证件号与名册均不同、且彼此一致 → 整个文件夹可能被换过。 */
|
||||
crossFileSuspected: number
|
||||
lowResolution: number
|
||||
orphanImages: number
|
||||
}
|
||||
|
||||
export interface Highlight {
|
||||
type: string
|
||||
/** `1` 提示 / `2` 警告 / `3` 必须人工。 */
|
||||
level: number
|
||||
rosterId?: number | null
|
||||
realName?: string | null
|
||||
title: string
|
||||
materialCode?: string | null
|
||||
detail?: string | null
|
||||
suggestion?: string | null
|
||||
}
|
||||
|
||||
export interface NameFixSample {
|
||||
original: string
|
||||
fixed: string
|
||||
rule?: string
|
||||
}
|
||||
|
||||
export interface IdcardConflict {
|
||||
rosterId?: number | null
|
||||
realName?: string | null
|
||||
rosterValue?: string | null
|
||||
fileNameValue?: string | null
|
||||
verdict: string
|
||||
verdictText: string
|
||||
}
|
||||
|
||||
export interface CleanupReportVO {
|
||||
jobId: number
|
||||
summary: ReportSummary
|
||||
/** 系统全自动处理了多少(**服务端算的**,不让前端去减)。 */
|
||||
autoHandled: number
|
||||
/** 必须人工判断的(串档 + 无法归位 + 待确认的证件号冲突)。 */
|
||||
needManual: number
|
||||
highlights: Highlight[]
|
||||
nameFixedSamples: NameFixSample[]
|
||||
idcardConflicts: IdcardConflict[]
|
||||
}
|
||||
|
||||
export interface CleanupImportVO {
|
||||
taskId: number
|
||||
rosterCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
export function getCleanupJobs(params: {
|
||||
status?: number
|
||||
keyword?: string
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
return http.get<CleanupJobPage>('/cleanup/jobs', params)
|
||||
}
|
||||
|
||||
export function getCleanupJob(id: number) {
|
||||
return http.get<CleanupJobVO>(`/cleanup/jobs/${id}`)
|
||||
}
|
||||
|
||||
export function getCleanupReport(id: number) {
|
||||
return http.get<CleanupReportVO>(`/cleanup/jobs/${id}/report`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建整理任务(multipart)。
|
||||
*
|
||||
* ⚠️ 超时给到 600s:大包的解压 + 解析 + 重新打包都在服务端同步做,
|
||||
* 而默认 30s 在几百 MB 的包上必然超时 —— 超时的表现是"点了没反应",
|
||||
* 用户会重复点,于是同一个包被整理好几次。
|
||||
*/
|
||||
export function createCleanupJob(file: File, opts: {
|
||||
jobName: string
|
||||
rosterTaskId?: number
|
||||
namingTemplate?: string
|
||||
}) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('jobName', opts.jobName)
|
||||
fd.append('sourceType', '1')
|
||||
if (opts.rosterTaskId !== undefined) {
|
||||
fd.append('rosterTaskId', String(opts.rosterTaskId))
|
||||
}
|
||||
if (opts.namingTemplate) {
|
||||
fd.append('namingTemplate', opts.namingTemplate)
|
||||
}
|
||||
// 不要手动设 Content-Type:axios 会带着 boundary 自己设
|
||||
return http.request<{ jobId: number, status: number, estimatedSeconds: number }>({
|
||||
method: 'post',
|
||||
url: '/cleanup/jobs',
|
||||
data: fd,
|
||||
timeout: 600 * 1000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入为正式任务。
|
||||
*
|
||||
* ⚠️ 这条路**不能带 `importFiles`**:一期还做不到把整理结果里的文件自动挂到填报单,
|
||||
* 而服务端会**如实拒绝**(`40044`)而不是安静忽略。界面因此不要给这个勾选项 ——
|
||||
* 给了一个必然报错的选项,用户只会以为系统坏了。
|
||||
*/
|
||||
export function importCleanupJob(id: number, data: {
|
||||
taskName: string
|
||||
collectMode?: number
|
||||
batchNo: string
|
||||
importRoster?: boolean
|
||||
majorName?: string
|
||||
}) {
|
||||
return http.post<CleanupImportVO>(`/cleanup/jobs/${id}/import`, {
|
||||
collectMode: 1,
|
||||
importRoster: true,
|
||||
...data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正下载整理结果包。
|
||||
*
|
||||
* ⚠️ 必须带 token 取 blob 再本地触发下载:`/cleanup/jobs/{id}/result` 是要登录的,
|
||||
* 直接 `window.open` / `<a href>` 都不会带 `Authorization` 头,
|
||||
* 用户会看到一个 401 页面(或"下载没反应")。
|
||||
*
|
||||
* 放在 api 层而不是页面里,与导出中心的 `downloadExportFile` 保持同一写法 ——
|
||||
* 同一件事在一个项目里有两处实现,改的时候一定会漏一处。
|
||||
*/
|
||||
export async function downloadCleanupResult(id: number, jobName?: string): Promise<void> {
|
||||
const token = getToken()
|
||||
const base = import.meta.env.VITE_API_PREFIX || '/api/v1'
|
||||
const res = await fetch(`${base}/cleanup/jobs/${id}/result`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`下载失败(HTTP ${res.status})`)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const href = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.download = `${jobName || '整理结果'}-${id}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(href)
|
||||
}
|
||||
|
||||
/** 状态 → Tag 颜色(Arco 的 status 只认 normal/warning/success/danger)。 */
|
||||
export function statusColor(status: CleanupStatus): 'normal' | 'warning' | 'success' | 'danger' {
|
||||
switch (status) {
|
||||
case 3:
|
||||
return 'success'
|
||||
case 4:
|
||||
return 'danger'
|
||||
case 1:
|
||||
case 2:
|
||||
return 'warning'
|
||||
default:
|
||||
return 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
/** 重点条目的级别 → Tag 颜色。`3` 是"必须人工",用红色。 */
|
||||
export function levelColor(level: number): 'normal' | 'warning' | 'danger' {
|
||||
if (level >= 3) return 'danger'
|
||||
if (level === 2) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告里"最有说服力的一句话"。
|
||||
*
|
||||
* 契约的原话就是这个格式:**「112 个文件里 90 个系统全自动处理,
|
||||
* 只有 1 个必须你亲自判断」**。所以这句话由前端按同一口径拼 ——
|
||||
* 但它拼的是**服务端算好的两个数**,不是自己减出来的
|
||||
* (前端减出来的数字早晚会与服务端分叉)。
|
||||
*/
|
||||
export function headlineOf(report: CleanupReportVO): string {
|
||||
const total = report.summary.totalFiles
|
||||
const need = report.needManual
|
||||
return `${total} 个文件里,${report.autoHandled} 个系统全自动处理,`
|
||||
// ⚠️ 不要在这里写 Markdown 的 `**粗体**`:它落在**文本节点**里,
|
||||
// 页面上会原样显示两个星号(要粗体得让调用方用富文本渲染)
|
||||
+ `${need > 0 ? `只有 ${need} 个必须你亲自判断` : '没有需要你判断的'}`
|
||||
}
|
||||
Reference in New Issue
Block a user