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:
@@ -0,0 +1,233 @@
|
||||
import { getToken } from '@/utils/auth'
|
||||
/**
|
||||
* 导出与交付接口(《03-接口设计》§12.4 ~ §12.6)。
|
||||
*
|
||||
* ## 这一屏的核心是"按钮什么时候不能点"
|
||||
*
|
||||
* PRD 铁律:**没有规范文件名、没有压到 1M 内的文件,不得进入汇总包**,
|
||||
* 而且不允许"警告后放行"。所以界面必须先调 `precheck`(或 `preview`),
|
||||
* `canExport=false` 时**直接禁用**「开始导出」,并把"是谁的哪一项卡住了"列出来 ——
|
||||
* 只说"存在不合格文件"等于没说,管理员会去几百份材料里逐个翻。
|
||||
*
|
||||
* ## 为什么先看目录树再导出
|
||||
*
|
||||
* `preview` 返回的就是最终交付的目录结构(PRD §5.9:根目录 `{批次号}-{任务名称}`
|
||||
* → 班级 → 材料类型 → 文件),**与真正打包共用同一份构建代码**。
|
||||
* 原型 STEP 07 把它放在右侧,就是为了"点导出之前先确认名字对不对、体积标没标"。
|
||||
*
|
||||
* ## 下载的两步
|
||||
*
|
||||
* `getDownload` 只回**信息**(文件名/体积/校验值/有效期),真正下文件走 `/file`;
|
||||
* 后者需要 `Authorization` 头,所以前端必须走 `downloadFile`(带 token 取 blob),
|
||||
* **不能**直接 `window.open(url)` —— 那样浏览器不会带 token,会 401。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
|
||||
/** 导出范围。 */
|
||||
export interface ExportScope {
|
||||
/** 只要这些班;空表示全部 */
|
||||
orgIds?: number[]
|
||||
/** 只要终审通过的(默认 false = 全部已提交的) */
|
||||
onlyApproved?: boolean
|
||||
/** 是否把原始件也打进去(默认 false) */
|
||||
includeRaw?: boolean
|
||||
}
|
||||
|
||||
/** 一条阻断项。 */
|
||||
export interface ExportBlocking {
|
||||
/** `FILE_SIZE` / `NAME_INVALID` / `MISSING_MATERIAL` / `UNAPPROVED` */
|
||||
type: string
|
||||
rosterId: number | null
|
||||
realName: string | null
|
||||
materialCode: string | null
|
||||
fileId: number | null
|
||||
title: string
|
||||
/** 该怎么办(服务端写好了,直接显示) */
|
||||
action: string | null
|
||||
}
|
||||
|
||||
export interface ExportBlockingSummary {
|
||||
sizeExceeded: number
|
||||
nameInvalid: number
|
||||
missing: number
|
||||
unapproved: number
|
||||
}
|
||||
|
||||
/** 前置检查结果。`canExport=false` 时**必须禁用**导出按钮。 */
|
||||
export interface ExportPrecheck {
|
||||
canExport: boolean
|
||||
blocking: ExportBlocking[]
|
||||
summary: ExportBlockingSummary
|
||||
quickActions: string[]
|
||||
/** 不阻断但要说明的提醒(如"有 3 人没提交,不会进包") */
|
||||
warnings: string[]
|
||||
candidateCount: number
|
||||
fileCount: number
|
||||
rawSizeBytes: number
|
||||
}
|
||||
|
||||
/** 目录树节点。 */
|
||||
export interface ExportTreeNode {
|
||||
name: string
|
||||
type: 'DIR' | 'FILE' | string
|
||||
path: string
|
||||
sizeKb: number | null
|
||||
/** 仅班级节点:该班会进包的人数 */
|
||||
studentCount: number | null
|
||||
children: ExportTreeNode[]
|
||||
}
|
||||
|
||||
export interface ExportTree {
|
||||
root: string
|
||||
children: ExportTreeNode[]
|
||||
personCount: number
|
||||
fileCount: number
|
||||
totalSizeBytes: number
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface ExportCreateReq {
|
||||
taskId: number
|
||||
exportName: string
|
||||
scope?: ExportScope
|
||||
splitSizeMb?: number
|
||||
desensitize?: boolean
|
||||
format?: string
|
||||
generateSummaryExcel?: boolean
|
||||
notifyOnFinish?: boolean
|
||||
}
|
||||
|
||||
export interface ExportResultFile {
|
||||
name: string
|
||||
sizeMb: number
|
||||
part: number
|
||||
size: number
|
||||
checksum: string
|
||||
}
|
||||
|
||||
export interface ExportStats {
|
||||
fileCount: number
|
||||
processedCount: number
|
||||
rawSizeMb: number
|
||||
finalSizeMb: number
|
||||
compressRatio: string
|
||||
}
|
||||
|
||||
/** 导出任务(列表与详情共用)。 */
|
||||
export interface ExportTaskVO {
|
||||
id: number
|
||||
taskId: number
|
||||
taskName: string | null
|
||||
exportName: string
|
||||
/** `0` 排队 / `1` 处理中 / `2` 完成 / `3` 失败 */
|
||||
status: number
|
||||
statusText: string
|
||||
progress: number
|
||||
currentStep: string | null
|
||||
stepText: string | null
|
||||
fileCount: number | null
|
||||
stats: ExportStats | null
|
||||
resultFiles: ExportResultFile[] | null
|
||||
errorMsg: string | null
|
||||
createTime: string | null
|
||||
finishTime: string | null
|
||||
expireTime: string | null
|
||||
}
|
||||
|
||||
export interface ExportDownloadInfo {
|
||||
url: string
|
||||
expireSeconds: number
|
||||
fileName: string
|
||||
sizeMb: number
|
||||
checksum: string
|
||||
remainsCachedDays: number
|
||||
}
|
||||
|
||||
/** 只检查、不创建 —— 界面用它决定按钮能不能点。 */
|
||||
export function precheckExport(taskId: number, scope: ExportScope = {}) {
|
||||
return http.get<ExportPrecheck>('/exports/precheck', {
|
||||
taskId,
|
||||
orgIds: scope.orgIds?.length ? scope.orgIds.join(',') : undefined,
|
||||
onlyApproved: scope.onlyApproved
|
||||
})
|
||||
}
|
||||
|
||||
/** 目录树预览(最终交付的样子)。 */
|
||||
export function previewExportTree(taskId: number, scope: ExportScope = {}) {
|
||||
return http.get<ExportTree>('/exports/preview', {
|
||||
taskId,
|
||||
orgIds: scope.orgIds?.length ? scope.orgIds.join(',') : undefined,
|
||||
onlyApproved: scope.onlyApproved
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建导出(前置检查不过返回 40035,并把清单放在 data 里)。 */
|
||||
export function createExport(data: ExportCreateReq) {
|
||||
return http.post<{ exportId: number, status: number, estimatedSeconds: number }>('/exports', data)
|
||||
}
|
||||
|
||||
export function getExportList(params: { taskId?: number, pageNum?: number, pageSize?: number } = {}) {
|
||||
return http.get<{ list: ExportTaskVO[], total: number }>('/exports', params)
|
||||
}
|
||||
|
||||
export function getExportDetail(id: number) {
|
||||
return http.get<ExportTaskVO>(`/exports/${id}`)
|
||||
}
|
||||
|
||||
/** 取下载信息(文件名/体积/校验值/有效期)。 */
|
||||
export function getExportDownload(id: number, fileIndex = 0) {
|
||||
return http.get<ExportDownloadInfo>(`/exports/${id}/download`, { fileIndex })
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正下载文件。
|
||||
*
|
||||
* ⚠️ 必须带 token 取 blob 再本地触发下载:`/exports/{id}/file` 是要登录的,
|
||||
* 直接 `window.open` 不会带 `Authorization` 头,用户会看到一个 401 页面。
|
||||
*/
|
||||
export async function downloadExportFile(id: number, fileIndex = 0): Promise<void> {
|
||||
const info = await getExportDownload(id, fileIndex)
|
||||
const token = getToken()
|
||||
const base = import.meta.env.VITE_API_PREFIX || '/api/v1'
|
||||
// 服务端回的是本服务的绝对路径(/api/v1/exports/{id}/file?...),
|
||||
// 去掉前缀再拼 base,避免出现 /api/v1/api/v1/... 这种双前缀
|
||||
const res = await fetch(`${base}${info.data.url.replace('/api/v1', '')}`, {
|
||||
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 = info.data.fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(href)
|
||||
}
|
||||
|
||||
/** 导出状态 → 文案与颜色。 */
|
||||
export const EXPORT_STATUS: Record<number, { text: string, color: string }> = {
|
||||
0: { text: '排队中', color: 'gray' },
|
||||
1: { text: '处理中', color: 'arcoblue' },
|
||||
2: { text: '已完成', color: 'green' },
|
||||
3: { text: '失败', color: 'red' }
|
||||
}
|
||||
|
||||
/** 阻断类型 → 中文标签。 */
|
||||
export const BLOCKING_TYPE: Record<string, { text: string, color: string }> = {
|
||||
UNAPPROVED: { text: '还没审完', color: 'arcoblue' },
|
||||
MISSING_MATERIAL: { text: '缺必传材料', color: 'red' },
|
||||
FILE_SIZE: { text: '体积超标', color: 'orange' },
|
||||
NAME_INVALID: { text: '文件名未规范', color: 'gold' }
|
||||
}
|
||||
|
||||
/** 可一键做的补救动作 → 人话。 */
|
||||
export const QUICK_ACTION_TEXT: Record<string, string> = {
|
||||
APPROVE_PENDING: '还有没审完的单子,去「审核」把它们审掉',
|
||||
RETRY_COMPRESS: '有材料体积超标,让学生(或班主任代传)重新上传,系统会自动重压',
|
||||
REPROCESS_NAMES: '有文件没走完规范化,删掉重传即可自动改名',
|
||||
NOTIFY_UNSUBMITTED: '有人还没提交,可以先催报再导出'
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user