diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java index 442eeb8..018a198 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java @@ -9,6 +9,7 @@ import cn.tianbaotong.fill.common.util.MaskUtil; import cn.tianbaotong.fill.module.roster.dto.RosterImportReport; import cn.tianbaotong.fill.module.roster.entity.CollectRoster; import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTask; import cn.tianbaotong.fill.module.task.mapper.TaskMapper; import cn.tianbaotong.fill.support.IdentityNormalizer; import cn.tianbaotong.fill.support.SensitiveCodec; @@ -69,6 +70,12 @@ public class RosterImportService { /** 表头只会出现在前 N 行里(实测最靠后的是第 6 行,留一倍余量)。 */ private static final int HEADER_SCAN_ROWS = 10; + /** 名册可改的任务状态 —— 与 {@code MaterialDefService.EDITABLE_TASK_STATUS} 同口径。 */ + private static final Set EDITABLE_TASK_STATUS = Set.of( + CollectTask.STATUS_DRAFT, + CollectTask.STATUS_COLLECTING, + CollectTask.STATUS_PAUSED); + /** 一行里命中这么多个已知列名,才认为它是表头。 */ private static final int HEADER_MIN_SCORE = 3; @@ -107,7 +114,17 @@ public class RosterImportService { LoginUser me = UserContext.require(); Long tenantId = me.tenantId(); - requireTask(taskId); + CollectTask task = requireTask(taskId); + // 与材料规则同口径:已截止/已归档的任务不许再动名册。 + // 只拦"真导入"—— dryRun 不写库,看体检报告是安全的,不该被拦。 + if (!dryRun) { + int st = task.getStatus() == null ? -1 : task.getStatus(); + if (!EDITABLE_TASK_STATUS.contains(st)) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "任务已" + (st == CollectTask.STATUS_CLOSED ? "截止" : "归档") + + ",名册不可再改(只能查看)。如需继续收集,请新建任务"); + } + } if (orgId != null && rosterMapper.countOrg(tenantId, orgId) == 0) { throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位"); } @@ -693,9 +710,11 @@ public class RosterImportService { .map(RosterImportReport.RowIssue::row).distinct().count(); } - private void requireTask(Long taskId) { - if (taskMapper.selectById(taskId) == null) { + private CollectTask requireTask(Long taskId) { + CollectTask task = taskMapper.selectById(taskId); + if (task == null) { throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); } + return task; } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskReadinessMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskReadinessMapper.java index 8eb0647..f9be78a 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskReadinessMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskReadinessMapper.java @@ -4,6 +4,9 @@ import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; +import java.util.Map; + /** * 「任务是否具备发布条件」的体检查询。 * @@ -100,4 +103,47 @@ public interface TaskReadinessMapper { WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2) """) Long countPendingAudit(@Param("taskId") Long taskId); + + // ── 任务统计快照(§3.3):列表页一次算一页,不要每行查一次 ────────── + + /** + * 一批任务的名册行数。 + * + *

为什么是"一批"而不是"一个":任务列表一页 10 行, + * 逐行查会让"打开任务列表"变成 10 次以上的数据库往返, + * 而这一屏是管理端每天打开次数最多的页面。 + * + *

⚠️ 这里带了 {@code tenant_id}(本文件上面几条没有): + * 上面几条的 {@code taskId} 都来自"已经过租户过滤的任务", + * 而批量版本的 ids 虽然也来自租户过滤后的分页结果, + * 但一条查不到租户的行就会把整页的统计串到别的学校去 —— 代价不对等,带上更稳。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + List> countRosterByTaskIds(@Param("tenantId") Long tenantId, + @Param("ids") List ids); + + /** + * 一批任务的提交单状态分布(每个任务每种状态一行)。 + * + *

调用方按 {@code status} 汇总成"已提交 / 待审 / 已通过 / 已驳回"。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + List> countSubmissionsByTaskIds(@Param("tenantId") Long tenantId, + @Param("ids") List ids); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java index 8b0a8e4..fceeff9 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java @@ -9,6 +9,7 @@ import cn.tianbaotong.fill.module.task.dto.AuditConfigVO; import cn.tianbaotong.fill.module.task.dto.CompressConfigVO; import cn.tianbaotong.fill.module.task.dto.TaskActionReq; import cn.tianbaotong.fill.module.task.dto.TaskCreateVO; +import cn.tianbaotong.fill.module.task.dto.TaskStatVO; import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq; import cn.tianbaotong.fill.module.task.dto.TaskVO; import cn.tianbaotong.fill.module.task.entity.CollectTask; @@ -32,8 +33,10 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -116,9 +119,99 @@ public class TaskService { IPage page = taskMapper.selectPage(new Page<>(num, size), q); // 用 toFullVo:列表里就要显示审核流与压缩参数,走 toVo 会是 null List list = page.getRecords().stream().map(this::toFullVo).toList(); + fillStats(list); return PageResult.of(list, page.getTotal(), num, size); } + /** + * 填「任务统计快照」(§3.3)—— 名册(M2)与提交单(M3)都有之后它才开始有值。 + * + *

⚠️ 名册为空时返回 {@code null},而不是一串 0。 + * 这是 {@link TaskStatVO} 类注释里定下的口径:显示"完成率 0%"看起来像"没人填", + * 而真相是"还没有分母"。界面据此显示「待导入名册」。 + * + *

一次算一页(两条批量查询),不做逐任务查询:任务列表是管理端 + * 打开次数最多的页面,10 行 × 2 次往返是白白多出来的。 + */ + private void fillStats(List vos) { + List ids = new ArrayList<>(); + for (TaskVO v : vos) { + if (v != null && v.getId() != null) { + ids.add(v.getId()); + } + } + if (ids.isEmpty()) { + return; + } + Long tenantId = UserContext.tenantId(); + + Map rosterTotal = new HashMap<>(); + for (Map r : readinessMapper.countRosterByTaskIds(tenantId, ids)) { + rosterTotal.put(asLong(col(r, "taskId")), asInt(col(r, "cnt"))); + } + Map> byStatus = new HashMap<>(); + for (Map r : readinessMapper.countSubmissionsByTaskIds(tenantId, ids)) { + byStatus.computeIfAbsent(asLong(col(r, "taskId")), k -> new HashMap<>()) + .put(asInt(col(r, "status")), asInt(col(r, "cnt"))); + } + + for (TaskVO v : vos) { + if (v == null || v.getId() == null) { + continue; + } + int rt = rosterTotal.getOrDefault(v.getId(), 0); + if (rt == 0) { + v.setStat(null); + continue; + } + Map st = byStatus.getOrDefault(v.getId(), Map.of()); + int pending = st.getOrDefault(1, 0) + st.getOrDefault(2, 0); + int approved = st.getOrDefault(3, 0); + int rejected = st.getOrDefault(4, 0); + int archived = st.getOrDefault(5, 0); + // 「已提交」= 交上来且没被退回的(待初审 + 待终审 + 已通过)。 + // 与"已通过"必须分开报:原型定稿的口径是「完成率 82% 是已提交率 23/28」。 + int submitted = pending + approved; + + TaskStatVO s = new TaskStatVO(); + s.setRosterTotal(rt); + s.setSubmittedCount(submitted); + s.setPendingCount(pending); + s.setApprovedCount(approved); + s.setRejectedCount(rejected); + // 没交的 = 名册里减去"已经交了的"与"被退回的"(草稿算没交:他还没提交) + s.setUnsubmittedCount(Math.max(0, rt - submitted - rejected - archived)); + s.setSubmitRate(pct(submitted, rt)); + s.setPassRate(pct(approved, rt)); + // abnormalCount / avgAuditHours 仍为 null:前者要 M4 的校验问题表、 + // 后者要 M5 的审核记录,现在编一个数就是谎 + v.setStat(s); + } + } + + // 列标签在 PostgreSQL 里会被折成小写,而 MyBatis 用列标签做 key —— + // 按不区分大小写取,避免"取不到就当 0"这种最难受的错(数字看起来正常,其实是空的) + private static Object col(Map row, String name) { + for (Map.Entry e : row.entrySet()) { + if (e.getKey().equalsIgnoreCase(name)) { + return e.getValue(); + } + } + return null; + } + + private static String pct(int part, int total) { + return total <= 0 ? null : Math.round(part * 100.0 / total) + "%"; + } + + private static Integer asInt(Object o) { + return o instanceof Number n ? n.intValue() : null; + } + + private static Long asLong(Object o) { + return o instanceof Number n ? n.longValue() : null; + } + /** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */ private boolean seesWholeTenant() { LoginUser me = UserContext.require(); @@ -128,7 +221,9 @@ public class TaskService { // ── 详情 ────────────────────────────────────── public TaskVO detail(Long id) { - return toFullVo(requireTask(id)); + TaskVO vo = toFullVo(requireTask(id)); + fillStats(new ArrayList<>(List.of(vo))); + return vo; } // ── 新建 ────────────────────────────────────── diff --git a/docs/交接摘要.md b/docs/交接摘要.md index fb147f8..c566c86 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -1775,3 +1775,134 @@ GET /api/v1/tasks/{id}/roster keyword / orgId / submitStatus / bindSta 5. 界面文案要照顾"表头在第 6 行"这件事 —— **不要让用户以为系统导错了** > 做完第 2 步,M1 的"配置闭环"就真的能在界面上从头走到尾了(里程碑 A 的完整形态)。 + +--- + +## §一·二十三 第 2 步完成:名册导入前端 —— **任务发布现在真的能在界面上走通了** + +### 这一步的意义(一句话) + +在此之前,「配置 → 任务就绪」这条链缺了**名册**那一环:班级能配、材料能配、表单能配, +但名册只能靠 SQL 造 —— 于是任务在界面上**发布不了**(发布前置校验 `40016` 名册非空)。 + +现在四个配置入口齐了,并且**验证过发布真的通**(`e2e-m2-roster.py` 第 8 节): + +``` +[8] 配置闭环:名册到位后,任务应当能发布 + ✓ 配覆盖班级成功 + ✓ 配必传材料成功 + ✓ **发布成功**(名册 + 覆盖班级 + 必传材料 三样齐了) + ✓ 任务状态变成「收集中」 + ✓ 发布后 stat 仍带分母(rosterTotal=3) +``` + +> `force=true` 只跳过 P1 级提醒(如"某班没指定初审人"),那 5 项阻断级校验 +> **force 也绕不过** —— 所以这条断言证明的是真链路,不是绕过去的。 + +### 落地了什么 + +**新增**(`frontend-pc/`): + +| 文件 | 作用 | +|---|---| +| `src/apis/xt/roster.ts` | 导入(multipart)+ 名册分页 + 类型 + 状态字典 | +| `src/views/task/TaskRosterDrawer.vue` | 「名单管理」抽屉:导入 → 体检报告 → 名册列表 | + +**改动**:`views/task/index.vue` 加「名单管理」入口(草稿/收集中/已暂停可改, +已截止/已归档只读仍可查看)+ 发布失败提示改指本行入口; +`config/xt-routes.ts` 的顶层「名单管理」菜单说明改成指向任务行(见下)。 + +**接口**(第 1 步就做好了,这次只调):`POST /tasks/{id}/roster/import`、`GET /tasks/{id}/roster`。 + +### 界面上五个刻意的设计 + +| # | 设计 | 为什么 | +|:--:|---|---| +| 1 | **先体检、再导入(两步)** | 实测表头在第 1 行或第 6 行。一步导入的话用户只看到"成功 0 条",然后以为自己文件坏了 —— 而真相是系统把标题行当成了表头 | +| 2 | **明说"前面那 5 行是标题与说明,已跳过,这不是导错了"** | 看到"第 6 行"的人第一反应就是"导错了",然后会去改自己那份**本来正确**的表格 | +| 3 | **行号 + 列字母 + 原值 + 原因,四样一起给** | 只给"第 12 行有问题",用户还得自己回去找是哪一列 | +| 4 | **`dryRun` 默认 true** | 服务端也默认 true:不显式要求就不写库。避免"以为只是看看,结果已经导进去了" | +| 5 | **阻断行与警告行分两张表** | 阻断的要改;警告的已经入库、只是建议补。混在一起用户会以为整批都没进 | + +另外:原因后面跟的是**机器码**(`IDCARD_CHECKSUM` 这种)而不是中文标签 —— +第一版给的是中文,结果和上面那句原因重复了一遍("姓名为空 姓名为空"),已改。 + +### 顺带修掉一处「界面在说谎」 + +名册导进来了,但任务列表还显示「待导入名册」—— 因为 `TaskStatVO` 恒为 `null` +(M1 阶段的既有缺口,类注释里写着"名册为空时返回 null,由界面显示「待导入名册」")。 +现在名册有了,就该有分母了: + +- `TaskService.fillStats()`:**一次算一页**(两条批量查询,不做逐任务查询 —— + 任务列表是管理端打开次数最多的页面,10 行 × 2 次往返是白白多出来的) +- **名册为空时仍返回 `null`**,界面继续显示「待导入名册」—— 这条口径**没有放宽**: + 显示"0%"看起来像"没人填",而真相是"还没有分母" +- `abnormalCount` / `avgAuditHours` **仍为 null**:前者要 M4 的校验问题表、 + 后者要 M5 的审核记录,现在编一个数就是谎 +- E2E:`e2e-m1-task.py` 那条"stat 为 null"改成措辞更准的"名册为空时 stat 为 null"(意图不变), + 并在 `e2e-m2-roster.py` 补了 4 条"名册有值后 stat 该有值" + +> 这条也是第 2 步**自己制造出来**的问题:能导名册之后,"待导入名册"就从"诚实"变成"说谎"。 + +### 关于顶层菜单「名单管理」为什么还是"待建" + +`config/xt-routes.ts` 里本来就有一个顶层「名单管理」(`/collect/roster`,标着"待建 / M2")。 +这次**没有**把它做成独立页,而是做进了**任务行**里的抽屉,理由: + +- **名册是按任务(task)的**,独立页得先让用户选任务,多一步且容易选错; +- 它和「配置班级」「材料规则」「表单设计」是同一层的配置,放在一起才一致; +- 报考范围确认(M2-04/05)会接着做进**同一个抽屉**,那时也不需要新页面。 + +所以把那个菜单的说明改成指向任务行("名册是按任务的,入口在收集任务的每一行里"), +而不是留着一句已经过期的"对应里程碑 M2"。 + +### 浏览器验证:一个可复用的手法 + +文件选择框没法自动化(`uni.chooseImage` / `input[type=file]` 都会弹系统对话框), +但**可以喂进去**: + +```js +const blob = await (await fetch('/某个夹具.xlsx')).blob() +const dt = new DataTransfer() +dt.items.add(new File([blob], 'x.xlsx', { type: '...' })) +input.files = dt.files +input.dispatchEvent(new Event('change', { bubbles: true })) +``` + +配合"把夹具放到 `frontend-pc/public/`"(Vite 会直接服务它), +就能让浏览器走**完整的真实链路**(选文件 → 体检 → 导入 → 列表刷新), +只绕开了"手指点选"那一步。这次就是这样验的:3 人被正确导入、 +脱敏证件号、报考三项、扩展字段、状态列都对。 + +> 夹具用完即删(`frontend-pc/public/_web-roster-fixture.xlsx`),别提交。 + +### 两个流程教训 + +1. **`Test-Path jar` 不能当作"打包成功"**。这次 `mvn package` 失败了, + 而 jar 是上一次的、还在 —— 检查文件存在会得到"打包 ✓", + 然后测试对着**旧 jar** 全绿(或像这次一样四条新断言全红)。 + **必须看 mvn 的退出码 / `BUILD SUCCESS`**。 +2. **`call()` 返回的是 `(status, body)` 元组**,不是字典。我新写的那一节忘了解包, + 报 `'tuple' object has no attribute 'get'` —— 与第 1 步里 + "把已存在判断写反"是同一类:**新写的代码最容易在细节上错,而 E2E 正是抓它的地方**。 + +### 回归 + +- **全量 622 条断言、0 失败**(`e2e-m2-roster.py` 62 → **67**,新增配置闭环 5 条) +- `frontend-pc`:`vue-tsc --noEmit` **0 错**、`eslint` 目标文件 **0 错** +- `python scripts/verify-docs.py` → 19 项全通过 + +### 下一步(第 3 步) + +**报考范围聚合与一致性体检(M2-04/05)** —— 这是**产品的核心增量** +(官方 Excel 的「报考职业」「报考工种」是自由填写、没有下拉, +我们要把它变成"从名单聚合 + 一致性体检"): + +1. 按职业 / 工种 / 等级分组聚合,给出每个分组的**人数**与**名册行列表** +2. 5 类 issue 体检:非计划内职业、工种与职业不匹配、等级缺失、同一人多个职业… +3. 界面:做进**同一个抽屉**(「报考范围」页签 / 折叠区),支持"排除某行不参与本次认定" +4. 发布前置校验 `40024`(报考范围未覆盖全部名册)到那时才真正有意义 + +> 做之前先确认一件事:`sys_eval_plan` / `sys_occupation_ref` 这几张"评价计划"表 +> 库里只有 `sys_occupation_ref`(见 §一·二十二 的记录), +> **评价计划本身的导入(FR-311)还没做** —— 需要先定"计划从哪来"(机构下发文件?) diff --git a/frontend-pc/src/apis/xt/roster.ts b/frontend-pc/src/apis/xt/roster.ts new file mode 100644 index 0000000..26ecff6 --- /dev/null +++ b/frontend-pc/src/apis/xt/roster.ts @@ -0,0 +1,190 @@ +/** + * 名册接口(《03-接口设计》§8.1 ~ §8.2)。 + * + * 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`, + * 解包后 `http.get()` 得到 `{ 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 | 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({ + 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 = { + 0: { text: '未提交', color: 'gray' }, + 1: { text: '草稿', color: 'gray' }, + 2: { text: '待初审', color: 'orange' }, + 3: { text: '已通过', color: 'green' }, + 4: { text: '已驳回', color: 'red' } +} diff --git a/frontend-pc/src/components.d.ts b/frontend-pc/src/components.d.ts index 2dbe126..80e9f0b 100644 --- a/frontend-pc/src/components.d.ts +++ b/frontend-pc/src/components.d.ts @@ -49,6 +49,8 @@ declare module 'vue' { AOption: typeof import('@arco-design/web-vue')['Option'] APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm'] APopover: typeof import('@arco-design/web-vue')['Popover'] + ARadio: typeof import('@arco-design/web-vue')['Radio'] + ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup'] ARow: typeof import('@arco-design/web-vue')['Row'] AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar'] ASelect: typeof import('@arco-design/web-vue')['Select'] @@ -129,6 +131,8 @@ declare global { const AOption: typeof import('@arco-design/web-vue')['Option'] const APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm'] const APopover: typeof import('@arco-design/web-vue')['Popover'] + const ARadio: typeof import('@arco-design/web-vue')['Radio'] + const ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup'] const ARow: typeof import('@arco-design/web-vue')['Row'] const AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar'] const ASelect: typeof import('@arco-design/web-vue')['Select'] diff --git a/frontend-pc/src/config/xt-routes.ts b/frontend-pc/src/config/xt-routes.ts index a178398..de11514 100644 --- a/frontend-pc/src/config/xt-routes.ts +++ b/frontend-pc/src/config/xt-routes.ts @@ -62,7 +62,8 @@ export const XT_MENUS: XtMenu[] = [ group: 'collect', sort: 12, ready: false, - note: '导入名册、报考范围确认、门禁名单维护。对应里程碑 M2。' + note: '名册是**按任务**的,所以入口在「收集任务」的每一行里:点「名单管理」。' + + '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。' }, { key: 'join', diff --git a/frontend-pc/src/views/task/TaskRosterDrawer.vue b/frontend-pc/src/views/task/TaskRosterDrawer.vue new file mode 100644 index 0000000..b35f76e --- /dev/null +++ b/frontend-pc/src/views/task/TaskRosterDrawer.vue @@ -0,0 +1,780 @@ + + + + + diff --git a/frontend-pc/src/views/task/index.vue b/frontend-pc/src/views/task/index.vue index a1ef7c9..574a671 100644 --- a/frontend-pc/src/views/task/index.vue +++ b/frontend-pc/src/views/task/index.vue @@ -31,27 +31,17 @@ 下面 6 列合计 840 —— 只差 10px,几乎不出现横向滚动条。 之前给到 1130px,结果是横向滚动 + 钉住的「操作」列把「状态」整个盖住, 表头挤成了「进度操作」。**改列宽前先按这个口径算一遍。** - --> -