feat(task): M1-01 收集任务 CRUD 与状态机

后端:/tasks 建改删查 + publish/pause/resume/close/archive 六个动作,
按角色过滤可见范围(班主任只看自己带的班级的任务)。
前端:收集任务列表页 + 建/改任务弹窗(命名模板带实时预览与变量插入)。

验收:e2e-m1-task.py 71/71;e2e-m0.py 36/36、e2e-m0-user.py 55/55 回归通过;
前端 typecheck 0 错 / build 通过 / 浏览器肉眼确认。

三条硬约束(改动时当成规则):

1) 「暂停」与「截止」不是一回事。暂停:学生不能交、已提交的照常审、
   且可恢复;截止不可逆。做成一个会让"临时冻一下"变成不可逆操作。
2) 发布前置 5 项全是阻断级,force 绕不过:名册非空(40016)、名册含姓名与
   证件号(40017)、至少 1 项必传材料(40018)、已设置覆盖班级(40019)、
   命名模板可用(40020)。只有 P1 级提醒(某班未指定初审人)可 force 跳过。
3) collectMode / certType 发布后不可改 —— 它们决定审核流与材料清单,
   改了会让已填过的学生面对另一份表单。

本轮修掉的 3 个真问题:

1) collect_task_org 没有 deleted 列,而我让它继承了带 @TableLogic 的基类
   —— MyBatis-Plus 会给每条查询自动加 deleted = 0,运行时报"列不存在",
   编译期完全看不出来。改为不继承基类、显式声明字段;
   "移除"语义走 status = 0,不要对它调 deleteById(会变成物理删除)。

2) 任务编码查重被逻辑删除遮蔽:用 MP 的 selectCount 会自动过滤 deleted = 0,
   但数据库的唯一约束不看软删标记 —— 软删掉的任务仍占着那个编码,
   于是"查重说没有、插进去说重复",报 duplicate key(500)。
   另外原用 COUNT+1 也错:删掉中间一个后计数会退回去,算出已占用的号。
   改为 MAX(序号)+1,且查重语句刻意不加 deleted = 0(与唯一约束同口径);
   编码一次用过就不复用(它同时是文件目录名,复用会把两批文件混进同一目录)。

3) Boot 4 把 Jackson 自动配置挪到了 spring-boot-starter-jackson,项目没引,
   导致容器里没有 ObjectMapper bean(@Autowired 直接启动失败),
   且 application.yml 的 spring.jackson.* 静默失效 —— 其中
   time-zone: Asia/Shanghai 不生效最危险:本机时区恰好是上海时看不出来,
   部署到 UTC 服务器后所有时间会整体偏 8 小时。
   注意 spring-boot-starter-json 在 Boot 4 已无 4.x 版本(仓库里最高 3.5.6)。
   补依赖,但任务配置的 JSON 读写仍不用 Jackson —— Boot 4 用 Jackson 3
   而 jjwt-jackson 带来 Jackson 2,打包后两个 jackson-databind 并存
   (2.21.5 + 3.1.5),import 哪个都是赌;改用项目已直接声明的 hutool-all。

已知缺口(都写进了代码注释与交接摘要):

- 发布在前置数据齐备前不可达(名册属 M2、材料属 M1-04、覆盖班级属 M1-02),
  E2E 用 SQL 夹具造数据才走到发布之后的状态 —— M1-02/M1-04/M2 落地后
  要回来把夹具换成真实接口。
- 命名规则试跑目前只做静态校验(变量名合法 + 必含姓名/证件号/材料名),
  完整形态(拿真实名册跑一遍查非空与不重名)属 M1-09 / M3-01。
- stat 恒为 null(依赖名册与提交单)。故意不返回全 0 的对象 ——
  那会让界面显示"完成率 0%",而真相是"还没有分母";界面显示「待导入名册」。
- 任务编码并发撞号由唯一约束兜底 + 可读提示(不再 500),未做分布式锁。

顺带:

- 两个列表页的列宽按实测可用宽度重算(1366 屏:收集任务 824px、
  账号管理因左侧组织树只有 574px),并把"操作列必须 fixed + 列宽口径"
  写进注释 —— 之前把「状态」列整个遮住、把操作链接挤成竖排。
