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
+219
View File
@@ -0,0 +1,219 @@
/**
* 收集任务接口(《03-接口设计》§7.1 ~ §7.5)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
/** 审核流配置(§3.2) */
export interface AuditConfig {
needClassAudit: boolean
needAdminAudit: boolean
classAuditTimeoutHours: number
adminAuditTimeoutHours: number
maxRejectCount: number
allowProxyUpload: boolean
/** 批量通过只带无问题项。**铁律,不可关闭** —— 传 false 也会被服务端扳回 true */
batchApproveOnlyGreen: boolean
}
/** 压缩参数(§3.11) */
export interface CompressConfig {
defaultMaxSizeKb: number
defaultTargetDpi: number
defaultQuality: number
minQuality: number
hardFloorDpi: number
maxPixels: number
keepPngForScreenshot: boolean
markWhenForced: boolean
}
/**
* 任务统计快照(§3.3)。
*
* ⚠️ **M1 阶段后端恒返回 `null`** —— 它依赖名册(M2)与提交单(M3)。
* 界面要显示「待导入名册」,而不是把 null 当成 0 显示成"完成率 0%"。
*/
export interface TaskStat {
rosterTotal: number
submittedCount: number
approvedCount: number
pendingCount: number
rejectedCount: number
unsubmittedCount: number
submitRate: string
passRate: string
abnormalCount: number
avgAuditHours: number
}
/** 收集任务(§3.1) */
export interface TaskVO {
id: number
taskCode: string
taskName: string
/** 1 学校收集 / 2 社会收集(二期) */
collectMode: number
batchNo: string
majorName: string
jobName: string | null
jobLevel: string | null
/** 1 自主认定 / 2 社会评价(二期)/ 3 专项职业能力(二期) */
certType: number
startTime: string | null
endTime: string | null
auditConfig: AuditConfig
namingTemplate: string
compressConfig: CompressConfig
/** 恒为 true —— 名单门禁是产品根基 */
rosterRequired: boolean
/** 0 草稿 / 1 收集中 / 2 已截止 / 3 已归档 / 4 已暂停 */
status: number
publishedTime: string | null
/** M1 阶段恒为 null */
stat: TaskStat | null
createTime: string
}
/** 建任务请求(§7.2) */
export interface TaskUpsertReq {
taskName: string
collectMode: number
batchNo: string
majorName: string
jobName?: string | null
jobLevel?: string | null
certType: number
startTime: string
endTime: string
auditConfig?: Partial<AuditConfig>
namingTemplate?: string
compressConfig?: Partial<CompressConfig>
}
export interface TaskCreateResult {
id: number
taskCode: string
status: number
}
export interface TaskPublishResult {
status: number
publishedTime: string
warnings: string[]
qrcodeUrl: string
}
export interface TaskListQuery {
keyword?: string
status?: number
collectMode?: number
batchNo?: string
orgId?: number
pageNum?: number
pageSize?: number
}
/** 任务分页列表。班主任只会拿到自己带的班级的任务 */
export function getTaskList(params: TaskListQuery) {
return http.get<{ list: TaskVO[], total: number }>('/tasks', params)
}
export function getTaskDetail(id: number) {
return http.get<TaskVO>(`/tasks/${id}`)
}
export function addTask(data: TaskUpsertReq) {
return http.post<TaskCreateResult>('/tasks', data)
}
export function updateTask(id: number, data: TaskUpsertReq) {
return http.put<void>(`/tasks/${id}`, data)
}
/** 删除:只有草稿可以删 */
export function deleteTask(id: number) {
return http.delete<void>(`/tasks/${id}`)
}
/** 发布。前置 5 项全是阻断级,force 无法绕过 */
export function publishTask(id: number, force = false) {
return http.post<TaskPublishResult>(`/tasks/${id}/publish`, { force })
}
/** 暂停:幂等。已提交的照常审核,且可以恢复 —— 与「截止」不是一回事 */
export function pauseTask(id: number, reason?: string) {
return http.post<void>(`/tasks/${id}/pause`, { reason })
}
/** 恢复:幂等 */
export function resumeTask(id: number) {
return http.post<void>(`/tasks/${id}/resume`)
}
/** 截止:不可逆 */
export function closeTask(id: number) {
return http.post<void>(`/tasks/${id}/close`, {})
}
/** 归档:不可逆,要求无待审单 */
export function archiveTask(id: number, archiveRemark?: string) {
return http.post<void>(`/tasks/${id}/archive`, { archiveRemark })
}
// ── 枚举镜像 ────────────────────────────────────────
/** 任务状态 → 文案与颜色(对应 collect_task.status) */
export const TASK_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '草稿', color: 'gray' },
1: { text: '收集中', color: 'green' },
2: { text: '已截止', color: 'orange' },
3: { text: '已归档', color: 'purple' },
4: { text: '已暂停', color: 'red' }
}
/** 收集模式(一期只启用 1) */
export const COLLECT_MODE_NAMES: Record<number, string> = {
1: '学校收集',
2: '社会收集(二期)'
}
/** 认定类型(一期只启用 1) */
export const CERT_TYPE_NAMES: Record<number, string> = {
1: '自主认定',
2: '社会评价(二期)',
3: '专项职业能力(二期)'
}
/** 任务状态常量,避免界面上到处写魔法数字 */
export const TASK_STATUS_DRAFT = 0
export const TASK_STATUS_COLLECTING = 1
export const TASK_STATUS_CLOSED = 2
export const TASK_STATUS_ARCHIVED = 3
export const TASK_STATUS_PAUSED = 4
/**
* 命名模板可用变量(与 PRD 的"可用变量"一栏逐字对齐)。
*
* 必须与服务端 `TaskService.NAMING_PLACEHOLDERS` 一致:
* 前端多给一个,用户点了会被服务端以 40020 拒掉。
*/
export const NAMING_VARIABLES = [
'姓名',
'证件号',
'证件类型',
'材料名',
'班级',
'专业',
'工种',
'等级',
'报名点',
'序号',
'日期',
'后缀'
] as const
/** 默认命名模板(与 DDL 的列默认值一致) */
export const DEFAULT_NAMING_TEMPLATE = '{姓名}-{证件号}-{材料名}'