feat(export): 导出与交付(M6)—— 前置检查、目录树预览、分卷打包与 7 天过期清理

客户最关心的那条链:precheck(不通过就没有 force 分支,铁律 10)→ 目录树预览
(与真正打包共用同一份构建代码,预览不会说谎)→ 异步分卷(每卷都是完整可解的 ZIP)
→ 产物落表按行判可下载(过期回「已被清理、请重新导出」)。

契约没有但界面必需的 GET /exports/precheck 一并补上(否则按钮要点一次才知道能不能导)。

顺带修 F-11:severity 口径统一为 INFO=1/WARN=2/ERROR=3(三处注释曾互相矛盾,
按直觉写会把「提示」当阻断,正常数据也导不出来)。

验证:scripts/e2e-m6-export.py 60/60、e2e-m6-export-split.py 33/33;
ZIP 的 UTF-8 标志位(general purpose bit 11)与 sha256 都有断言。
This commit is contained in:
2026-09-30 09:02:50 +08:00
parent 3195dc4d7b
commit 6cca97efe6
18 changed files with 3406 additions and 1 deletions
@@ -0,0 +1,236 @@
<template>
<a-drawer v-model:visible="visible" :width="900" :mask-closable="false" :footer="false"
unmount-on-close @cancel="close">
<template #title>
<span>导出详情</span>
<a-tag v-if="detail" class="ed__head-tag" :color="EXPORT_STATUS[detail.status]?.color ?? 'gray'">
{{ detail.statusText || EXPORT_STATUS[detail.status]?.text }}
</a-tag>
<span v-if="detail" class="ed__head-sub">{{ detail.exportName }}</span>
</template>
<a-spin :loading="loading" class="ed__spin">
<template v-if="detail">
<a-alert v-if="detail.status === 3" type="error" class="ed__alert">
<template #title>打包失败:{{ detail.errorMsg || '未知原因' }}</template>
<template #default>
失败不会产生任何交付物,重新导出即可(**不会**因此产生重复计费或脏数据)。
</template>
</a-alert>
<!-- 进度:处理中每 2 秒自动刷新一次,完成后自动停 -->
<div class="ed__bar">
<a-progress :percent="(detail.progress ?? 0) / 100" :status="progressStatus"
:animation="detail.status === 1" />
<div class="ed__muted">
{{ detail.stepText || EXPORT_STATUS[detail.status]?.text }}
<template v-if="detail.fileCount"> · 共 {{ detail.fileCount }} 人</template>
</div>
</div>
<a-descriptions :column="2" size="small" bordered class="ed__desc">
<a-descriptions-item label="任务">{{ detail.taskName || '—' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ fmt(detail.createTime) }}</a-descriptions-item>
<a-descriptions-item label="完成时间">{{ fmt(detail.finishTime) }}</a-descriptions-item>
<a-descriptions-item label="保留到">{{ fmt(detail.expireTime) }}(到期后需重新导出)</a-descriptions-item>
<a-descriptions-item label="原始体积">
{{ detail.stats?.rawSizeMb ?? '—' }} MB
</a-descriptions-item>
<a-descriptions-item label="包体 / 压缩率">
{{ detail.stats?.finalSizeMb ?? '—' }} MB
<span class="ed__muted">({{ detail.stats?.compressRatio || '—' }})</span>
</a-descriptions-item>
</a-descriptions>
<!-- 产物 -->
<div class="ed__section">产物</div>
<a-empty v-if="!(detail.resultFiles ?? []).length" description="还没有产物(未完成或失败)" />
<a-table v-else :data="detail.resultFiles ?? []" :pagination="false" size="small"
row-key="name" :bordered="{ cell: true }">
<template #columns>
<a-table-column title="文件名" data-index="name" />
<a-table-column title="体积" :width="100" align="center">
<template #cell="{ record }">{{ record.sizeMb }} MB</template>
</a-table-column>
<a-table-column title="校验值(sha256)" :width="240">
<template #cell="{ record }">
<span class="ed__code" :title="record.checksum">{{ shortHash(record.checksum) }}</span>
</template>
</a-table-column>
<a-table-column title="操作" :width="110" align="center">
<template #cell="{ record }">
<a-link :loading="downloading" @click="onDownload(record.part ?? 0)">下载</a-link>
</template>
</a-table-column>
</template>
</a-table>
<div class="ed__muted ed__tip">
下载的是 ZIP 包,解压后目录结构为「{批次号}-{任务名称}/班级/材料类型/文件」+ 根目录的汇总表。
校验值用于核对传输是否完整(本机核对:<span class="ed__code">certutil -hashfile 包名 SHA256</span>)。
</div>
</template>
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
import type { ExportTaskVO } from '@/apis/xt/export-task'
import { Message } from '@arco-design/web-vue'
import { downloadExportFile, EXPORT_STATUS, getExportDetail } from '@/apis/xt/export-task'
/**
* 导出详情(进度 + 产物 + 下载)。
*
* ## 为什么要轮询、以及什么时候停
*
* 打包是后台任务,进度只能轮询。**完成后必须停**:不停的话用户开着这个抽屉
* 就等于一直在打接口;而"忘了停"在演示时看不出来,只在监控里变成莫名其妙的 QPS。
*
* ## 下载为什么要走 axios/fetch 而不是直接给个链接
*
* `/exports/{id}/file` 是要登录的(带 `Authorization` 头),
* 直接 `window.open` 会 401。所以走 `downloadExportFile`(取 blob 再本地触发下载)。
*/
defineOptions({ name: 'ExportDetailDrawer' })
const emit = defineEmits<{
(e: 'changed'): void
}>()
const visible = ref(false)
const loading = ref(false)
const downloading = ref(false)
const detail = ref<ExportTaskVO | null>(null)
const exportId = ref<number>(0)
let timer: number | null = null
const progressStatus = computed(() => {
const s = detail.value?.status
if (s === 3) {
// Arco 的 Progress 状态里"失败"叫 danger(不是 error)
return 'danger'
}
if (s === 2) {
return 'success'
}
return 'normal'
})
function fmt(v: string | null) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
function shortHash(v: string | null) {
if (!v) {
return '—'
}
return v.length > 24 ? `${v.slice(0, 18)}…` : v
}
async function load() {
const res = await getExportDetail(exportId.value)
detail.value = res.data ?? null
// 只在"还在跑"的时候继续轮询;完成/失败各刷一次就停
if (detail.value && (detail.value.status === 0 || detail.value.status === 1)) {
startTimer()
} else {
stopTimer()
}
}
function startTimer() {
stopTimer()
timer = window.setInterval(() => {
load().catch(() => stopTimer())
}, 2000)
}
function stopTimer() {
if (timer !== null) {
window.clearInterval(timer)
timer = null
}
}
async function open(id: number) {
exportId.value = id
visible.value = true
loading.value = true
try {
await load()
} finally {
loading.value = false
}
}
function close() {
stopTimer()
visible.value = false
emit('changed')
}
async function onDownload(fileIndex: number) {
downloading.value = true
try {
await downloadExportFile(exportId.value, fileIndex)
Message.success('已开始下载')
} catch (e) {
Message.error((e as Error).message || '下载失败')
} finally {
downloading.value = false
}
}
onBeforeUnmount(stopTimer)
defineExpose({ open })
</script>
<style lang="scss" scoped>
.ed {
&__head-tag {
margin-left: 8px;
}
&__head-sub {
margin-left: 12px;
font-size: 12px;
font-weight: 400;
color: var(--color-text-3);
}
&__spin {
display: block;
}
&__alert {
margin-bottom: 12px;
}
&__bar {
margin-bottom: 14px;
}
&__section {
margin: 18px 0 8px;
font-size: 14px;
font-weight: 600;
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
&__code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
}
&__tip {
margin-top: 10px;
line-height: 1.8;
}
}
</style>
+536
View File
@@ -0,0 +1,536 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>导出中心</template>
<template #extra>
<span class="exp__hint">交付物:ZIP 包 + 根目录汇总表,目录结构按机构规范生成</span>
</template>
<div class="g-row-tool">
<a-space wrap>
<a-select v-model="taskId" placeholder="选择要导出的任务" size="small" class="exp__task"
:loading="taskLoading" @change="onTaskChange">
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">
{{ t.taskName }}
</a-option>
</a-select>
<a-radio-group v-model="onlyApproved" type="button" size="small" :disabled="!taskId"
@change="reload">
<a-radio :value="false">全部已提交</a-radio>
<a-radio :value="true">仅终审通过</a-radio>
</a-radio-group>
<a-button size="small" :disabled="!taskId" @click="reload">
<template #icon><IconRefresh /></template>
重新检查
</a-button>
</a-space>
<a-space>
<span class="exp__hint">{{ checkedAt ? `上次检查:${checkedAt}` : '选择任务后自动检查' }}</span>
</a-space>
</div>
<a-empty v-if="!taskId" description="先选一个任务 —— 导出是按任务进行的" />
<a-spin v-else :loading="checking">
<!--
⚠️ 这里是**铁律**的落点:canExport=false 时按钮直接禁用,**不弹警告放行**。
清单要具体到"谁 + 哪一项 + 怎么办",否则管理员只能去几百份材料里逐个翻。
-->
<a-alert v-if="precheck && !precheck.canExport" type="error" class="exp__alert">
<template #title>
前置检查未通过:{{ precheck.blocking.length }} 项不合格,<strong>无法导出</strong>。
这是铁律 —— 不合格的文件不允许进入汇总包。
</template>
<template #default>
<div v-for="(a, i) in quickActions" :key="i" class="exp__action">
· {{ a }}
</div>
</template>
</a-alert>
<a-alert v-else-if="precheck" type="success" :show-icon="false" class="exp__alert">
<template #title>
检查通过:<strong>{{ precheck.candidateCount }}</strong> 人、
<strong>{{ precheck.fileCount }}</strong> 个文件(原始
{{ mb(precheck.rawSizeBytes) }} MB)可以打包。
</template>
</a-alert>
<a-alert v-for="(w, i) in precheck?.warnings ?? []" :key="`w${i}`" type="warning"
:show-icon="false" class="exp__alert">
{{ w }}
</a-alert>
<a-row :gutter="12">
<a-col :span="11">
<div class="exp__card">
<div class="exp__card-head">导出设置</div>
<!-- 不引 a-form:这些是"输入即生效"的设置项,没有校验,加一层 form
只会多要一个 model 绑定(Arco 的 a-form 要求 model 必填) -->
<div class="exp__field">
<div class="exp__label">包名(会写进 ZIP 文件名)</div>
<a-input v-model="exportName" placeholder="如 202609机电认定-24中技数控1班"
:max-length="60" show-word-limit />
</div>
<div class="exp__field">
<div class="exp__label">分卷大小</div>
<a-select v-model="splitSizeMb">
<a-option :value="0">不分卷(一期只支持这一项)</a-option>
</a-select>
<div class="exp__hint">分卷还没做:几百人的批次超过 1GB 时会需要它</div>
</div>
<div class="exp__field">
<a-checkbox v-model="desensitize">证件号脱敏导出</a-checkbox>
<div class="exp__hint">
名册里存的本来就是脱敏值,这个开关留给二期(明文导出需二次确认)
</div>
</div>
</div>
<!-- 阻断清单:逐条说清"谁 + 哪一项 + 怎么办" -->
<div v-if="precheck && precheck.blocking.length" class="exp__card">
<div class="exp__card-head">
卡住的地方({{ precheck.blocking.length }})
<span class="exp__hint">
还没审完 {{ precheck.summary.unapproved }} · 缺材料 {{ precheck.summary.missing }} ·
体积超标 {{ precheck.summary.sizeExceeded }} · 名字未规范 {{ precheck.summary.nameInvalid }}
</span>
</div>
<a-table :data="precheck.blocking" :pagination="{ pageSize: 8, size: 'mini' }"
size="mini" row-key="title" :bordered="{ cell: true }">
<template #columns>
<a-table-column title="类型" :width="100" align="center">
<template #cell="{ record }">
<a-tag :color="BLOCKING_TYPE[record.type]?.color ?? 'gray'" size="small">
{{ BLOCKING_TYPE[record.type]?.text ?? record.type }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="问题">
<template #cell="{ record }">
<div>{{ record.title }}</div>
<div class="exp__hint">{{ record.action }}</div>
</template>
</a-table-column>
</template>
</a-table>
</div>
</a-col>
<a-col :span="13">
<div class="exp__card">
<div class="exp__card-head">
目录结构预览
<span class="exp__hint">
{{ tree ? `${tree.personCount} 人 × 各类材料 = ${tree.fileCount} 个文件` : '' }}
</span>
</div>
<!--
目录树用「半角制表符 + CSS 缩进」,**不用全角空格**:
全角空格(U+3000)会被 eslint 的 no-irregular-whitespace 拦下,
而靠空白对齐的写法在 `--fix` 之后必然被重排 —— 那样每次改这个文件
都要打一次仗。缩进交给 `.exp__tree-*` 的 padding-left。
-->
<div v-if="tree" class="exp__tree">
<div class="exp__tree-root">{{ tree.root }}/</div>
<div v-for="c in tree.children" :key="c.path" class="exp__tree-org">
<div class="exp__tree-dir">
<span class="exp__br">|-- </span>{{ c.name }}/
<span class="exp__hint">{{ c.studentCount }} 人</span>
</div>
<div v-for="m in c.children" :key="m.path" class="exp__tree-mat">
<div class="exp__tree-dir2">
<span class="exp__br">|-- </span>{{ m.name }}/
<span class="exp__hint">{{ m.children.length }} 个</span>
</div>
<div v-for="f in m.children.slice(0, 2)" :key="f.path" class="exp__tree-file">
<span class="exp__br">`-- </span>{{ f.name }}
<span class="exp__hint">{{ f.sizeKb }}KB</span>
</div>
<div v-if="m.children.length > 2" class="exp__tree-file exp__hint">
<span class="exp__br">`-- </span>…(其余 {{ m.children.length - 2 }} 个)
</div>
</div>
</div>
<div class="exp__tree-file">
<span class="exp__br">`-- </span>考生线下报名信息导入模板(汇总).xlsx
</div>
</div>
<a-empty v-else description="没有可预览的内容" />
<div class="exp__hint exp__tree-note">
预览与真正打包**共用同一份构建代码**,所以这里看到的就是解压后的样子
(每个材料目录最多列 2 个文件,避免几百人时刷屏)。
</div>
</div>
</a-col>
</a-row>
<!-- 底部动作条:按钮的可用性就是铁律的落点 -->
<div class="exp__footer">
<span class="exp__hint">
导出是后台任务,完成后在这里下载;产物保留 7 天
</span>
<a-space>
<a-tooltip :disabled="canExport" :content="blockReason">
<a-button type="primary" size="small" :disabled="!canExport" :loading="creating"
@click="onCreate">
<template #icon><IconDownload /></template>
开始导出
</a-button>
</a-tooltip>
</a-space>
</div>
</a-spin>
<a-divider />
<div class="exp__card-head">导出记录</div>
<a-table :data="records" :loading="listLoading" row-key="id" size="small"
:pagination="pagination" :scroll="{ x: 900 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="包名 / 任务" :width="240" ellipsis tooltip>
<template #cell="{ record }">
<div class="exp__name">{{ record.exportName }}</div>
<div class="exp__hint">{{ record.taskName || '—' }}</div>
</template>
</a-table-column>
<a-table-column title="状态" :width="100" align="center">
<template #cell="{ record }">
<a-tag :color="EXPORT_STATUS[record.status]?.color ?? 'gray'" size="small">
{{ record.statusText || EXPORT_STATUS[record.status]?.text }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="进度" :width="150">
<template #cell="{ record }">
<a-progress :percent="(record.progress ?? 0) / 100" size="small"
:status="record.status === 3 ? 'danger' : (record.status === 2 ? 'success' : 'normal')" />
</template>
</a-table-column>
<a-table-column title="人数 / 包体" :width="120">
<template #cell="{ record }">
<div>{{ record.fileCount ?? '—' }} 人</div>
<div class="exp__hint">{{ record.stats?.finalSizeMb ?? '—' }} MB</div>
</template>
</a-table-column>
<a-table-column title="创建时间" :width="150">
<template #cell="{ record }">{{ fmt(record.createTime) }}</template>
</a-table-column>
<a-table-column title="操作" :width="160" align="center" fixed="right">
<template #cell="{ record }">
<a-space size="mini">
<a-link @click="onOpen(record)">详情</a-link>
<a-link v-if="record.status === 2 && record.resultFiles?.length"
@click="onQuickDownload(record)">
下载
</a-link>
<a-link v-if="record.status === 3" status="warning" @click="onOpen(record)">看原因</a-link>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<ExportDetailDrawer ref="detailRef" @changed="loadRecords" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { ExportPrecheck, ExportTaskVO, ExportTree } from '@/apis/xt/export-task'
import { Message, Modal } from '@arco-design/web-vue'
import { IconDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
import {
BLOCKING_TYPE,
createExport,
downloadExportFile,
EXPORT_STATUS,
getExportList,
precheckExport,
previewExportTree,
QUICK_ACTION_TEXT
} from '@/apis/xt/export-task'
import { getTaskList } from '@/apis/xt/task'
import ExportDetailDrawer from './ExportDetailDrawer.vue'
/**
* 导出中心(原型 STEP 07,契约 §12.4~§12.6)。
*
* ## 一屏只做一件事:让"不允许带问题打包"变成按钮禁用
*
* 所以这一页的**主逻辑就是前置检查**:进页面先检查、改范围再检查、
* `canExport=false` 就把「开始导出」禁用并把清单列出来。
* 没有"点一下试试看"的分支 —— 也不该有。
*
* ## 目录树预览不是装饰
*
* 客户拿到包才会发现问题(名字不对、少一个人、体积没压下去),
* 而那时已经交出去了。预览把这些提前到"点导出之前",
* 并且它与打包**共用同一份构建代码**,所以不会说谎。
*/
defineOptions({ name: 'Export' })
const taskLoading = ref(false)
const checking = ref(false)
const listLoading = ref(false)
const creating = ref(false)
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
const taskId = ref<number | undefined>(undefined)
const onlyApproved = ref(false)
const exportName = ref('')
const splitSizeMb = ref(0)
const desensitize = ref(false)
const precheck = ref<ExportPrecheck | null>(null)
const tree = ref<ExportTree | null>(null)
const checkedAt = ref('')
const records = ref<ExportTaskVO[]>([])
const total = ref(0)
const query = reactive({ pageNum: 1, pageSize: 10 })
const detailRef = useTemplateRef('detailRef')
const canExport = computed(() => precheck.value?.canExport === true)
const blockReason = computed(() => {
const n = precheck.value?.blocking.length ?? 0
return n ? `有 ${n} 项不合格,先按左侧清单处理` : '前置检查未通过'
})
const quickActions = computed(
() => (precheck.value?.quickActions ?? []).map((k) => QUICK_ACTION_TEXT[k] ?? k)
)
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function fmt(v: string | null) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
function mb(bytes: number | null) {
return bytes ? Math.round(bytes / 1024 / 1024 * 10) / 10 : 0
}
async function loadTasks() {
taskLoading.value = true
try {
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
} finally {
taskLoading.value = false
}
}
/** 前置检查 + 目录树预览一起拉(两者口径必须一致,所以永远是同一次点击触发) */
async function reload() {
if (!taskId.value) {
return
}
checking.value = true
try {
const scope = { onlyApproved: onlyApproved.value }
const [pre, tr] = await Promise.all([
precheckExport(taskId.value, scope),
previewExportTree(taskId.value, scope)
])
precheck.value = pre.data ?? null
tree.value = tr.data ?? null
checkedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
if (!exportName.value) {
const t = taskOptions.value.find((x) => x.id === taskId.value)
if (t) {
exportName.value = t.taskName.slice(0, 60)
}
}
} finally {
checking.value = false
}
}
function onTaskChange() {
exportName.value = ''
precheck.value = null
tree.value = null
query.pageNum = 1
reload()
loadRecords()
}
async function loadRecords() {
listLoading.value = true
try {
const res = await getExportList({
taskId: taskId.value,
pageNum: query.pageNum,
pageSize: query.pageSize
})
records.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
listLoading.value = false
}
}
function onPageChange(page: number) {
query.pageNum = page
loadRecords()
}
async function onCreate() {
if (!taskId.value || !exportName.value.trim()) {
Message.warning('请填写包名')
return
}
creating.value = true
try {
const res = await createExport({
taskId: taskId.value,
exportName: exportName.value.trim(),
scope: { onlyApproved: onlyApproved.value },
splitSizeMb: splitSizeMb.value,
desensitize: desensitize.value,
format: 'ZIP',
generateSummaryExcel: true
})
Message.success('已开始打包,完成后在这里下载')
await loadRecords()
if (res.data?.exportId) {
detailRef.value?.open(res.data.exportId)
}
} catch (e) {
// 40035:前置检查不过。**不带 force**,所以这里只能把清单摊开
const err = e as Error & { bizCode?: number, bizData?: unknown }
if (err.bizCode === 40035 && err.bizData) {
precheck.value = err.bizData as ExportPrecheck
Modal.warning({
title: '还不能导出',
content: `${err.message}\n\n左侧清单列出了卡住的人与项,处理完再点「重新检查」。`,
hideCancel: true
})
}
} finally {
creating.value = false
}
}
function onOpen(row: ExportTaskVO) {
detailRef.value?.open(row.id)
}
async function onQuickDownload(row: ExportTaskVO) {
try {
await downloadExportFile(row.id, 0)
Message.success('已开始下载')
} catch (e) {
Message.error((e as Error).message || '下载失败')
}
}
onMounted(async () => {
await loadTasks()
await loadRecords()
})
</script>
<style lang="scss" scoped>
.exp {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__task {
width: 220px;
}
&__alert {
margin-bottom: 10px;
}
&__action {
font-size: 12px;
}
&__card {
padding: 12px;
margin-bottom: 12px;
background: var(--color-fill-1);
border-radius: 4px;
}
&__card-head {
display: flex;
gap: 8px;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
}
&__tree {
max-height: 320px;
overflow: auto;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.9;
}
&__tree-root {
font-weight: 600;
color: var(--color-text-1);
}
&__tree-dir {
padding-left: 0;
color: rgb(var(--arcoblue-6));
}
&__tree-dir2 {
padding-left: 20px;
color: rgb(var(--arcoblue-5));
}
&__tree-file {
padding-left: 40px;
color: var(--color-text-2);
}
/** 制表符(├─ └─):缩进靠父级的 padding,这里只管样式 */
&__br {
color: var(--color-text-3);
user-select: none;
}
&__tree-note {
margin-top: 8px;
line-height: 1.7;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
margin-top: 6px;
border-top: 1px solid var(--color-border-1);
}
&__name {
font-weight: 500;
color: var(--color-text-1);
}
&__field {
margin-bottom: 12px;
}
&__label {
margin-bottom: 4px;
font-size: 13px;
color: var(--color-text-2);
}
}
</style>