feat(roster): 名册导入前端 —— 任务发布现在真的能在界面上走通了

第 2 步。在此之前「配置 → 任务就绪」缺了**名册**那一环:班级能配、材料能配、
表单能配,但名册只能靠 SQL 造,于是任务在界面上**根本发布不了**(40016 名册非空)。

一、新增(frontend-pc/)
   src/apis/xt/roster.ts               导入(multipart)+ 分页 + 类型 + 状态字典
   src/views/task/TaskRosterDrawer.vue 「名单管理」抽屉:导入 → 体检报告 → 名册列表
   views/task/index.vue                 加「名单管理」入口;已截止/已归档只读仍可查看
   config/xt-routes.ts                  顶层「名单管理」菜单说明改为指向任务行

二、★ 验证了"发布真的通"(e2e-m2-roster.py 第 8 节)
   配覆盖班级 → 配必传材料 → **发布成功** → 状态变「收集中」→ stat 仍带分母。
   force=true 只跳过 P1 提醒,那 5 项阻断级校验 force 也绕不过 —— 所以是真链路。

三、界面上五个刻意的设计
   1. 先体检再导入(两步):实测表头在第 1 行或第 6 行,一步导入只会看到
      "成功 0 条",然后以为文件坏了
   2. 明说「前面那 5 行是标题与说明,已跳过,这不是导错了」—— 不解释的话
      用户会去改自己那份本来正确的表格
   3. 行号 + 列字母 + 原值 + 原因四样一起给,用户拿着就能回去改 Excel
   4. dryRun 默认 true(服务端也默认 true):不显式要求就不写库
   5. 阻断行与警告行分两张表:混在一起会让人以为整批都没进
   原因后面跟**机器码**而不是中文标签(第一版给中文,和上面那句原因重复了一遍)

四、顺带修掉一处「界面在说谎」
   名册导进来了,任务列表却还显示「待导入名册」—— TaskStatVO 恒为 null 是 M1 的
   既有缺口(注释写着"名册为空时返回 null,由界面显示待导入名册")。现在名册有了:
   · TaskService.fillStats():一次算一页(两条批量查询,不逐任务查)
   · **名册为空时仍返回 null**,口径没有放宽 —— 显示 0% 看起来像"没人填",
     而真相是"还没有分母"
   · abnormalCount / avgAuditHours 仍为 null(要 M4 的校验表 / M5 的审核记录)
   · e2e-m1-task 那条断言改成措辞更准的"名册为空时 stat 为 null"(意图不变),
     并在 e2e-m2-roster 补 4 条"名册有值后 stat 该有值"
   这条也是第 2 步**自己制造**的问题:能导名册之后,"待导入名册"从诚实变成说谎。

五、为什么顶层「名单管理」菜单还是"待建"
   名册是**按任务**的,独立页得先选任务(多一步且容易选错);它和「配置班级」
   「材料规则」「表单设计」是同一层的配置。报考范围确认(M2-04/05)会接着做进
   同一个抽屉,那时也不需要新页面。所以改的是那句已过期的"对应里程碑 M2"。

六、浏览器验证(真链路,只绕开"手指点选")
   文件框没法自动化,但可以把夹具放到 frontend-pc/public/ 再喂进去:
     fetch 字节 → new File → DataTransfer → input.files → dispatch change
   实测:3 人被正确导入、脱敏证件号、报考三项、扩展字段、状态列、搜索全对。
   夹具用完即删。

七、回归
   全量 622 条断言、0 失败(名册 E2E 62 → 67)
   frontend-pc: vue-tsc 0 错、eslint 目标文件 0 错
   verify-docs.py 19 项全通过

八、两个流程教训(已写进交接摘要)
   · `Test-Path jar` 不能当作"打包成功":这次 mvn 失败而旧 jar 还在,
     于是检查得到"打包 ✓",测试对着旧 jar 跑,四条新断言全红。
     必须看退出码 / BUILD SUCCESS。
   · `call()` 返回的是 (status, body) 元组不是字典 —— 我新写的那节忘了解包。
     与第 1 步"已存在判断写反"同类:新写的代码最容易在细节上错。

九、下一步(第 3 步)
   报考范围聚合与一致性体检(M2-04/05)—— 产品的核心增量。
   先确认:sys_eval_plan 那几张"评价计划"表还没落地(FR-311 未做),
   需要先定"计划从哪来"。
