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
+233
View File
@@ -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: '有人还没提交,可以先催报再导出'
}