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.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<Integer> 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;
}
}
@@ -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):列表页一次算一页,不要每行查一次 ──────────
/**
* 一批任务的名册行数。
*
* <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.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<CollectTask> page = taskMapper.selectPage(new Page<>(num, size), q);
// 用 toFullVo:列表里就要显示审核流与压缩参数,走 toVo 会是 null
List<TaskVO> list = page.getRecords().stream().map(this::toFullVo).toList();
fillStats(list);
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() {
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;
}
// ── 新建 ──────────────────────────────────────