This commit is contained in:
2026-09-29 17:17:55 +08:00
parent fdfe21345a
commit e85a25a97f
11 changed files with 1352 additions and 27 deletions
+190
View File
@@ -0,0 +1,190 @@
/**
* 名册接口(《03-接口设计》§8.1 ~ §8.2)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 解包后 `http.get<T>()` 得到 `{ code: 200, data: T }`。
*
* ## 三件界面上必须照顾到的事
*
* 1. **表头行不固定**。实测两份真实文件的表头分别在第 1 行和第 6 行,工作表名也不同。
* 所以导入接口不是"传个文件就完事":它先回一份**体检报告**(表头在第几行、
* 每个候选命中几列、每列映射到哪个字段),由界面呈现给人确认,
* 确认之后才用 `dryRun=false` 真导入。
* ⚠️ 界面上要把"系统在第 6 行找到表头"讲清楚 —— 否则用户看到"第 6 行"
* 会以为系统导错了,而真相是**前 5 行本来就是标题与说明**。
*
* 2. **部分成功是设计目标**。42 行里有 2 行号码写错,不该让另外 40 个人白等,
* 但也不能把那 2 行悄悄丢掉。所以 `errors` / `warnings` 是**逐行**的
* (行号 + 列字母 + 原值 + 原因),界面必须让用户能拿着行号回去改 Excel。
*
* 3. **证件号一律只有脱敏值**。列表里是 `idCardMask`,报错里的 `value` 也是脱敏的 ——
* 界面上不需要(也不该)显示完整号码。
*/
import http from '@/utils/http'
/** 一列的映射结果。 */
export interface RosterColumnReport {
/** 列字母(`A`~),用户能对着 Excel 找 */
col: string
/** 表头原文 */
title: string
/** 落到哪:官方字段名,或 `ext_json.xxx`;null = 忽略该列 */
mappedTo: string | null
/** 是否识别为官方 21 列之一 */
matched: boolean
}
/** 一处问题。 */
export interface RosterRowIssue {
/** Excel 里的真实行号(从 1 开始) */
row: number
col: string | null
field: string
/** 原值(证件号已脱敏) */
value: string | null
/** 人话原因,直接显示 */
reason: string
/** 机器码:IDCARD_CHECKSUM / DUP_IDCARD / ROW_LOCKED … */
code: string
}
export interface RosterImportSummary {
uniquePersons: number
dupRows: number
idcardInvalid: number
nameSuspect: number
phoneInvalid: number
}
/** 导入体检报告(§8.1)。`dryRun=true` 时 `created`/`updated` 是"如果真导会怎样"。 */
export interface RosterImportReport {
total: number
success: number
failed: number
headerRow: number
sheetName: string | null
headerCandidates: { row: number, score: number }[]
headerConfirmed: boolean
columns: RosterColumnReport[]
errors: RosterRowIssue[]
warnings: RosterRowIssue[]
summary: RosterImportSummary
dryRun: boolean
mode: 'APPEND' | 'REPLACE' | string
created: number
updated: number
removed: number
}
/** 名册项(§3.6)。证件号与手机号只有脱敏值。 */
export interface RosterVO {
id: number
seqNo: number | null
studentNo: string | null
realName: string
/** 脱敏证件号 */
idCardMask: string | null
/** 脱敏手机号 */
phoneMask: string | null
orgId: number | null
orgName: string | null
occupation: string | null
worktype: string | null
jobLevel: string | null
education: string | null
isStudent: number | null
school: string | null
major: string | null
enrollDate: string | null
workUnit: string | null
isRetake: number | null
retakeTheory: string | null
retakePractice: string | null
retakeOverall: string | null
workYears: number | null
certOccupation: string | null
certLevel: string | null
certNo: string | null
extJson: Record<string, unknown> | null
/** 0 未绑定 / 1 已绑定 */
bindStatus: number
bindTime: string | null
/** 1 门禁核验 / 2 管理员指定 */
bindType: number | null
/** 0 未提交 / 1 草稿 / 2 待审 / 3 已通过 / 4 已驳回 */
submitStatus: number
}
export interface RosterImportOptions {
/** 手动指定表头行(1 起);不传则自动探测 */
headerRow?: number
/** 手动指定工作表;不传则取第一个有数据的 */
sheetName?: string
/** `APPEND`(默认,按证件号去重合并)/ `REPLACE`(整表替换) */
mode?: 'APPEND' | 'REPLACE'
/** true = 只体检不落库(**推荐默认**) */
dryRun?: boolean
/** 整批归到哪个班级 */
orgId?: number
}
/**
* 导入名册(multipart)。
*
* ⚠️ 超时给到 180s:5000 行的体检要遍历整表 + 逐行算校验位,
* 而默认的 30s 在真实的大名单上会超时 —— 而超时的表现是"点了没反应",
* 用户只会重复点,于是同一份文件被导好几次。
*/
export function importRoster(taskId: number, file: File, opts: RosterImportOptions = {}) {
const fd = new FormData()
fd.append('file', file)
fd.append('dryRun', String(opts.dryRun ?? true))
if (opts.headerRow !== undefined) {
fd.append('headerRow', String(opts.headerRow))
}
if (opts.sheetName) {
fd.append('sheetName', opts.sheetName)
}
if (opts.mode) {
fd.append('mode', opts.mode)
}
if (opts.orgId !== undefined) {
fd.append('orgId', String(opts.orgId))
}
// 不要手动设 Content-Type:axios 会带着 boundary 自己设,
// 手动写成 'multipart/form-data' 反而会丢掉 boundary,服务端直接解析失败
return http.request<RosterImportReport>({
method: 'post',
url: `/tasks/${taskId}/roster/import`,
data: fd,
timeout: 180 * 1000
})
}
export interface RosterQuery {
keyword?: string
orgId?: number
submitStatus?: number
bindStatus?: number
pageNum?: number
pageSize?: number
}
/**
* 名册分页(§8.2)。
*
* `keyword` 既能搜姓名(模糊),**也能粘一整串身份证号**(走 hash 等值)——
* 服务端按"看起来是不是身份证号"自动分支。密文搜不了 LIKE,这是加密的必然代价。
*/
export function getRosterList(taskId: number, params: RosterQuery = {}) {
return http.get<{ list: RosterVO[], total: number }>(`/tasks/${taskId}/roster`, params)
}
/** 填报单状态(§2.7)。名册行上的 `submitStatus` 与填报单同口径。 */
export const SUBMIT_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '未提交', color: 'gray' },
1: { text: '草稿', color: 'gray' },
2: { text: '待初审', color: 'orange' },
3: { text: '已通过', color: 'green' },
4: { text: '已驳回', color: 'red' }
}