7523ebe789
补上发布前置校验的最后一环(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 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
341 lines
11 KiB
Vue
341 lines
11 KiB
Vue
<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>
|