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
@@ -0,0 +1,338 @@
<template>
<a-modal v-model:visible="visible" :title="title" :width="640" :mask-closable="false"
:ok-loading="submitting" @ok="confirm" @cancel="close">
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="14">
<a-form-item field="taskName" label="任务名称">
<a-input v-model="form.taskName" placeholder="如 2026年9月机电专业认定材料收集"
:max-length="200" allow-clear />
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item field="batchNo" label="批次号">
<a-input v-model="form.batchNo" placeholder="如 202609" :max-length="8" allow-clear />
<template #extra>
<span class="task-form__hint">4~8 位数字,同时作为任务编码前缀</span>
</template>
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item field="majorName" label="专业">
<a-input v-model="form.majorName" placeholder="如 数控加工" :max-length="64" allow-clear />
</a-form-item>
</a-col>
<a-col :span="7">
<a-form-item field="jobName" label="报考工种">
<a-input v-model="form.jobName" placeholder="可后补" :max-length="64" allow-clear />
</a-form-item>
</a-col>
<a-col :span="7">
<a-form-item field="jobLevel" label="报考等级">
<a-input v-model="form.jobLevel" placeholder="可后补" :max-length="32" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="startTime" label="填报开始">
<a-date-picker v-model="form.startTime" value-format="YYYY-MM-DD" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="endTime" label="填报截止">
<a-date-picker v-model="form.endTime" value-format="YYYY-MM-DD" style="width: 100%" />
<template #extra>
<span class="task-form__hint">只选日期时按当天 23:59:59 处理</span>
</template>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">一期范围</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="收集模式">
<a-input model-value="学校收集" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="认定类型">
<a-input model-value="自主认定" disabled />
</a-form-item>
</a-col>
</a-row>
<a-alert type="info" :show-icon="false" class="task-form__scope">
<template #title>
一期只做 <strong>学校收集 + 自主认定</strong>。
社会收集、社会评价、专项职业能力已后置二期,数据结构保留、界面暂不开放。
</template>
</a-alert>
<a-divider orientation="left">命名规则</a-divider>
<a-form-item field="namingTemplate" label="文件命名模板">
<a-input v-model="form.namingTemplate" :max-length="256" allow-clear />
<template #extra>
<span class="task-form__hint">
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code> ——
缺了任一项,文件名就无法唯一归属到人。
</span>
</template>
</a-form-item>
<div class="task-form__vars">
<span class="task-form__vars-label">插入变量</span>
<a-space wrap size="mini">
<a-button v-for="opt in namingVariableOptions" :key="opt.value" size="mini" type="outline"
@click="appendVariable(opt.value)">
{{ opt.label }}
</a-button>
</a-space>
</div>
<a-form-item label="预览(以「张同学 / 361029200912100010 / 身份证」为例)">
<code class="task-form__preview">{{ namingPreview }}</code>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { TaskUpsertReq, TaskVO } from '@/apis/xt/task'
import { Message } from '@arco-design/web-vue'
import * as taskApi from '@/apis/xt/task'
import { DEFAULT_NAMING_TEMPLATE, NAMING_VARIABLES } from '@/apis/xt/task'
/**
* 建 / 改任务弹窗。
*
* 三处刻意的设计:
* 1. **命名模板带实时预览**:光看 `{姓名}-{证件号}-{材料名}` 很难想象最终长什么样,
* 而它直接决定导出包里每个文件的名字。用样例数据渲染出来一眼就能判断。
* 2. **收集模式与认定类型做成只读**:它们决定审核流与材料清单,
* 一期也不启用其它取值。可编辑只会让人以为能改。
* 3. **变量用按钮插入而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
* 服务端只会在发布时给一个 40020,而那时已经配了一堆别的东西。
*/
defineOptions({ name: 'TaskFormModal' })
const emit = defineEmits<{ 'save-success': [] }>()
const visible = ref(false)
const submitting = ref(false)
const detailId = ref<number | null>(null)
const formRef = useTemplateRef('formRef')
const isEdit = computed(() => !!detailId.value)
const title = computed(() => (isEdit.value ? '修改任务' : '新建收集任务'))
/**
* 表单自己的类型。
*
* 与 {@link TaskUpsertReq} 的差别只在 `jobName` / `jobLevel`:
* 接口那边允许 `null`(表示"清空"),而 Arco 的 `a-input` 的 v-model 只接受
* `string | undefined` —— 直接绑 `string | null | undefined` 会报 TS2322。
* 界面上用空串表示"没填",发给服务端前由 Service 的 `trimToNull` 转成 null。
*/
type TaskForm = Omit<TaskUpsertReq, 'jobName' | 'jobLevel'> & {
jobName: string
jobLevel: string
}
const getInitForm = (): TaskForm => ({
taskName: '',
collectMode: 1,
batchNo: '',
majorName: '',
jobName: '',
jobLevel: '',
certType: 1,
startTime: '',
endTime: '',
namingTemplate: DEFAULT_NAMING_TEMPLATE
})
const form = reactive(getInitForm())
const rules: FormInstance['rules'] = {
taskName: [{ required: true, message: '请填写任务名称' }],
batchNo: [
{ required: true, message: '请填写批次号' },
{ match: /^\d{4,8}$/, message: '批次号应为 4~8 位数字' }
],
majorName: [{ required: true, message: '请填写专业' }],
startTime: [{ required: true, message: '请选择填报开始时间' }],
endTime: [
{ required: true, message: '请选择填报截止时间' },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value && form.startTime && value <= form.startTime) {
callback('截止时间必须晚于开始时间')
return
}
callback()
}
}
],
namingTemplate: [{ required: true, message: '请填写命名模板' }]
}
/**
* 预览时把 `{自定义字段.xxx}` 整体替换掉。
*
* 放在模块作用域而不是 computed 里:正则每次渲染都重新编译一遍是纯浪费,
* 而且这条规则(e18e/prefer-static-regex)在项目里是开着的。
*/
const CUSTOM_FIELD_RE = /\{自定义字段\.[^}]*\}/g
/** 命名预览:把变量替换成样例值,让用户一眼看出最终文件名长什么样 */
const namingPreview = computed(() => {
const sample: Record<string, string> = {
姓名: '张同学',
证件号: '361029200912100010',
证件类型: '居民身份证',
材料名: '身份证',
班级: '24中技数控1班',
专业: '数控加工',
工种: '数控车工',
等级: '四级',
报名点: '示范职业学校',
序号: '007',
日期: '20260928',
后缀: '.jpg'
}
let out = form.namingTemplate || ''
for (const [k, v] of Object.entries(sample)) {
out = out.replaceAll(`{${k}}`, v)
}
// 自定义字段在预览里没有真实值,用占位提示而不是留一个空
out = out.replace(CUSTOM_FIELD_RE, '(自定义字段)')
return out || '—'
})
/**
* 变量按钮的文案。
*
* ⚠️ **不要在模板里拼花括号**(比如 `{{ '{' + v + '}' }}`)。
* Vue 的插值解析器是靠找 `}}` 来收尾的,而模板里一旦出现内层的 `}` 与结尾的 `}}`
* 相邻(eslint --fix 会把它"帮忙"改成模板字符串 `` `{${v}}` ``,正好制造这种情况),
* 就会报 `Error parsing JavaScript expression: Unexpected token, expected "}"` ——
* 而且这个错误**只在构建/编译时才出现**,编辑器里看不出问题。
* 所以在 script 里算好,模板里只做纯取值。
*/
const namingVariableOptions = NAMING_VARIABLES.map((v) => ({ value: v, label: `{${v}}` }))
function appendVariable(v: string) {
form.namingTemplate = `${form.namingTemplate ?? ''}{${v}}`
}
async function add() {
detailId.value = null
Object.assign(form, getInitForm())
visible.value = true
}
function edit(row: TaskVO) {
detailId.value = row.id
Object.assign(form, getInitForm())
form.taskName = row.taskName
form.batchNo = row.batchNo ?? ''
form.majorName = row.majorName ?? ''
form.jobName = row.jobName ?? ''
form.jobLevel = row.jobLevel ?? ''
form.startTime = (row.startTime ?? '').slice(0, 10)
form.endTime = (row.endTime ?? '').slice(0, 10)
form.namingTemplate = row.namingTemplate ?? DEFAULT_NAMING_TEMPLATE
visible.value = true
}
function close() {
visible.value = false
}
async function confirm() {
const valid = await formRef.value?.validate()
if (valid) return false
submitting.value = true
try {
const payload: TaskUpsertReq = {
taskName: form.taskName.trim(),
collectMode: 1,
batchNo: (form.batchNo ?? '').trim(),
majorName: form.majorName.trim(),
jobName: form.jobName?.trim() || null,
jobLevel: form.jobLevel?.trim() || null,
certType: 1,
startTime: form.startTime,
// 只给日期时补上当天最后一秒,避免"截止日期当天 00:00 就截止了"
endTime: form.endTime.length <= 10 ? `${form.endTime}T23:59:59` : form.endTime,
namingTemplate: (form.namingTemplate ?? '').trim()
}
if (isEdit.value) {
await taskApi.updateTask(detailId.value!, payload)
Message.success('已保存')
} else {
const res = await taskApi.addTask(payload)
Message.success(`任务已创建,编码 ${res.data?.taskCode ?? ''}(草稿)`)
}
visible.value = false
emit('save-success')
return true
} finally {
submitting.value = false
}
}
defineExpose({ add, edit })
</script>
<style lang="scss" scoped>
.task-form {
&__hint {
font-size: 12px;
line-height: 1.7;
color: var(--color-text-3);
code {
padding: 0 3px;
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--color-fill-2);
border-radius: 2px;
}
}
&__scope {
margin-top: -6px;
line-height: 1.8;
}
&__vars {
display: flex;
gap: 10px;
align-items: baseline;
margin: -8px 0 18px;
}
&__vars-label {
flex: none;
font-size: 12px;
color: var(--color-text-3);
}
&__preview {
display: block;
width: 100%;
padding: 6px 10px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 13px;
color: var(--color-text-1);
word-break: break-all;
background: var(--color-fill-2);
border-radius: 4px;
}
}
</style>