Files
xiaotutongxue/frontend-pc/src/views/task/TaskFormModal.vue
T
lovife 7523ebe789 feat(task): M1-02 覆盖班级与班主任配置
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。

后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用);
前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示
「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引
(BLOCKING_HINTS),而不是只说"未设置覆盖班级"。

三条守住的规则:
1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除",
   不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己
   的任务上,B 校的班主任就出现在 A 校的审批流里。
2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权,
   那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把
   "谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。
3) 移除是软移除(status=0),且「移除再加回」必须复用原行。
   后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status
   条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。

本轮修掉的 3 个真问题:
1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而
   CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。
   基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active
   和一段基于错误前提的代码,已全部撤掉并更正注释。
   教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。
2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾:
   DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在
   PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs
   名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职
   表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警
   变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉,
   而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值,
   并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。
3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package,
   target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行
   了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4,
   于是"两个 v4"→"checksum 冲突",排查绕了好几圈。

顺带修掉:
· 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进
  「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。
· defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。
  我此前写的 5 个组件都是被禁的 tuple 形式,一并改正:
  OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。
· e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally
  里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号
  留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。
  其余三条 E2E 脚本仍是老样子,下一轮一并改。

验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
2026-09-29 09:32:48 +08:00

341 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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<{
(e: 'save-success'): void
}>()
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>