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
@@ -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.dto.RosterImportReport;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster; import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; 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.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer; import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.SensitiveCodec; import cn.tianbaotong.fill.support.SensitiveCodec;
@@ -69,6 +70,12 @@ public class RosterImportService {
/** 表头只会出现在前 N 行里(实测最靠后的是第 6 行,留一倍余量)。 */ /** 表头只会出现在前 N 行里(实测最靠后的是第 6 行,留一倍余量)。 */
private static final int HEADER_SCAN_ROWS = 10; private static final int HEADER_SCAN_ROWS = 10;
/** 名册可改的任务状态 —— 与 {@code MaterialDefService.EDITABLE_TASK_STATUS} 同口径。 */
private static final Set<Integer> EDITABLE_TASK_STATUS = Set.of(
CollectTask.STATUS_DRAFT,
CollectTask.STATUS_COLLECTING,
CollectTask.STATUS_PAUSED);
/** 一行里命中这么多个已知列名,才认为它是表头。 */ /** 一行里命中这么多个已知列名,才认为它是表头。 */
private static final int HEADER_MIN_SCORE = 3; private static final int HEADER_MIN_SCORE = 3;
@@ -107,7 +114,17 @@ public class RosterImportService {
LoginUser me = UserContext.require(); LoginUser me = UserContext.require();
Long tenantId = me.tenantId(); 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) { if (orgId != null && rosterMapper.countOrg(tenantId, orgId) == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
} }
@@ -693,9 +710,11 @@ public class RosterImportService {
.map(RosterImportReport.RowIssue::row).distinct().count(); .map(RosterImportReport.RowIssue::row).distinct().count();
} }
private void requireTask(Long taskId) { private CollectTask requireTask(Long taskId) {
if (taskMapper.selectById(taskId) == null) { CollectTask task = taskMapper.selectById(taskId);
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
} }
return task;
} }
} }
@@ -4,6 +4,9 @@ import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; 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) WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2)
""") """)
Long countPendingAudit(@Param("taskId") Long taskId); Long countPendingAudit(@Param("taskId") Long taskId);
// ── 任务统计快照(§3.3):列表页一次算一页,不要每行查一次 ──────────
/**
* 一批任务的名册行数。
*
* <p><b>为什么是"一批"而不是"一个"</b>:任务列表一页 10 行,
* 逐行查会让"打开任务列表"变成 10 次以上的数据库往返,
* 而这一屏是管理端每天打开次数最多的页面。
*
* <p>⚠️ 这里带了 {@code tenant_id}(本文件上面几条没有):
* 上面几条的 {@code taskId} 都来自"已经过租户过滤的任务",
* 而批量版本的 ids 虽然也来自租户过滤后的分页结果,
* 但一条查不到租户的行就会把整页的统计串到别的学校去 —— 代价不对等,带上更稳。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT task_id AS taskId, COUNT(*) AS cnt FROM collect_roster
WHERE tenant_id = #{tenantId} AND deleted = 0
AND task_id IN <foreach collection="ids" item="i" open="(" separator="," close=")">#{i}</foreach>
GROUP BY task_id
</script>
""")
List<Map<String, Object>> countRosterByTaskIds(@Param("tenantId") Long tenantId,
@Param("ids") List<Long> ids);
/**
* 一批任务的提交单状态分布(每个任务每种状态一行)。
*
* <p>调用方按 {@code status} 汇总成"已提交 / 待审 / 已通过 / 已驳回"。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT task_id AS taskId, status AS status, COUNT(*) AS cnt FROM collect_submission
WHERE tenant_id = #{tenantId} AND deleted = 0
AND task_id IN <foreach collection="ids" item="i" open="(" separator="," close=")">#{i}</foreach>
GROUP BY task_id, status
</script>
""")
List<Map<String, Object>> countSubmissionsByTaskIds(@Param("tenantId") Long tenantId,
@Param("ids") List<Long> ids);
} }
@@ -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.CompressConfigVO;
import cn.tianbaotong.fill.module.task.dto.TaskActionReq; import cn.tianbaotong.fill.module.task.dto.TaskActionReq;
import cn.tianbaotong.fill.module.task.dto.TaskCreateVO; 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.TaskUpsertReq;
import cn.tianbaotong.fill.module.task.dto.TaskVO; import cn.tianbaotong.fill.module.task.dto.TaskVO;
import cn.tianbaotong.fill.module.task.entity.CollectTask; import cn.tianbaotong.fill.module.task.entity.CollectTask;
@@ -32,8 +33,10 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -116,9 +119,99 @@ public class TaskService {
IPage<CollectTask> page = taskMapper.selectPage(new Page<>(num, size), q); IPage<CollectTask> page = taskMapper.selectPage(new Page<>(num, size), q);
// 用 toFullVo:列表里就要显示审核流与压缩参数,走 toVo 会是 null // 用 toFullVo:列表里就要显示审核流与压缩参数,走 toVo 会是 null
List<TaskVO> list = page.getRecords().stream().map(this::toFullVo).toList(); List<TaskVO> list = page.getRecords().stream().map(this::toFullVo).toList();
fillStats(list);
return PageResult.of(list, page.getTotal(), num, size); return PageResult.of(list, page.getTotal(), num, size);
} }
/**
* 填「任务统计快照」(§3.3)—— 名册(M2)与提交单(M3)都有之后它才开始有值。
*
* <p>⚠️ <b>名册为空时返回 {@code null},而不是一串 0</b>。
* 这是 {@link TaskStatVO} 类注释里定下的口径:显示"完成率 0%"看起来像"没人填",
* 而真相是"还没有分母"。界面据此显示「待导入名册」。
*
* <p>一次算一页(两条批量查询),不做逐任务查询:任务列表是管理端
* 打开次数最多的页面,10 行 × 2 次往返是白白多出来的。
*/
private void fillStats(List<TaskVO> vos) {
List<Long> 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<Long, Integer> rosterTotal = new HashMap<>();
for (Map<String, Object> r : readinessMapper.countRosterByTaskIds(tenantId, ids)) {
rosterTotal.put(asLong(col(r, "taskId")), asInt(col(r, "cnt")));
}
Map<Long, Map<Integer, Integer>> byStatus = new HashMap<>();
for (Map<String, Object> 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<Integer, Integer> 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<String, Object> row, String name) {
for (Map.Entry<String, Object> 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() { private boolean seesWholeTenant() {
LoginUser me = UserContext.require(); LoginUser me = UserContext.require();
@@ -128,7 +221,9 @@ public class TaskService {
// ── 详情 ────────────────────────────────────── // ── 详情 ──────────────────────────────────────
public TaskVO detail(Long id) { public TaskVO detail(Long id) {
return toFullVo(requireTask(id)); TaskVO vo = toFullVo(requireTask(id));
fillStats(new ArrayList<>(List.of(vo)));
return vo;
} }
// ── 新建 ────────────────────────────────────── // ── 新建 ──────────────────────────────────────
+131
View File
@@ -1775,3 +1775,134 @@ GET /api/v1/tasks/{id}/roster keyword / orgId / submitStatus / bindSta
5. 界面文案要照顾"表头在第 6 行"这件事 —— **不要让用户以为系统导错了** 5. 界面文案要照顾"表头在第 6 行"这件事 —— **不要让用户以为系统导错了**
> 做完第 2 步,M1 的"配置闭环"就真的能在界面上从头走到尾了(里程碑 A 的完整形态)。 > 做完第 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)还没做** —— 需要先定"计划从哪来"(机构下发文件?)
+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' }
}
+4
View File
@@ -49,6 +49,8 @@ declare module 'vue' {
AOption: typeof import('@arco-design/web-vue')['Option'] AOption: typeof import('@arco-design/web-vue')['Option']
APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm'] APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
APopover: typeof import('@arco-design/web-vue')['Popover'] 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'] ARow: typeof import('@arco-design/web-vue')['Row']
AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar'] AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
ASelect: typeof import('@arco-design/web-vue')['Select'] ASelect: typeof import('@arco-design/web-vue')['Select']
@@ -129,6 +131,8 @@ declare global {
const AOption: typeof import('@arco-design/web-vue')['Option'] const AOption: typeof import('@arco-design/web-vue')['Option']
const APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm'] const APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
const APopover: typeof import('@arco-design/web-vue')['Popover'] 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 ARow: typeof import('@arco-design/web-vue')['Row']
const AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar'] const AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
const ASelect: typeof import('@arco-design/web-vue')['Select'] const ASelect: typeof import('@arco-design/web-vue')['Select']
+2 -1
View File
@@ -62,7 +62,8 @@ export const XT_MENUS: XtMenu[] = [
group: 'collect', group: 'collect',
sort: 12, sort: 12,
ready: false, ready: false,
note: '导入名册、报考范围确认、门禁名单维护。对应里程碑 M2。' note: '名册是**按任务**的,所以入口在「收集任务」的每一行里:点「名单管理」。'
+ '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。'
}, },
{ {
key: 'join', key: 'join',
@@ -0,0 +1,780 @@
<template>
<a-drawer v-model:visible="visible" :width="1120" :mask-closable="false" :footer="false"
unmount-on-close @cancel="close">
<template #title>
<span>名单管理</span>
<span class="roster__task">{{ taskName }}</span>
</template>
<!--
已截止 / 已归档只读。与服务端同口径(服务端也会拒),
界面上先说清楚 —— 否则用户选完文件点「开始体检」才被告知不能导。
注意:**体检(dryRun)不受状态限制**,看报告是安全的,所以只有"确认导入"被禁。
-->
<a-alert v-if="readonly" type="normal" :show-icon="false" class="roster__alert">
任务已{{ readonlyReason }},名册不可再改,只能查看。
(仍然可以「开始体检」看一份表格有什么问题,只是不能导入。)
</a-alert>
<!--
收集中改名册要明确警告:学生可能正在核验。
删掉/改掉某一行,那个学生下次扫码就进不来了;而改「报考职业」这类锁定字段,
等于把他已经确认过的材料变成另一种认定。
-->
<a-alert v-else-if="collecting" type="warning" class="roster__alert">
<template #title>
任务正在收集中,<strong>改动立刻生效</strong>:学生下一次核验就会看到新名单。
已经核验过的人不受影响(他们的名册项<b>不会被覆盖</b>,系统会跳过并提示)。
</template>
</a-alert>
<!-- ── 一、导入 ─────────────────────────────────── -->
<div class="roster__section">导入名册</div>
<a-alert type="info" :show-icon="false" class="roster__intro">
<template #title>
表格里需要有 <strong>姓名</strong> 与 <strong>身份证号</strong> 两列,
其余按官方模板的列名(学历、联系电话、报考职业…)识别。
<strong>表头在第几行不用管</strong>,系统会自己找;认错了可以手动指定。
</template>
<template #default>
<div class="roster__hint">
名册是<b>准入名单</b>:学生必须先填写姓名与身份证号、与名册匹配上,才能进入填报。
所以这一份名单就是"谁能填、填的是谁"的唯一依据。
</div>
</template>
</a-alert>
<a-row :gutter="16" class="roster__form">
<a-col :span="8">
<a-form-item label="整批归到哪个班级" class="roster__item">
<a-select v-model="orgId" placeholder="不指定(名册项不带班级)" allow-clear
:loading="orgLoading" size="small">
<a-option v-for="c in classOptions" :key="c.id" :value="c.id">{{ c.label }}</a-option>
</a-select>
<template #extra>
<span class="roster__hint">官方模板里没有班级列,所以这里选了就整批都算这个班</span>
</template>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="导入方式" class="roster__item">
<a-radio-group v-model="mode" type="button" size="small">
<a-radio value="APPEND">合并导入</a-radio>
<a-radio value="REPLACE">整表替换</a-radio>
</a-radio-group>
<template #extra>
<span class="roster__hint">{{ modeHint }}</span>
</template>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="Excel 文件" class="roster__item">
<div class="roster__pick">
<!--
用原生 input 而不是 a-upload:本场景只需要"选一个本地文件然后自己发请求",
原生 input 拿原始 File 的行为是完全确定的。与账号导入弹窗同一做法。
-->
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="roster__file"
@change="onFileChange" />
<a-button size="small" :disabled="readonly" @click="pickFile">
<template #icon><IconUpload /></template>
选择文件
</a-button>
<span v-if="file" class="roster__filename" :title="file.name">{{ file.name }}</span>
<span v-else class="roster__hint">.xlsx / .xls,≤10MB</span>
</div>
</a-form-item>
</a-col>
</a-row>
<a-space class="roster__tool" wrap>
<a-button type="primary" size="small" :loading="checking" :disabled="!file || readonly"
@click="runDryRun()">
{{ report ? '按当前设置重新体检' : '开始体检' }}
</a-button>
<span class="roster__hint">
体检<b>不会写库</b>:先看清楚系统把哪一行当表头、哪几行有问题,确认无误再导入。
</span>
</a-space>
<!-- ── 二、体检报告 ─────────────────────────────── -->
<template v-if="report">
<a-divider />
<a-row :gutter="12" class="roster__stats">
<a-col :span="4">
<div class="roster__stat">
<div class="roster__stat-num">{{ report.total }}</div>
<div class="roster__stat-label">数据行</div>
</div>
</a-col>
<a-col :span="4">
<div class="roster__stat roster__stat--ok">
<div class="roster__stat-num">{{ report.success }}</div>
<div class="roster__stat-label">可入库</div>
</div>
</a-col>
<a-col :span="4">
<div class="roster__stat" :class="report.failed ? 'roster__stat--bad' : ''">
<div class="roster__stat-num">{{ report.failed }}</div>
<div class="roster__stat-label">被阻断</div>
</div>
</a-col>
<a-col :span="4">
<div class="roster__stat">
<div class="roster__stat-num">{{ report.dryRun ? plannedNew : report.created }}</div>
<div class="roster__stat-label">{{ report.dryRun ? '将新增' : '已新增' }}</div>
</div>
</a-col>
<a-col :span="4">
<div class="roster__stat">
<div class="roster__stat-num">{{ report.dryRun ? plannedUpdate : report.updated }}</div>
<div class="roster__stat-label">{{ report.dryRun ? '将更新' : '已更新' }}</div>
</div>
</a-col>
<a-col :span="4">
<div class="roster__stat" :class="report.warnings.length ? 'roster__stat--warn' : ''">
<div class="roster__stat-num">{{ report.warnings.length }}</div>
<div class="roster__stat-label">警告(不影响入库)</div>
</div>
</a-col>
</a-row>
<a-alert :type="headerAlertType" :show-icon="false" class="roster__header-alert">
<template #title>
系统把第 <strong>{{ report.headerRow }}</strong> 行当成了表头
<span v-if="report.headerRow > 1">
—— 它<strong>前面那 {{ report.headerRow - 1 }} 行</strong>是标题与说明,已跳过,
这不是导错了
</span>
。
<span v-if="report.sheetName">(工作表:{{ report.sheetName }})</span>
</template>
<template #default>
<div v-if="report.headerCandidates.length > 1" class="roster__hint">
另外还有 {{ report.headerCandidates.length - 1 }} 行也像表头(命中列数:
{{ report.headerCandidates.map((c) => `第${c.row}行=${c.score}`).join(',') }})。
如果系统选错了,在下面手动指定。
</div>
<div class="roster__hint">
缺了哪些列、多出来的列去了哪,看下面的「列识别结果」——
多出来的列不会丢,会原样存进扩展字段。
</div>
</template>
</a-alert>
<a-space class="roster__tool" wrap>
<span class="roster__hint">手动指定表头行:</span>
<a-input-number v-model="manualHeaderRow" :min="1" :max="200" size="small"
:style="{ width: '110px' }" placeholder="行号" />
<a-button size="small" :loading="checking" :disabled="!file" @click="runDryRun(manualHeaderRow)">
按这一行重新体检
</a-button>
</a-space>
<!-- 列识别结果 -->
<a-table :data="report.columns" :pagination="false" row-key="col" size="small"
:max-height="200" :scroll="{ y: 200 }" class="roster__table">
<template #columns>
<a-table-column title="列" :width="60" align="center" data-index="col" />
<a-table-column title="表头原文" data-index="title" :width="200" ellipsis tooltip />
<a-table-column title="识别为" :width="180">
<template #cell="{ record }">
<span v-if="record.matched" class="roster__mapped">{{ fieldName(record.mappedTo) }}</span>
<span v-else-if="record.mappedTo" class="roster__muted">
非官方列 → 存进扩展字段
</span>
<span v-else class="roster__muted">忽略</span>
</template>
</a-table-column>
<a-table-column title="说明" :width="140">
<template #cell="{ record }">
<a-tag v-if="record.matched" color="green" size="small">官方列</a-tag>
<a-tag v-else-if="record.mappedTo" size="small">保留</a-tag>
<a-tag v-else size="small">空列</a-tag>
</template>
</a-table-column>
</template>
</a-table>
<!-- 阻断行 -->
<div class="roster__section roster__section--bad">
没能入库的行({{ report.errors.length }})
</div>
<a-empty v-if="!report.errors.length" description="没有阻断行" />
<a-table v-else :data="report.errors" :pagination="false" row-key="key" size="small"
:max-height="240" :scroll="{ y: 240 }">
<template #columns>
<a-table-column title="Excel 行" :width="90" align="center">
<template #cell="{ record }">第 {{ record.row }} 行</template>
</a-table-column>
<a-table-column title="列" :width="60" align="center">
<template #cell="{ record }">
<span v-if="record.col">{{ record.col }} 列</span>
<span v-else class="roster__muted">—</span>
</template>
</a-table-column>
<a-table-column title="原值" :width="170" ellipsis tooltip>
<template #cell="{ record }">
<span v-if="record.value">{{ record.value }}</span>
<span v-else class="roster__muted">(空)</span>
</template>
</a-table-column>
<a-table-column title="原因" ellipsis tooltip>
<template #cell="{ record }">
{{ record.reason }}
<!-- 标签给的是**机器码**(排查/提单时有用),不重复上面那句人话 -->
<code class="roster__code">{{ record.code }}</code>
</template>
</a-table-column>
</template>
</a-table>
<!-- 警告行 -->
<div class="roster__section roster__section--warn">
有警告、但仍然会入库的行({{ report.warnings.length }})
</div>
<a-empty v-if="!report.warnings.length" description="没有警告" />
<a-table v-else :data="report.warnings" :pagination="false" row-key="key" size="small"
:max-height="200" :scroll="{ y: 200 }">
<template #columns>
<a-table-column title="Excel 行" :width="90" align="center">
<template #cell="{ record }">第 {{ record.row }} 行</template>
</a-table-column>
<a-table-column title="原值" :width="170" ellipsis tooltip>
<template #cell="{ record }">{{ record.value || '(空)' }}</template>
</a-table-column>
<a-table-column title="原因" ellipsis tooltip>
<template #cell="{ record }">
{{ record.reason }}
<code class="roster__code">{{ record.code }}</code>
</template>
</a-table-column>
</template>
</a-table>
<div class="roster__footer">
<a-space>
<a-button size="small" @click="resetReport">重新选文件</a-button>
<a-button type="primary" size="small" :loading="importing"
:disabled="readonly || !report.success" @click="confirmImport">
{{ importButtonText }}
</a-button>
</a-space>
<div v-if="!report.success" class="roster__hint">
没有任何可入库的行,请按上面的原因改好 Excel 再来。
</div>
<div v-else-if="report.dryRun" class="roster__hint">
上面这些数字是<b>预计</b>结果;导入只处理有问题的行以外的那
{{ report.success }} 行,其余行改好后可以再次导入(同证件号会合并更新,不会重复)。
</div>
</div>
</template>
<!-- ── 三、名册 ─────────────────────────────────── -->
<a-divider />
<div class="roster__section">
名册({{ total }} 人)
<span v-if="stat.t">已核验 {{ stat.bound }} 人 · 已提交 {{ stat.submitted }} 人</span>
</div>
<a-space class="roster__tool" wrap>
<a-input-search v-model="query.keyword" placeholder="姓名,或粘贴完整身份证号" allow-clear
class="roster__search" size="small" @search="search" @clear="search" />
<a-select v-model="query.submitStatus" placeholder="填报状态" allow-clear size="small"
class="roster__filter" @change="search">
<a-option v-for="(v, k) in SUBMIT_STATUS" :key="k" :value="Number(k)">{{ v.text }}</a-option>
</a-select>
<a-select v-model="query.bindStatus" placeholder="核验状态" allow-clear size="small"
class="roster__filter" @change="search">
<a-option :value="0">未核验</a-option>
<a-option :value="1">已核验</a-option>
</a-select>
<a-button size="small" @click="loadList">
<template #icon><IconRefresh /></template>
刷新
</a-button>
</a-space>
<a-table :data="rows" :loading="listLoading" row-key="id" size="small" :pagination="pagination"
:scroll="{ x: 940 }" class="roster__table" @page-change="onPageChange">
<template #columns>
<a-table-column title="序号" :width="64" align="center">
<template #cell="{ record }">
<span v-if="record.seqNo !== null">{{ record.seqNo }}</span>
<span v-else class="roster__muted">—</span>
</template>
</a-table-column>
<a-table-column title="姓名 / 学历" :width="120">
<template #cell="{ record }">
<div class="roster__name">{{ record.realName }}</div>
<div class="roster__muted">{{ record.education || '—' }}</div>
</template>
</a-table-column>
<a-table-column title="证件号 / 电话" :width="190">
<template #cell="{ record }">
<!-- 后端只回脱敏值;这里不做任何"还原",界面上不该有完整号码 -->
<div class="roster__mono">{{ record.idCardMask || '—' }}</div>
<div class="roster__muted">{{ record.phoneMask || '未填' }}</div>
</template>
</a-table-column>
<a-table-column title="班级" :width="110" ellipsis tooltip>
<template #cell="{ record }">
<span v-if="record.orgName">{{ record.orgName }}</span>
<span v-else class="roster__muted">未指定</span>
</template>
</a-table-column>
<a-table-column title="报考(职业 / 工种 / 等级)" :width="230">
<template #cell="{ record }">
<div>{{ record.occupation || '—' }}</div>
<div class="roster__muted">
{{ record.worktype || '—' }} · {{ record.jobLevel || '—' }}
</div>
</template>
</a-table-column>
<a-table-column title="状态" :width="130" align="center">
<template #cell="{ record }">
<a-tag :color="SUBMIT_STATUS[record.submitStatus]?.color ?? 'gray'" size="small">
{{ SUBMIT_STATUS[record.submitStatus]?.text ?? '未知' }}
</a-tag>
<div class="roster__muted">
{{ record.bindStatus === 1 ? '已核验' : '未核验' }}
</div>
</template>
</a-table-column>
<a-table-column title="其它" :width="96" align="center">
<template #cell="{ record }">
<a-tooltip v-if="record.extJson && Object.keys(record.extJson).length"
:content="extText(record.extJson)">
<a-tag size="small">扩展字段 {{ Object.keys(record.extJson).length }}</a-tag>
</a-tooltip>
<span v-else class="roster__muted">—</span>
</template>
</a-table-column>
</template>
</a-table>
</a-drawer>
</template>
<script setup lang="ts">
import type { RosterImportReport, RosterVO } from '@/apis/xt/roster'
import type { TaskVO } from '@/apis/xt/task'
import { Message, Modal } from '@arco-design/web-vue'
import { IconRefresh, IconUpload } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import * as rosterApi from '@/apis/xt/roster'
import { SUBMIT_STATUS } from '@/apis/xt/roster'
/**
* 任务「名单管理」抽屉(《03-接口设计》§8.1 ~ §8.2)—— FR-501 / FR-502。
*
* ## 为什么这个界面必须存在
*
* 发布任务的 5 项前置检查里有一项是「名册不能为空」(40016)。在它之前,
* 名册只能靠直接改数据库来造 —— 而**名册是准入名单**:学生能不能进来填、
* 填的是谁,全靠它。也就是说这个界面之前,整条填报链路在界面上是走不通的。
*
* ## 三处刻意的设计
*
* 1. **先体检、再导入(两步)**。实测两份真实文件的表头分别在第 1 行和第 6 行,
* 工作表名也不同。一步导入的话,用户只会看到"成功 0 条",
* 然后以为自己的文件坏了 —— 而真相是系统把标题行当成了表头。
* 所以:先把"系统认的是第几行、认成了哪几列、哪几行有问题"摊开给他看,
* **确认之后才写库**。
*
* 2. **把"表头在第 6 行"解释清楚**。看到"第 6 行"的人第一反应是"导错了"。
* 所以那句提示里直接写明「它前面那 5 行是标题与说明,已跳过」——
* 不解释的话,用户会去改自己那份**本来正确**的表格。
*
* 3. **行号 + 列字母 + 原值 + 原因,四样一起给**。只给"第 12 行有问题",
* 用户还得自己回去找是哪一列;给了列字母与原因,他能直接改完再导一次。
* 而"改好再来一次"是这个界面的正常用法(同证件号会合并更新,不会重复)。
*/
defineOptions({ name: 'TaskRosterDrawer' })
const emit = defineEmits<{
(e: 'save-success'): void
}>()
const visible = ref(false)
const taskId = ref<number | null>(null)
const taskName = ref('')
const taskStatus = ref(0)
/** 与服务端 `RosterImportService.EDITABLE_TASK_STATUS` 同口径:{草稿, 收集中, 已暂停} */
const EDITABLE = [0, 1, 4]
const readonly = computed(() => !EDITABLE.includes(taskStatus.value))
const readonlyReason = computed(() => (taskStatus.value === 2 ? '截止' : '归档'))
const collecting = computed(() => taskStatus.value === 1)
// ── 导入 ──────────────────────────────────────────
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const file = ref<File | null>(null)
const mode = ref<'APPEND' | 'REPLACE'>('APPEND')
const orgId = ref<number | undefined>(undefined)
const checking = ref(false)
const importing = ref(false)
const report = ref<RosterImportReport | null>(null)
const manualHeaderRow = ref<number | undefined>(undefined)
/** 整表替换的二次确认只做一次(用户点过一次「确认替换」之后就走真导入) */
const replaceConfirmed = ref(false)
const modeHint = computed(() =>
mode.value === 'REPLACE'
? '替换:清掉现有名册再导入(已有人核验时会拒绝)'
: '合并:按身份证号去重,已有的更新、没有的新增'
)
/** dryRun 时后端把预计结果放在 created/updated 里;这里用同一份数据,只为把标签说准 */
const plannedNew = computed(() => report.value?.created ?? 0)
const plannedUpdate = computed(() => report.value?.updated ?? 0)
const headerAlertType = computed(() => (report.value?.headerConfirmed ? 'success' : 'warning'))
const importButtonText = computed(() => {
if (!report.value) return '确认导入'
if (report.value.dryRun) {
return mode.value === 'REPLACE'
? `确认替换:导入这 ${report.value.success} 行`
: `确认导入这 ${report.value.success} 行`
}
return '已完成导入'
})
/**
* 列的中文名 —— 报告里回的是字段(`real_name`),直接展示用户看不懂。
* 只覆盖官方 21 列;多出来的列报告里已标成"非官方列"。
*/
const FIELD_NAMES: Record<string, string> = {
seq_no: '序号',
real_name: '姓名',
id_card_no: '身份证号',
education: '学历',
phone: '联系电话',
work_unit: '工作单位',
occupation: '报考职业',
worktype: '报考工种',
job_level: '报考等级',
is_retake: '是否补考',
retake_theory: '补考理论',
retake_practice: '补考实操',
retake_overall: '补考综合',
is_student: '是否在校生',
school: '在读学校',
major: '所学专业',
enroll_date: '入学时间',
work_years: '本职业工作年限',
cert_occupation: '已获证书职业工种',
cert_level: '已获证书等级',
cert_no: '已获证书编号'
}
function fieldName(field: string | null) {
if (!field) return '—'
return FIELD_NAMES[field] ?? field
}
function extText(ext: Record<string, unknown>) {
return Object.entries(ext).map(([k, v]) => `${k}:${String(v)}`).join('\n')
}
// ── 班级下拉 ───────────────────────────────────────
interface ClassOption {
id: number
label: string
}
const classOptions = ref<ClassOption[]>([])
const orgLoading = ref(false)
async function loadClasses() {
if (classOptions.value.length) {
return
}
orgLoading.value = true
try {
const res = await orgApi.getOrgTree()
const out: ClassOption[] = []
const walk = (nodes: orgApi.OrgTreeVO[] | undefined, parent: string) => {
for (const n of nodes ?? []) {
if (n.orgType === 3) {
out.push({ id: n.id, label: parent ? `${parent} / ${n.orgName}` : n.orgName })
}
walk(n.children, n.orgType === 3 ? parent : n.orgName)
}
}
walk(res.data, '')
classOptions.value = out
} finally {
orgLoading.value = false
}
}
// ── 名册列表 ───────────────────────────────────────
const listLoading = ref(false)
const rows = ref<RosterVO[]>([])
const total = ref(0)
const query = reactive<{
keyword: string
submitStatus: number | undefined
bindStatus: number | undefined
pageNum: number
pageSize: number
}>({ keyword: '', submitStatus: undefined, bindStatus: undefined, pageNum: 1, pageSize: 10 })
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
/**
* 一句话统计。没有它,用户看不出"名册导进来了没有、有多少人已经开始填",
* 而这些恰恰是他打开这个抽屉最想知道的两件事。
*/
const stat = computed(() => {
const bound = rows.value.filter((r) => r.bindStatus === 1).length
const submitted = rows.value.filter((r) => r.submitStatus > 0).length
return { t: total.value > 0, bound, submitted }
})
async function loadList() {
if (!taskId.value) {
return
}
listLoading.value = true
try {
const res = await rosterApi.getRosterList(taskId.value, {
keyword: query.keyword.trim() || undefined,
submitStatus: query.submitStatus,
bindStatus: query.bindStatus,
pageNum: query.pageNum,
pageSize: query.pageSize
})
rows.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
listLoading.value = false
}
}
function search() {
query.pageNum = 1
loadList()
}
function onPageChange(page: number) {
query.pageNum = page
loadList()
}
// ── 打开 / 关闭 ────────────────────────────────────
async function open(task: TaskVO) {
visible.value = true
taskId.value = task.id
taskName.value = task.taskName
taskStatus.value = task.status
resetReport()
query.keyword = ''
query.submitStatus = undefined
query.bindStatus = undefined
query.pageNum = 1
await Promise.all([loadClasses(), loadList()])
}
function close() {
resetReport()
}
function resetReport() {
file.value = null
report.value = null
manualHeaderRow.value = undefined
mode.value = 'APPEND'
orgId.value = undefined
if (fileInput.value) {
fileInput.value.value = ''
}
}
function pickFile() {
fileInput.value?.click()
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const picked = input.files?.[0] ?? null
if (picked && picked.size > 10 * 1024 * 1024) {
Message.warning('文件超过 10MB,请拆分后分批导入')
input.value = ''
file.value = null
return
}
file.value = picked
report.value = null
}
// ── 体检 / 导入 ────────────────────────────────────
async function runDryRun(headerRow?: number) {
if (!file.value || !taskId.value) {
return
}
checking.value = true
try {
const res = await rosterApi.importRoster(taskId.value, file.value, {
dryRun: true,
mode: mode.value,
orgId: orgId.value,
headerRow: headerRow ?? undefined
})
report.value = withKeys(res.data ?? null)
manualHeaderRow.value = report.value?.headerRow
} catch (e) {
// 40028(认不出表头)/ 40017(缺关键列)是**预期内的结果**,不是系统故障:
// 把它当"需要人工指定表头"来处理,比弹一句红字有用
const err = e as Error & { bizCode?: number }
if (err?.bizCode === 40028 || err?.bizCode === 40017) {
Modal.info({
title: '需要人工指定表头',
content: `${err.message}
请在下方把表头行号填上再试一次(比如标题在第 1 行、说明在第 3 行、表头在第 6 行时,填 6)。`,
hideCancel: true
})
}
} finally {
checking.value = false
}
}
/** 行 key:报告里的行号可能有多条问题,拼上 code 才是唯一的 */
function withKeys(r: RosterImportReport | null): RosterImportReport | null {
if (!r) return null
const tag = (list: RosterImportReport['errors']) =>
list.map((x, i) => ({ ...x, key: `${x.row}-${x.code}-${i}` })) as RosterImportReport['errors']
return { ...r, errors: tag(r.errors), warnings: tag(r.warnings) }
}
async function confirmImport() {
if (!file.value || !taskId.value || !report.value) {
return
}
if (mode.value === 'REPLACE' && !replaceConfirmed.value) {
Modal.warning({
title: '整表替换会清掉现有名册',
content: `现有的 ${total.value} 条名册会被替换成这份文件里的 ${report.value.success} 条。
已经核验或已提交的人会被拒绝(服务端会拦下整次替换)。
确认要用「替换」而不是「合并」吗?`,
okText: '确认替换',
hideCancel: false,
onBeforeOk: () => {
replaceConfirmed.value = true
void confirmImport()
return false
}
})
return
}
importing.value = true
try {
const res = await rosterApi.importRoster(taskId.value, file.value, {
dryRun: false,
mode: mode.value,
orgId: orgId.value,
headerRow: manualHeaderRow.value
})
report.value = withKeys(res.data ?? null)
const d = report.value
const removedPart = d?.removed ? `、移除 ${d.removed} 人` : ''
Message.success(`导入完成:新增 ${d?.created ?? 0} 人、更新 ${d?.updated ?? 0} 人${removedPart}`)
query.pageNum = 1
await loadList()
emit('save-success')
} finally {
importing.value = false
replaceConfirmed.value = false
}
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.roster {
&__task { margin-left: 12px; color: var(--color-text-3); font-weight: 400; }
&__alert { margin-bottom: 16px; }
&__intro { margin-bottom: 16px; line-height: 1.8; }
&__form { margin-bottom: 4px; }
&__item { margin-bottom: 8px; }
&__pick { display: flex; gap: 10px; align-items: center; }
&__file { display: none; }
&__filename {
max-width: 220px;
overflow: hidden;
font-size: 13px;
color: var(--color-text-1);
text-overflow: ellipsis;
white-space: nowrap;
}
&__hint { font-size: 12px; line-height: 1.7; color: var(--color-text-3); }
&__muted { font-size: 12px; color: var(--color-text-3); }
&__mono { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
&__name { font-weight: 500; color: var(--color-text-1); }
&__tool { margin-bottom: 12px; }
&__table { margin-bottom: 8px; }
&__mapped { color: rgb(var(--primary-6)); }
&__code { margin-left: 6px; }
&__section {
margin: 4px 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
span { margin-left: 8px; font-size: 12px; font-weight: 400; color: var(--color-text-3); }
&--bad { color: rgb(var(--danger-6)); }
&--warn { color: rgb(var(--warning-6)); }
}
&__stats { margin-bottom: 16px; }
&__stat {
padding: 10px 8px;
text-align: center;
background: var(--color-fill-2);
border-radius: 6px;
&--ok .roster__stat-num { color: rgb(var(--success-6)); }
&--bad .roster__stat-num { color: rgb(var(--danger-6)); }
&--warn .roster__stat-num { color: rgb(var(--warning-6)); }
}
&__stat-num {
font-size: 22px;
font-weight: 600;
line-height: 1.3;
color: var(--color-text-1);
}
&__stat-label { font-size: 12px; color: var(--color-text-3); }
&__header-alert { margin-bottom: 12px; line-height: 1.8; }
&__footer { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; margin: 12px 0 4px; }
&__search { width: 240px; }
&__filter { width: 130px; }
}
</style>
+26 -21
View File
@@ -31,27 +31,17 @@
下面 6 列合计 840 —— 只差 10px,几乎不出现横向滚动条。 下面 6 列合计 840 —— 只差 10px,几乎不出现横向滚动条。
之前给到 1130px,结果是横向滚动 + 钉住的「操作」列把「状态」整个盖住, 之前给到 1130px,结果是横向滚动 + 钉住的「操作」列把「状态」整个盖住,
表头挤成了「进度操作」。**改列宽前先按这个口径算一遍。** 表头挤成了「进度操作」。**改列宽前先按这个口径算一遍。**
-->
<!--
⚠️ 关于列宽与「操作」列的 fixed:**改之前先量一下表格可用宽度。**
实测(1366 宽的屏幕): ⚠️ 2026-09-29 加「名单管理」时的账(新增一个链接,所以必须从别处挪宽度):
· 收集任务页:左侧栏 200,表格可用约 824px · 「任务」230 → 200、「填报时间」160 → 130,共省出 60
· 账号管理页:左侧栏 200 + 左侧组织树卡约 250,表格可用**只有约 574px** · 「操作」300 → 340(草稿态有 7 个链接,会折成两行 —— 这是**故意认下的**:
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
所以账号管理页必须保留 `fixed="right"`: · 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
列宽合计 800px 装不进 574px,必然横向滚动;
横向滚动时**钉住的操作列会盖住紧邻它的那一列**,这是固定列的固有行为,不是配置错了。
去掉 fixed 看似不压盖了,实际是把操作列滚出视野 ——
用户根本看不到「编辑 / 重置密码 / 停用」,那比压盖更糟。
要真正消除压盖只有一条路:**把列数减到能在 574px 内放下**。
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
--> -->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination" <a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 820 }" @page-change="onPageChange"> :scroll="{ x: 820 }" @page-change="onPageChange">
<template #columns> <template #columns>
<a-table-column title="任务" :width="230" ellipsis tooltip> <a-table-column title="任务" :width="200" ellipsis tooltip>
<template #cell="{ record }"> <template #cell="{ record }">
<div class="task__name">{{ record.taskName }}</div> <div class="task__name">{{ record.taskName }}</div>
<div class="task__code">{{ record.taskCode }}</div> <div class="task__code">{{ record.taskCode }}</div>
@@ -65,7 +55,7 @@
</template> </template>
</a-table-column> </a-table-column>
<a-table-column title="填报时间" :width="160"> <a-table-column title="填报时间" :width="130">
<template #cell="{ record }"> <template #cell="{ record }">
<div class="task__muted">{{ fmt(record.startTime) }} 起</div> <div class="task__muted">{{ fmt(record.startTime) }} 起</div>
<div class="task__muted">{{ fmt(record.endTime) }} 止</div> <div class="task__muted">{{ fmt(record.endTime) }} 止</div>
@@ -95,12 +85,14 @@
</template> </template>
</a-table-column> </a-table-column>
<a-table-column title="操作" :width="300" align="center" fixed="right"> <a-table-column title="操作" :width="340" align="center" fixed="right">
<template #cell="{ record }"> <template #cell="{ record }">
<a-space v-if="canManage" size="mini" wrap> <a-space v-if="canManage" size="mini" wrap>
<template v-if="record.status === TASK_STATUS_DRAFT"> <template v-if="record.status === TASK_STATUS_DRAFT">
<a-link @click="onEdit(record)">编辑</a-link> <a-link @click="onEdit(record)">编辑</a-link>
<a-link @click="onConfigOrgs(record)">配置班级</a-link> <a-link @click="onConfigOrgs(record)">配置班级</a-link>
<!-- 名册(准入名单)排在材料之前:没有它,学生连填报页都进不去 -->
<a-link @click="onConfigRoster(record)">名单管理</a-link>
<!-- 文案必须与服务端 40018 的提示「请到『材料规则』里配置…」一字不差, <!-- 文案必须与服务端 40018 的提示「请到『材料规则』里配置…」一字不差,
否则用户按提示找不到地方 --> 否则用户按提示找不到地方 -->
<a-link @click="onConfigMaterials(record)">材料规则</a-link> <a-link @click="onConfigMaterials(record)">材料规则</a-link>
@@ -114,6 +106,7 @@
<template v-else-if="record.status === TASK_STATUS_COLLECTING"> <template v-else-if="record.status === TASK_STATUS_COLLECTING">
<a-link @click="onConfigOrgs(record)">配置班级</a-link> <a-link @click="onConfigOrgs(record)">配置班级</a-link>
<a-link @click="onConfigRoster(record)">名单管理</a-link>
<a-link @click="onConfigMaterials(record)">材料规则</a-link> <a-link @click="onConfigMaterials(record)">材料规则</a-link>
<a-link @click="onConfigForm(record)">表单设计</a-link> <a-link @click="onConfigForm(record)">表单设计</a-link>
<a-link status="warning" @click="onPause(record)">暂停</a-link> <a-link status="warning" @click="onPause(record)">暂停</a-link>
@@ -122,6 +115,7 @@
<template v-else-if="record.status === TASK_STATUS_PAUSED"> <template v-else-if="record.status === TASK_STATUS_PAUSED">
<a-link @click="onConfigOrgs(record)">配置班级</a-link> <a-link @click="onConfigOrgs(record)">配置班级</a-link>
<a-link @click="onConfigRoster(record)">名单管理</a-link>
<a-link @click="onConfigMaterials(record)">材料规则</a-link> <a-link @click="onConfigMaterials(record)">材料规则</a-link>
<a-link @click="onConfigForm(record)">表单设计</a-link> <a-link @click="onConfigForm(record)">表单设计</a-link>
<a-link status="success" @click="onResume(record)">恢复</a-link> <a-link status="success" @click="onResume(record)">恢复</a-link>
@@ -129,10 +123,14 @@
</template> </template>
<template v-else-if="record.status === TASK_STATUS_CLOSED"> <template v-else-if="record.status === TASK_STATUS_CLOSED">
<!-- 截止后名册只能看不能改(抽屉里会说清),所以仍然给入口 -->
<a-link @click="onConfigRoster(record)">查看名单</a-link>
<a-link @click="onArchive(record)">归档</a-link> <a-link @click="onArchive(record)">归档</a-link>
</template> </template>
<span v-else class="task__muted">只读</span> <template v-else>
<a-link @click="onConfigRoster(record)">查看名单</a-link>
</template>
</a-space> </a-space>
<span v-else class="task__muted">只读</span> <span v-else class="task__muted">只读</span>
</template> </template>
@@ -145,6 +143,7 @@
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" /> <TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
<TaskMaterialDrawer ref="materialDrawerRef" @save-success="load" /> <TaskMaterialDrawer ref="materialDrawerRef" @save-success="load" />
<TaskFormSchemaDrawer ref="formDrawerRef" @save-success="load" /> <TaskFormSchemaDrawer ref="formDrawerRef" @save-success="load" />
<TaskRosterDrawer ref="rosterDrawerRef" @save-success="load" />
</GiPageLayout> </GiPageLayout>
</template> </template>
@@ -165,6 +164,7 @@ import TaskFormModal from './TaskFormModal.vue'
import TaskFormSchemaDrawer from './TaskFormSchemaDrawer.vue' import TaskFormSchemaDrawer from './TaskFormSchemaDrawer.vue'
import TaskMaterialDrawer from './TaskMaterialDrawer.vue' import TaskMaterialDrawer from './TaskMaterialDrawer.vue'
import TaskOrgDrawer from './TaskOrgDrawer.vue' import TaskOrgDrawer from './TaskOrgDrawer.vue'
import TaskRosterDrawer from './TaskRosterDrawer.vue'
/** /**
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。 * 收集任务(《03-接口设计》§7.1 ~ §7.5)。
@@ -195,6 +195,7 @@ const formModalRef = useTemplateRef('formModalRef')
const orgDrawerRef = useTemplateRef('orgDrawerRef') const orgDrawerRef = useTemplateRef('orgDrawerRef')
const materialDrawerRef = useTemplateRef('materialDrawerRef') const materialDrawerRef = useTemplateRef('materialDrawerRef')
const formDrawerRef = useTemplateRef('formDrawerRef') const formDrawerRef = useTemplateRef('formDrawerRef')
const rosterDrawerRef = useTemplateRef('rosterDrawerRef')
/** /**
* 发布被拦时,每个阻断项**去哪里修**。 * 发布被拦时,每个阻断项**去哪里修**。
@@ -204,8 +205,8 @@ const formDrawerRef = useTemplateRef('formDrawerRef')
* 让用户自己找是纯粹的浪费。 * 让用户自己找是纯粹的浪费。
*/ */
const BLOCKING_HINTS: Record<number, string> = { const BLOCKING_HINTS: Record<number, string> = {
40016: '请先到「名单管理」导入本任务的报考名册', 40016: '请点本行的「名单管理」,导入本任务的报考名册',
40017: '名册缺少「姓名」或「身份证号」列,请重新导入名册', 40017: '名册缺少「姓名」或「身份证号」列,请到本行的「名单管理」里重新导入',
// 必须说"点本行":「材料规则」也是一个顶层菜单(那个还是里程碑占位页), // 必须说"点本行":「材料规则」也是一个顶层菜单(那个还是里程碑占位页),
// 只说「请到『材料规则』里配置」会把人引到那个占位页去 // 只说「请到『材料规则』里配置」会把人引到那个占位页去
40018: '请点本行的「材料规则」,配置至少一项必传材料', 40018: '请点本行的「材料规则」,配置至少一项必传材料',
@@ -285,6 +286,10 @@ function onConfigForm(row: TaskVO) {
formDrawerRef.value?.open(row) formDrawerRef.value?.open(row)
} }
function onConfigRoster(row: TaskVO) {
rosterDrawerRef.value?.open(row)
}
async function onDelete(row: TaskVO) { async function onDelete(row: TaskVO) {
await taskApi.deleteTask(row.id) await taskApi.deleteTask(row.id)
Message.success('已删除') Message.success('已删除')
+1 -1
View File
@@ -268,7 +268,7 @@ def main() -> int:
cc = d.get("compressConfig") or {} cc = d.get("compressConfig") or {}
check("rosterRequired 恒为 true", d.get("rosterRequired") is True, check("rosterRequired 恒为 true", d.get("rosterRequired") is True,
f"rosterRequired={d.get('rosterRequired')}") f"rosterRequired={d.get('rosterRequired')}")
check("stat 为 null(名册未接,不能假装是 0)", check("名册为空时 stat 为 null(没有分母,不能假装是 0%)",
d.get("stat") is None, f"stat={d.get('stat')}") d.get("stat") is None, f"stat={d.get('stat')}")
check("auditConfig 默认:需班主任初审 + 需终审", check("auditConfig 默认:需班主任初审 + 需终审",
ac.get("needClassAudit") is True and ac.get("needAdminAudit") is True, ac.get("needClassAudit") is True and ac.get("needAdminAudit") is True,
+54
View File
@@ -491,6 +491,17 @@ def main() -> int:
sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND bind_user_id IS NOT NULL")[1] == "1", sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND bind_user_id IS NOT NULL")[1] == "1",
"绑定数不为 1") "绑定数不为 1")
# ── 3b. 任务统计快照(§3.3)现在该有分母了 ─────────────
print("\n[3b] 任务统计:名册接上之后,stat 不再恒为 null")
# TaskStatVO 的类注释里写着"名册为空时返回 null,由界面显示「待导入名册」"——
# 现在名册有 3 行,就该给出分母与比率,而不是继续显示"待导入名册"(那是**界面在说谎**)
_, det = call("GET", f"{base}/api/v1/tasks/{task_id}", admin)
st = (det.get("data") or {}).get("stat") or {}
check("stat 不再为 null(有名册就有分母)", st != {}, f"{st}")
check("rosterTotal = 名册行数 3", st.get("rosterTotal") == 3, f"{st}")
check("submitRate = 0%(3 人里还没人提交)", st.get("submitRate") == "0%", f"{st}")
check("unsubmittedCount = 3", st.get("unsubmittedCount") == 3, f"{st}")
# ── 4. 列表接口 ──────────────────────────────────── # ── 4. 列表接口 ────────────────────────────────────
print("\n[4] 名册列表(§8.2)") print("\n[4] 名册列表(§8.2)")
status, lst = call("GET", f"{base}/api/v1/tasks/{task_id}/roster?pageNum=1&pageSize=10", admin) status, lst = call("GET", f"{base}/api/v1/tasks/{task_id}/roster?pageNum=1&pageSize=10", admin)
@@ -557,6 +568,49 @@ def main() -> int:
check("指定了不存在的工作表 → 明确报错并列出可用表名", check("指定了不存在的工作表 → 明确报错并列出可用表名",
r8.get("code") == 40001 and SHEET_NAME in str(r8.get("msg") or ""), f"{r8}") r8.get("code") == 40001 and SHEET_NAME in str(r8.get("msg") or ""), f"{r8}")
# ── 8. 配置闭环:这是名册导入真正的意义 ────────────────
print("\n[8] 配置闭环:名册到位后,任务应当能发布")
# 发布有 5 项前置校验,其中「名册非空」(40016)就是本次补上的那一项。
# 也就是说:在这之前,任务在界面上**根本发布不了** —— 这是第 2 步的落地标志。
# 发布还缺「覆盖班级」与「必传材料」:它们各自的界面在 M1-02 / M1-04 已验过,
# 这里用接口配齐,验的是"三样齐了之后发布真的通"。
_, orgs = call("GET", f"{base}/api/v1/orgs/tree", admin)
classes: list[dict] = []
def collect(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
collect(n.get("children"))
collect(orgs.get("data"))
if not classes:
fail("配置闭环", "租户里没有班级节点,无法验证发布")
else:
org_id = classes[0]["id"]
_, r9 = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", admin, {
"orgs": [{"orgId": org_id, "expectedCount": 3}]
})
check("配覆盖班级成功", r9.get("code") == 0, f"{r9}")
_, r10 = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", admin, {
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2
})
check("配必传材料成功", r10.get("code") == 0, f"{r10}")
# force=true 只跳过 P1 级提醒(本例里"没指定初审人"),
# 那 5 项阻断级校验(含 40016 名册非空)**force 也绕不过** —— 所以这条断言是真的
_, r11 = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", admin, {"force": True})
check("**发布成功**(名册 + 覆盖班级 + 必传材料 三样齐了)",
r11.get("code") == 0, f"{r11}")
_, after = call("GET", f"{base}/api/v1/tasks/{task_id}", admin)
check("任务状态变成「收集中」",
(after.get("data") or {}).get("status") == 1, f"{(after.get('data') or {}).get('status')}")
check("发布后 stat 仍带分母(rosterTotal=3)",
((after.get("data") or {}).get("stat") or {}).get("rosterTotal") == 3,
f"{(after.get('data') or {}).get('stat')}")
print() print()
print("=" * 76) print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")