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:
@@ -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' }
|
||||
}
|
||||
Reference in New Issue
Block a user