feat(cleanup): 历史数据整理器(§13.2,V13)—— 解压解析、体检报告、导入为正式任务

从「人长出来的」历史路径里解析姓名/证件号/材料/班级 → 与名册比对 → 按模板重打包 →
体检报告(autoHandled / needManual 由服务端算,前端只展示,不自己减)→ 幂等导入。

F-48 的教训:中文人名与业务短词无法用「包含」区分——噪声词只做精确匹配,
候选名按「完整中文串 2~4 字 + 不含数字 + 不含班级词」判定。
F-49:参数校验必须排在幂等判断之前(否则不合法的请求会因「之前导过一次」变成成功)。

验证:e2e-m7-cleanup 54/54(含「坏包要明确失败」与「无名单时串档恒为 0」)。
This commit is contained in:
2026-09-30 09:02:54 +08:00
parent 7090ee7d65
commit 0d3aae75fa
10 changed files with 2879 additions and 0 deletions
+264
View File
@@ -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} 个必须你亲自判断` : '没有需要你判断的'}`
}
+693
View File
@@ -0,0 +1,693 @@
<template>
<GiPageLayout margin collapsed>
<a-row justify="space-between" class="g-row-tool">
<a-space wrap>
<!-- ⚠️ GiButton **没有 `text` prop**:文案要么用 type 的预设(新增/编辑/…),
要么用**默认插槽**。写成 `text="..."` 不会报错,但页面上显示的是预设文案 -->
<GiButton type="upload" @click="openCreate">上传压缩包整理</GiButton>
<a-button @click="loadRecords">刷新</a-button>
</a-space>
<a-space wrap>
<a-input-search
v-model="query.keyword"
placeholder="按整理任务名称搜索"
allow-clear
style="width: 240px"
@search="onSearch"
/>
<a-select
v-model="query.status"
placeholder="全部状态"
allow-clear
style="width: 140px"
:options="STATUS_OPTIONS"
@change="onSearch"
/>
<a-button @click="onReset">重置</a-button>
</a-space>
</a-row>
<a-table
:data="records"
:loading="listLoading"
row-key="id"
:pagination="pagination"
:bordered="{ cell: true }"
:scroll="{ x: 1100 }"
@page-change="onPageChange"
>
<template #columns>
<a-table-column title="序号" :width="68" align="center">
<template #cell="{ rowIndex }">{{ rowIndex + 1 }}</template>
</a-table-column>
<a-table-column title="整理任务" data-index="jobName" :width="240" ellipsis tooltip />
<a-table-column title="状态" :width="110" align="center">
<template #cell="{ record }">
<a-tag :color="statusColor(record.status)">{{ record.statusText }}</a-tag>
</template>
</a-table-column>
<a-table-column title="进度" :width="150">
<template #cell="{ record }">
<span v-if="record.status === 3">{{ record.totalFiles ?? 0 }} 个文件</span>
<span v-else-if="record.status === 4" class="cl-dim">—</span>
<a-progress v-else :percent="(record.progress || 0) / 100" size="small" />
</template>
</a-table-column>
<a-table-column title="压缩率" :width="100" align="center">
<!--
⚠️ `—` 有**两个**含义,都不要替换成 0%:
① 还没算出来(任务在跑);
② 这个包**本来就不适用压缩** —— 里面的文件已经是压过的(jpg),
或总量太小,重新打 zip 的包头开销比省下来的还多。
实测 3 个 40 字节的小文件:raw=120B、final=802B,
按公式算是 `-568.3%` —— **负数压缩率是没有意义的**,
所以服务端也回 `—`(同一个符号,都是"这个数说明不了什么")。
-->
<template #cell="{ record }">{{ record.compressRatio || '—' }}</template>
</a-table-column>
<a-table-column title="结果" :width="110" align="center">
<template #cell="{ record }">
<a-tag v-if="record.status === 3 && record.targetTaskId" color="success">已导入</a-tag>
<a-tag v-else-if="record.status === 3" color="arcoblue">待导入</a-tag>
<a-tag v-else-if="record.status === 4" color="danger">失败</a-tag>
<span v-else class="cl-dim">—</span>
</template>
</a-table-column>
<a-table-column title="创建时间" data-index="createTime" :width="170" />
<a-table-column title="操作" :width="90" align="center" fixed="right">
<template #cell="{ record }">
<a-button type="text" size="mini" @click="openDetail(record)">详情</a-button>
</template>
</a-table-column>
</template>
</a-table>
<!--
上传弹窗。**刻意先选文件、再填名字**:用户的动作顺序是"我有个包要整理",
而"整理任务名叫什么"是他看到包之后才想的事。
-->
<a-modal
v-model:visible="createVisible"
title="上传压缩包整理"
:mask-closable="false"
:ok-loading="creating"
ok-text="开始整理"
@before-ok="submitCreate"
@close="resetCreate"
>
<a-form :model="createForm" layout="vertical">
<a-form-item field="file" label="压缩包(zip)" required>
<a-upload :auto-upload="false" :limit="1" accept=".zip" @change="onFileChange" />
<template #extra>
<div class="cl-tip">
包里单个文件不超过 50MB、总量不超过 2GB;macOS 的
<code>__MACOSX</code> 会被自动忽略(否则报告里的文件数会虚高)。
</div>
</template>
</a-form-item>
<a-form-item field="jobName" label="整理任务名称" required>
<a-input v-model="createForm.jobName" placeholder="如 24中技数控1班-历史整理" />
</a-form-item>
<a-form-item field="rosterTaskId" label="关联到已有任务的名册">
<a-select
v-model="createForm.rosterTaskId"
placeholder="不关联也能跑,但会少两项关键检查"
allow-clear
allow-search
:loading="taskLoading"
:options="taskOptions"
/>
<!--
⚠️ 这句是这一屏最该说清的话(契约 §13.2 的原话):
没有名册就只能做文件名规范化;**有名单才能做证件号校验、疑似串档检测** ——
而"疑似串档 1 例"正是报告里最有价值的一条。
不说清的话,用户会在报告里看到那两项是 0,然后以为功能没做。
-->
<template #extra>
<div class="cl-tip">
<strong>有关联才有证件号校验与串档检测</strong> ——
「疑似串档 1 例」是体检报告里最有价值的一条,而没有名册就无从比对。
</div>
</template>
</a-form-item>
</a-form>
</a-modal>
<!-- 详情:进度 / 报告 / 导入 -->
<a-drawer
v-model:visible="detailVisible"
:width="760"
:title="detail?.jobName || '整理详情'"
:footer="false"
unmount-on-close
@close="stopPolling"
>
<a-spin :loading="detailLoading" style="display: block">
<template v-if="detail">
<a-descriptions :column="2" bordered size="medium">
<a-descriptions-item label="状态">
<a-tag :color="statusColor(detail.status)">{{ detail.statusText }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="进度">{{ detail.progress }}%</a-descriptions-item>
<a-descriptions-item label="文件数">{{ detail.totalFiles ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="压缩率">
{{ detail.compressRatio || '—' }}
</a-descriptions-item>
<a-descriptions-item label="解析出学员">
{{ detail.parsedStudents ?? '—' }}
</a-descriptions-item>
<a-descriptions-item label="材料种类">
{{ detail.parsedMaterials ?? '—' }}
</a-descriptions-item>
</a-descriptions>
<!--
失败时**必须把 errorMsg 原样显示**:它是唯一能让用户知道"为什么"的东西。
常见原因(不是 zip、单文件超 50MB)服务端都有明确文案,
换成笼统的"整理失败"等于把已经写好的解释扔掉。
-->
<a-alert v-if="detail.status === 4" type="error" class="cl-alert" title="整理失败">
{{ detail.errorMsg || '服务端没有返回原因,请联系管理员查日志' }}
</a-alert>
<a-progress
v-else-if="detail.status !== 3"
:percent="(detail.progress || 0) / 100"
class="cl-alert"
animation
/>
<template v-else>
<div v-if="report" class="cl-headline">
<div class="cl-headline__main">{{ headlineOf(report) }}</div>
<div class="cl-headline__sub">
这一句是报告的全部意义:<strong>系统做了什么</strong>,以及
<strong>还剩多少要人判断</strong>。
</div>
</div>
<a-row v-if="report" :gutter="12" class="cl-stats">
<a-col v-for="s in reportStats" :key="s.label" :span="6">
<div class="cl-stat" :class="{ 'cl-stat--warn': s.warn }">
<div class="cl-stat__num">{{ s.value }}</div>
<div class="cl-stat__label">{{ s.label }}</div>
</div>
</a-col>
</a-row>
<a-divider>重点({{ (report?.highlights || []).length }})</a-divider>
<a-empty v-if="!(report?.highlights || []).length" description="没有需要人工处理的" />
<div v-for="(h, i) in report?.highlights || []" :key="i" class="cl-hl">
<a-space>
<a-tag :color="levelColor(h.level)">
{{ h.level >= 3 ? '必须人工' : h.level === 2 ? '警告' : '提示' }}
</a-tag>
<strong>{{ h.title }}</strong>
<span v-if="h.realName" class="cl-hl__who">{{ h.realName }}</span>
</a-space>
<div class="cl-hl__detail">{{ h.detail }}</div>
<div v-if="h.suggestion" class="cl-hl__suggest">建议:{{ h.suggestion }}</div>
</div>
<a-divider>系统改过的文件名(样例)</a-divider>
<a-empty v-if="!(report?.nameFixedSamples || []).length" description="没有需要改名的" />
<a-table
v-else
:data="report?.nameFixedSamples || []"
:pagination="false"
size="small"
:bordered="{ cell: true }"
>
<template #columns>
<a-table-column title="原名" data-index="original" ellipsis tooltip />
<a-table-column title="改成" data-index="fixed" ellipsis tooltip />
</template>
</a-table>
<a-divider>证件号冲突</a-divider>
<a-empty v-if="!(report?.idcardConflicts || []).length" description="没有证件号冲突" />
<a-table
v-else
:data="report?.idcardConflicts || []"
:pagination="false"
size="small"
:bordered="{ cell: true }"
>
<template #columns>
<a-table-column title="姓名" data-index="realName" :width="100" />
<a-table-column title="名册上的" data-index="rosterValue" :width="160" />
<a-table-column title="文件名里的" data-index="fileNameValue" :width="160" />
<a-table-column title="判定" data-index="verdictText" ellipsis tooltip />
</template>
</a-table>
</template>
</template>
</a-spin>
<div v-if="detail" class="cl-footer">
<a-space>
<a-button v-if="detail.status === 3" type="primary" @click="openImport">
导入为正式任务
</a-button>
<a-button v-if="detail.status === 3" :loading="downloading" @click="downloadResult">
下载整理结果
</a-button>
<a-button @click="refresh">刷新</a-button>
</a-space>
</div>
</a-drawer>
<!-- 导入为正式任务 -->
<a-modal
v-model:visible="importVisible"
title="导入为正式任务"
:mask-closable="false"
:ok-loading="importing"
ok-text="导入"
@before-ok="submitImport"
>
<a-alert type="normal" class="cl-alert">
导入会建一个<strong>草稿</strong>任务,并把解析出的学员建成名册。
<br />
<strong>不会导入文件</strong>:整理结果里的材料要让学生/老师重新传上来
—— 一期还没有「把外部文件挂到指定填报单」的入口。
</a-alert>
<a-form :model="importForm" layout="vertical">
<a-form-item field="taskName" label="新任务名称" required>
<a-input v-model="importForm.taskName" />
</a-form-item>
<a-form-item field="batchNo" label="批次号" required>
<a-input v-model="importForm.batchNo" placeholder="如 202609" />
</a-form-item>
<a-form-item field="majorName" label="专业名称">
<a-input v-model="importForm.majorName" placeholder="留空则从关联名册继承" />
</a-form-item>
</a-form>
</a-modal>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { CleanupJobVO, CleanupReportVO } from '@/apis/xt/cleanup'
import { Message } from '@arco-design/web-vue'
import { computed, onUnmounted, reactive, ref } from 'vue'
import {
createCleanupJob,
downloadCleanupResult,
getCleanupJob,
getCleanupJobs,
getCleanupReport,
headlineOf,
importCleanupJob,
levelColor,
statusColor
} from '@/apis/xt/cleanup'
import { getTaskList } from '@/apis/xt/task'
import { GiButton, GiPageLayout } from '@/components/index'
defineOptions({ name: 'SettingCleanup' })
// ⚠️ 这里用 `a-table + records + 手写分页`,**不用 Gi 模板的 `useTable`**:
// `useTable` 认的是 Gi 的 `{ records, total }`,而我们的契约为 `{ list, total }`。
// 项目里其它 XT 页面(导出中心、审核台…)都是这个写法 —— 保持一致,
// 免得同一个项目里出现两套列表加载方式。
//
// ⚠️ 分页对象**自己 computed**,不用 Gi 的 `usePagination()`:那个 hook 的第一个参数是
// "翻页时重新加载"的回调,而我们已经用 `@page-change` 在重新加载 ——
// 两处都触发会让一次翻页打两次接口(同一个页码打两次)。
// 这个 computed 的写法与 `views/export/index.vue` 一致。
const records = ref<CleanupJobVO[]>([])
const total = ref(0)
const listLoading = ref(false)
const query = reactive<{ pageNum: number, pageSize: number, keyword: string, status?: number }>({
pageNum: 1,
pageSize: 10,
keyword: '',
status: undefined
})
const STATUS_OPTIONS = [
{ label: '排队中', value: 0 },
{ label: '解析中', value: 1 },
{ label: '处理中', value: 2 },
{ label: '已完成', value: 3 },
{ label: '失败', value: 4 }
]
async function loadRecords() {
listLoading.value = true
try {
const res = await getCleanupJobs({
pageNum: query.pageNum,
pageSize: query.pageSize,
keyword: query.keyword || undefined,
status: query.status
})
records.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
listLoading.value = false
}
}
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function onSearch() {
query.pageNum = 1
loadRecords()
}
function onReset() {
query.keyword = ''
query.status = undefined
onSearch()
}
function onPageChange(page: number) {
query.pageNum = page
loadRecords()
}
loadRecords()
// ── 上传 ────────────────────────────────────────
const createVisible = ref(false)
const creating = ref(false)
const createForm = reactive<{
file: File | null
jobName: string
rosterTaskId?: number
}>({ file: null, jobName: '', rosterTaskId: undefined })
const taskLoading = ref(false)
const taskOptions = ref<{ label: string, value: number }[]>([])
function openCreate() {
createVisible.value = true
if (!taskOptions.value.length) {
taskLoading.value = true
getTaskList({ pageNum: 1, pageSize: 200 })
.then((res) => {
taskOptions.value = (res.data?.list || []).map((t) => ({
label: t.taskName,
value: t.id
}))
})
.finally(() => {
taskLoading.value = false
})
}
}
function resetCreate() {
createForm.file = null
createForm.jobName = ''
createForm.rosterTaskId = undefined
}
/** `auto-upload=false` 时 a-upload 给的是 fileList,里面 `file` 才是真正的 File。 */
function onFileChange(_: unknown, currentFile: { file?: File }) {
createForm.file = currentFile?.file || null
}
async function submitCreate() {
if (!createForm.file) {
Message.warning('请先选择一个 zip 压缩包')
return false
}
if (!createForm.jobName.trim()) {
Message.warning('请填写整理任务名称')
return false
}
creating.value = true
try {
const res = await createCleanupJob(createForm.file, {
jobName: createForm.jobName.trim(),
rosterTaskId: createForm.rosterTaskId
})
Message.success(`已开始整理(任务 ${res.data.jobId}),可以关掉这个窗口等它跑完`)
createVisible.value = false
resetCreate()
onSearch()
return true
} catch {
// http 封装已经提示过了;这里只保证弹窗不关掉
return false
} finally {
creating.value = false
}
}
// ── 详情与轮询 ──────────────────────────────────
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<CleanupJobVO | null>(null)
const report = ref<CleanupReportVO | null>(null)
let timer: number | undefined
async function openDetail(row: CleanupJobVO) {
detailVisible.value = true
detail.value = row
report.value = null
await loadDetail(row.id)
// 还在跑就轮询:整理是异步的,而用户会盯着这一屏等结果
if (!detail.value || detail.value.status === 0 || detail.value.status === 1
|| detail.value.status === 2) {
startPolling(row.id)
}
}
async function loadDetail(id: number) {
detailLoading.value = true
try {
const res = await getCleanupJob(id)
detail.value = res.data
if (res.data?.status === 3) {
stopPolling()
await loadReport(id)
} else if (res.data?.status === 4) {
stopPolling()
}
} finally {
detailLoading.value = false
}
}
async function loadReport(id: number) {
try {
const res = await getCleanupReport(id)
report.value = res.data
} catch {
// 报告拿不到不影响下载结果包,所以不打断;用户会看到"重点"那块是空的
report.value = null
}
}
function startPolling(id: number) {
stopPolling()
timer = window.setInterval(() => {
loadDetail(id)
}, 3000)
}
/** 抽屉关掉就停 —— 否则关了还在后台每 3 秒打一次接口。 */
function stopPolling() {
if (timer) {
window.clearInterval(timer)
timer = undefined
}
}
onUnmounted(stopPolling)
async function refresh() {
if (detail.value) {
await loadDetail(detail.value.id)
loadRecords()
}
}
// ── 报告里的数字 ────────────────────────────────
const reportStats = computed(() => {
const s = report.value?.summary
if (!s) return []
return [
{ label: '不规范文件名', value: s.nameInvalid, warn: false },
{ label: '已自动修复证件号', value: s.idcardAutoFixed, warn: false },
{ label: '疑似串档', value: s.crossFileSuspected, warn: s.crossFileSuspected > 0 },
{ label: '无法归位', value: s.orphanImages, warn: s.orphanImages > 0 }
]
})
// ── 下载结果包 ──────────────────────────────────
const downloading = ref(false)
/**
* ⚠️ 走 `fetch` + Blob 而不是 `<a href>`:结果接口要带 Authorization 头,
* 而 `<a href>` 带不了 —— 直接导航过去会 401,用户看到的是"下载没反应"。
*/
async function downloadResult() {
if (!detail.value) return
downloading.value = true
try {
// 下载放 api 层(与导出中心的 downloadExportFile 同一写法):它要带
// Authorization 头取 blob,`<a href>` 带不了 —— 直接导航过去会 401
await downloadCleanupResult(detail.value.id, detail.value.jobName)
} catch (e) {
Message.error((e as Error).message)
} finally {
downloading.value = false
}
}
// ── 导入为正式任务 ──────────────────────────────
const importVisible = ref(false)
const importing = ref(false)
const importForm = reactive({ taskName: '', batchNo: '', majorName: '' })
function openImport() {
if (!detail.value) return
if (detail.value.targetTaskId) {
// 服务端是幂等的(再导一次返回原来那个任务),但先说清更好:
// 否则用户以为点了会建第二个任务
Message.info(
`这份整理已经导入过了(任务 ${detail.value.targetTaskId}),再点会返回同一个任务`
)
}
importForm.taskName = `${detail.value.jobName}-导入`
importForm.batchNo = new Date().toISOString().slice(0, 7).replace('-', '')
importForm.majorName = ''
importVisible.value = true
}
async function submitImport() {
if (!detail.value) return false
if (!importForm.taskName.trim() || !importForm.batchNo.trim()) {
Message.warning('任务名称与批次号都要填')
return false
}
importing.value = true
try {
const res = await importCleanupJob(detail.value.id, {
taskName: importForm.taskName.trim(),
batchNo: importForm.batchNo.trim(),
majorName: importForm.majorName.trim() || undefined
})
Message.success(`已建任务 ${res.data.taskId},名册 ${res.data.rosterCount} 人`)
importVisible.value = false
await refresh()
return true
} catch {
return false
} finally {
importing.value = false
}
}
</script>
<style scoped lang="less">
.cl-tip {
margin-top: 4px;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.6;
}
.cl-dim {
color: var(--color-text-3);
}
.cl-alert {
margin: 12px 0;
}
.cl-footer {
margin-top: 20px;
padding-top: 12px;
border-top: 1px solid var(--color-border-1);
text-align: right;
}
.cl-headline {
margin: 16px 0;
padding: 16px;
background: var(--color-fill-1);
border-left: 3px solid rgb(var(--arcoblue-6));
border-radius: 4px;
&__main {
font-size: 16px;
font-weight: 600;
line-height: 1.5;
}
&__sub {
margin-top: 6px;
color: var(--color-text-3);
font-size: 12px;
}
}
.cl-stats {
margin-bottom: 8px;
}
.cl-stat {
padding: 12px;
background: var(--color-fill-1);
border-radius: 4px;
text-align: center;
&--warn .cl-stat__num {
color: rgb(var(--red-6));
}
&__num {
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
&__label {
margin-top: 4px;
color: var(--color-text-3);
font-size: 12px;
}
}
.cl-hl {
padding: 10px 0;
border-bottom: 1px solid var(--color-border-1);
&__who {
color: var(--color-text-2);
}
&__detail {
margin-top: 4px;
color: var(--color-text-2);
font-size: 13px;
}
&__suggest {
margin-top: 2px;
color: var(--color-text-3);
font-size: 12px;
}
}
</style>