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} 个必须你亲自判断` : '没有需要你判断的'}`
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user