- 状态与最后登录并成一列,给窄屏省出 150px。
This commit is contained in:
2026-09-28 23:48:21 +08:00
parent 8480f431ba
commit daf42608d9
22 changed files with 3228 additions and 29 deletions
+63 -3
View File
@@ -46,9 +46,10 @@
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** |
| M0 验收 | ✅ **`python scripts/e2e-m0.py` → 36/36**;**`python scripts/e2e-m0-user.py` → 53/53** |
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / **账号管理** |
| 待做 | 小程序(uni-app)未创建;CI/CD;H5 保底预研;`hasActiveTask` 待 M1 补实现 |
| M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** |
| **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** |
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务** |
| 待做 | M1 剩余(M1-02 覆盖班级、M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 |
---
@@ -93,6 +94,65 @@
---
## 一·五、M1-01 任务 CRUD 与状态机(2026-09-28 夜,未提交)
> 范围:`GET/POST /tasks`、`GET/PUT/DELETE /tasks/{id}`、
> `POST /tasks/{id}/{publish|pause|resume|close|archive}`;前端「收集管理 → 收集任务」一屏。
**验收**:`python scripts/e2e-m1-task.py` → **71/71**;
`e2e-m0.py` 36/36、`e2e-m0-user.py` 55/55 回归通过;
前端 `pnpm typecheck` 0 错、`pnpm build` 通过。
### 三条硬约束(改动时当成规则,不是"可以商量的校验")
| # | 约束 | 为什么 |
|:--:|---|---|
| 1 | **「暂停」与「截止」是两件事** | 暂停:学生不能交,**已提交的照常审**,且**可恢复**(名单要改、规则要调时用)。截止:不可交、仍可审,**不可逆**。做成一个就等于把"临时冻一下"变成不可逆操作,现场没人敢点 |
| 2 | **发布前置 5 项全是阻断级**,`force` 绕不过 | 名册非空(40016)、名册含姓名与证件号(40017)、≥1 项必传材料(40018)、已设置覆盖班级(40019)、命名模板可用(40020)。可绕过就会出现"学生填了却没人审"或"文件名根本生成不出来"。只有 P1 级提醒(某班未指定初审人)可用 `force` 跳过 |
| 3 | **`collectMode` / `certType` 发布后不可改** | 它们决定审核流与材料清单;改了会让已经填过的学生面对另一份表单。归档后仍可改名称,只锁这两个 |
### 本轮修掉的 3 个真问题
| # | 问题 | 后果 | 修法 |
|:--:|---|---|---|
| 1 | **`collect_task_org` 没有 `deleted` 列** | 我起初让它继承 `TenantEntity`(基类带 `@TableLogic deleted`),MyBatis-Plus 会给**每条**查询自动加 `deleted = 0` → 运行时报「列不存在」。而这**编译期完全看不出来** | 该实体改成不继承基类、显式声明字段;"移除"语义走 `status = 0`(DDL 注释即如此),**不要对它调 `deleteById`**(会变成物理删除) |
| 2 | **任务编码查重被逻辑删除遮蔽** | 用 MP 的 `selectCount` 查重会自动过滤 `deleted = 0`,但**数据库的唯一约束不看软删标记** —— 软删掉的任务仍占着那个编码。表现为 `duplicate key value violates unique constraint "uk_task_tenant_code"`(500)。另外原用 `COUNT+1` 也错:删掉中间一个任务后计数会退回去,算出已占用的号 | 改为 `MAX(序号) + 1`,且**查重语句刻意不加 `deleted = 0`**(与唯一约束同口径);编码一次用过就不复用(它同时是**文件目录名**,复用会把两批任务的文件混进同一目录)。并发撞号由唯一约束兜底并返回可读提示,不再 500 |
| 3 | **Boot 4 的 Jackson 自动配置在 `spring-boot-starter-jackson`,项目没引** | 两个症状:① 容器里**没有 `ObjectMapper` bean**(`@Autowired` 直接启动失败);② `application.yml` 里 `spring.jackson.*` **静默失效** —— 其中 `time-zone: Asia/Shanghai` 不生效最危险:本机时区恰好是上海时看不出来,部署到 UTC 服务器后所有时间会整体偏 8 小时,且只表现为"时间看着不对"。**注意 `spring-boot-starter-json` 在 Boot 4 已无 4.x 版本**(仓库里最高 3.5.6),照老习惯找它是找不到的 | 在 `xt-common` 补 `spring-boot-starter-jackson`。但**任务配置的 JSON 读写仍不用 Jackson** —— Boot 4 用 Jackson 3(`tools.jackson`)而 `jjwt-jackson` 带来 Jackson 2,打包后 jar 里两个 `jackson-databind` 并存(2.21.5 + 3.1.5),import 哪个都是赌;改用项目已直接声明的 `hutool-all` |
### 已知缺口(有意留着)
| 项 | 说明 |
|---|---|
| **发布在前置数据齐备前不可达** | 发布要求名册(M2)、材料项(M1-04)、覆盖班级(M1-02)。所以 E2E 用 **SQL 夹具**造这三样,才能走到"已发布→暂停⇄恢复→截止→归档"并验证状态机。这是测试夹具,不是产品捷径 —— **M1-02/M1-04/M2 落地后要回来把夹具换成真实接口** |
| 命名规则试跑目前是**静态校验** | 只查"变量名是否合法"与"是否含姓名/证件号/材料名"。完整形态(拿真实名册前若干行跑一遍,检查生成的名字非空且两两不重复)需要名册数据,属 M1-09 / M3-01。当前这条已能拦住绝大多数错误(变量名写错、漏必备变量),且名册为空时发布本就会被 `40016` 先拦 |
| `stat` 恒为 `null` | 依赖名册(M2)与提交单(M3)。**故意不返回全 0 的对象** —— 那会让界面显示"完成率 0%",而真相是"还没有分母"。界面显式显示「待导入名册」 |
| 任务编码并发撞号 | 两个请求同时到达可能算出同一个号。唯一约束兜底 + 可读提示(不再 500)。一学期几十个任务,撞号概率极低,为此引独立序列表或分布式锁不划算 —— 但**别当成已解决** |
| `taskOrgMapper` 尚未用到 | M1-01 只用到 `TaskReadinessMapper` 里的计数;覆盖班级的增删改属 M1-02 |
### 前端踩到的坑
- **不要在校验/修复工具会碰的模板里拼花括号**。我写 `{{ '{' + v + '}' }}` 渲染命名变量按钮,`eslint --fix` 把它"帮忙"改成模板字符串 `` `{${v}}` `` —— 而里面的 `}}` 会截断 Vue 的插值解析器,报
`Error parsing JavaScript expression: Unexpected token, expected "}"`。
**这个错只在 `pnpm build` / 编译时出现,编辑器里看不出来。**
正确做法:在 `<script>` 里算好 `{ value, label }`,模板只做纯取值。
- **列宽要先量可用宽度,不能按像素凑总宽**(实测数据):
| 页面 | 可用宽度 | 原因 |
|---|---|---|
| 收集任务 | 约 **824px** | 只有 200 左侧栏 |
| 账号管理 | 约 **574px** | 200 左侧栏 + 约 250 左侧组织树卡 |
Arco 会按容器**重新分配**列宽:声明 120 的列被拉到 188,而 `fixed="right"` 的操作列被压到 105 —— 105px 装不下「编辑 / 重置密码 / 停用」,**竖排成了「编 辑 / 重 置 密 码 / 停 用」**。
更关键的是:列宽合计超过可用宽度时必然横向滚动,而**钉住的操作列会盖住紧邻它的那一列**(实测把「状态」整个遮住了,表头挤成「进度操作」)—— 这是固定列的固有行为,不是配置错误。
**踩过两次的教训:去掉 `fixed` 看似不压盖了,实际是把操作列滚出视野,用户根本看不到操作入口 —— 比压盖更糟。**
真正的解法只有一条:把列数减到能在可用宽度内放下(账号管理目前 5 列 800px,还需要再减一列)。
- ⚠️ **改中文内容一律用 Python 脚本文件,不要用 PowerShell 内联命令**(本项目已写进规则,我又犯了一次):
用 `python -c @"..."@` 内联改这两个 vue 文件时,PowerShell 把 `fixed="right"` 的 `f` 吃成了一个**不可见字符**(变成 `ixed="right"`),同时引入"不规则空白"。
肉眼完全看不出来,只有 eslint 的 `no-irregular-whitespace` 能发现。
---
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |