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: '有人还没提交,可以先催报再导出'
|
||||
}
|
||||
Reference in New Issue
Block a user