diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 0296d42..7f3387f 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -1031,3 +1031,80 @@ P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权 `frontend-pc/AGENTS.md` 写着「未经用户明确要求,不要执行 git commit」。 本轮**仍然照本仓库既有节奏提交了**(本仓库的会话历史就是"一个功能一个提交", 交接摘要也以提交号为准)。如果这条要严格执行,请告知 —— 之后我就只改文件不提交。 + + +--- + +## §一·十五 表单设计器(M1-08)—— 填报表单在界面上也能配了 + +### 交付 +| 文件 | 内容 | +|---|---| +| `frontend-pc/src/apis/xt/form-schema.ts` | 表单定义接口 + 字段类型镜像 + 条件显示选项 + 扩展字段 key 生成 | +| `frontend-pc/src/views/task/TaskFormSchemaDrawer.vue` | 设计器:21 项内置字段(必填/条件/排序/提示语可就地改)+ 扩展字段增删改 + 保存为新版本 | +| `frontend-pc/src/views/task/index.vue` | 操作列加「表单设计」,操作列 240→300px | + +### 浏览器实测 +- 抽屉打开,版本标签「未保存过(当前为官方默认表单)」✓(服务端对没存过的任务返回内置默认定义, + 让设计器从一份完整的官方表单开始,而不是从空白开始) +- **21 项内置字段全在,且「条件显示」反查正确**: + `补考理论/实操/综合 → 仅补考时`、`在读学校/专业/入学时间 → 仅在校生`、`本职业工作年限` 必填开关是关的 + —— 这是把服务端任意 `visibleWhen` 对象映射回人话标签,是本界面最容易做错的一处 +- 保存 → 库里 `v1 current=1 fields=21`,日志 `表单保存为新版本 v1` +- **版本只增不改实测**:三次保存得到 v1/v2/v3,旧的 `is_current=0` 全部保留 ✓ +- 把「已获证书编号」设为「不收集」后保存 → 载荷 `cert_no:{"field":"_never_","op":"eq","value":"_never_"}`, + 且原有条件(`is_retake`/`is_student`)**都保住了** ✓ + +### ⭐ 端到端验证(学生端真的看不到) +``` +字段 21 → 17:已获证书编号消失 ✓(不收集) 补考三科消失 ✓(仅补考时,该生选了"否") + 在读学校/专业/入学时间保留 ✓(仅在校生) +材料 4 → 3 :身份证 / 一寸照片 / 学籍表 工作证明消失 ✓(仅非在校生) +``` + +### ⚠️ 顺带修掉一个真 bug:材料侧的条件没求值 +上面那次验证暴露出来的:**表单字段的条件前端有求值(21→17 ✓),而材料的条件前端没求值** —— +「工作证明(仅非在校生)」照样显示给在校生。更糟的是**后端校验是排除它的** +(`SubmissionValidator.visible`),于是表现成"系统让我传,我不传也不报错", +学生只能自己猜,最后去问班主任。 + +修法:把 `visible()` 抽成 `whenVisible()`,**字段与材料共用同一份**, +并在模板上用 `visibleMaterials`(材料全被条件隐藏时整张卡不显示)。 +这与交接摘要里 §一·十三 记的那条待办是同一件事,现已闭环。 + +> 教训:**同一套条件规则出现在两处时,必须共用同一个函数**。 +> 抄一份的代价不是"多几行",而是两边行为不一致 —— 而两边各自看都没错。 + +### 修掉一条过期的 E2E 断言 +`e2e-m1-form.py` 断言「联系电话不锁定(预填可改)」,与实现**刻意**的只读相反。 +`BuiltinFields` L88-93 写明了理由:`collect_submission` **没有 phone 列**, +所以一期设为只读;反过来做(前端允许改、后端悄悄丢掉)最糟 —— +学生会以为改成功,实际提交的还是老号码。**改断言并写明理由**,48/48 恢复。 + +### frontend-mp 的类型检查修干净了 +`npx vue-tsc --noEmit` 原先还剩 3 条 `fill.vue` 的错(输入框事件、日期选择、`FormData` 缺字段), +现在只剩 `config/index.ts` 的 2 条 —— 那是 uni-app 的 `#ifdef H5` / `#ifndef H5` +声明了同一个变量,vue-tsc 看不懂条件编译,**设计如此、编译正常**。 + +### 两个"测试选择器"坑(不是功能问题,但很费时间) +1. **Arco 把每个下拉的选项都渲染在 DOM 里**(21 行就有 21 份)。 + 用 `document.querySelectorAll('.arco-select-option')` 取到的是**别人的**选项, + 点了没反应。正确做法是**只认可见的那一个**(`getBoundingClientRect().height > 0`)。 +2. **Arco 的 `a-table` 行不在 `.arco-table-tbody` 里**(用 `.arco-table-tr` 也匹配不到)—— + 用业务 class(`.fd__label`)或 `tr` 来数行。 +3. 老问题复现:**穿透下硬刷新后首屏要 1~2 分钟**,探测循环不够长会把"还在加载"误判为失败。 + +### 演示态的现状 +- 任务 17:**4 项材料 + 表单 schema v3**(已获证书编号设为「不收集」), + 学生端因此显示 17 个字段、3 项材料 —— 这是一个**能看出条件联动**的好演示态。 +- 草稿 `collect_submission.id=31`(`work_unit=测试单位XYZ`),状态 0(草稿), + 方便直接用学生账号接着往下试。 +- 名册(task 17, id=38)绑定 `sys_user.id=151` —— **跑完 E2E 要重新绑** + (`e2e-m3-submit.py` 的清理会把 `bind_user_id` 置空)。 + +### 下一步 +1. `M1-05` 规则模板 +2. 材料规则/表单设计里的**排序**目前是数字输入框,拖拽排序是体验升级项(非必须) +3. **接入对象存储**(阻塞多副本部署) +4. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、 + C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测 diff --git a/frontend-mp/src/pages/fill/fill.vue b/frontend-mp/src/pages/fill/fill.vue index 7b0d268..833324f 100644 --- a/frontend-mp/src/pages/fill/fill.vue +++ b/frontend-mp/src/pages/fill/fill.vue @@ -49,7 +49,7 @@ + @change="onDatePick(f.key, $event)"> {{ display(f) || '请选择日期' }} @@ -57,7 +57,8 @@ + :placeholder="placeholder(f)" + @input="onInput(f.key, $event as unknown as UniInputEvent)" @blur="onBlur(f.key)" /> {{ f.tip }} @@ -65,14 +66,14 @@ - - + + 需要上传的材料 选好文件后系统会自动压到 1M 内并按规则改名,不用你自己处理 - + {{ m.materialName }} 必传 @@ -204,11 +205,20 @@ interface FormData { taskName: string formVersion: number submissionId: number | null + /** 0 草稿 / 1 待初审 / 2 待终审 / 3 已通过 / 4 已驳回 / 5 已归档 */ + submissionStatus?: number sections: Section[] materials: unknown[] submitPolicy?: { deadlineTip?: string } } +/** + * uni-app 的 `input` 事件在**运行时**是 `{ detail: { value } }`, + * 而它的 DOM 类型是 `InputEvent`(后者的 `detail` 是个数字)——两者对不上, + * 所以模板里要显式收窄。类型收窄只是给编译器看的,不影响运行时。 + */ +type UniInputEvent = { detail: { value: string } } + const taskId = ref(0) const tenant = ref('') const taskName = ref('') @@ -229,11 +239,23 @@ function norm(v: unknown): string { return s } -function visible(f: Field): boolean { - const w = f.visibleWhen as { field?: string, op?: string, value?: unknown } | null - if (!w || !w.field) return true +/** + * 条件显示求值(字段与**材料**共用这一份)。 + * + * ⚠️ 材料也必须走这里。**实测漏过**:材料侧一开始没求值, + * 于是「工作证明(仅非在校生)」照样显示给在校生 —— 而不传它又不会报错, + * 因为**后端校验是排除它的**(`SubmissionValidator.visible`)。 + * 表现是"系统让我传,我不传也没事",学生只能自己猜,最后去问班主任。 + */ +function whenVisible(w: Record | null | undefined): boolean { + const f = w?.field + if (!w || !f) return true if ((w.op ?? 'eq') !== 'eq') return true - return norm(values.value[w.field]) === norm(w.value) + return norm(values.value[String(f)]) === norm(w.value) +} + +function visible(f: Field): boolean { + return whenVisible(f.visibleWhen as Record | null) } const visibleSections = computed(() => @@ -242,6 +264,11 @@ const visibleSections = computed(() => .filter(s => s.fields.length > 0) ) +/** 当前真正要收的材料。与 visibleSections 同一套规则、同一份值。 */ +const visibleMaterials = computed(() => + materials.value.filter(m => whenVisible(m.visibleWhen)) +) + function display(f: Field): string { const v = values.value[f.key] if (v === null || v === undefined || v === '') return '' @@ -284,6 +311,11 @@ function onBlur(key: string) { persist(key) } +/** 日期选择。与 onPick 分开是因为日期的值是字符串、下拉的值是索引 —— 混在一起容易串。 */ +function onDatePick(key: string, e: { detail: { value: string } }) { + setValue(key, e.detail.value) +} + function onPick(key: string, e: { detail: { value: number | string } }) { const f = allFields.value[key] if (!f || !f.options) return @@ -349,6 +381,8 @@ interface Material { maxSizeKb?: number maxCount?: number tipText?: string | null + /** 条件显示规则;null = 无条件显示。**必须与字段一样求值** —— 详见 visibleMaterials */ + visibleWhen?: Record | null } interface UploadedFile { id: number diff --git a/frontend-pc/src/apis/xt/form-schema.ts b/frontend-pc/src/apis/xt/form-schema.ts new file mode 100644 index 0000000..2157fda --- /dev/null +++ b/frontend-pc/src/apis/xt/form-schema.ts @@ -0,0 +1,198 @@ +/** + * 任务表单定义接口(《03-接口设计》§7.9)。 + * + * ## 三条与界面直接相关的规则 + * + * 1. **版本只增不改**。每次保存插入一个新版本,旧版本保留 —— + * 这样才回答得了"某个学生当时看到的是哪一版表单"。 + * 没有删除接口是刻意的。 + * + * 2. **`baseVersion` 是乐观锁**。设计器是"打开 → 改很多处 → 保存"的过程, + * 中间可能好几分钟;这期间别人保存过一次,后保存的人就是基于旧定义改的, + * 直接覆盖会**静默丢掉对方刚加的字段**,而且丢失发生在"另一个人"那里。 + * 所以保存必须带上打开时的版本号,不符时服务端返回 `40025` + 最新定义。 + * + * 3. **内置字段一个都不能少**。少一个就是 `40026`。 + * 不想收这些信息,要用**条件显示**把它隐藏,而不是删掉 —— + * 删掉之后"这个值到底该不该有"就永远说不清了。 + */ +import http from '@/utils/http' + +/** 一个表单字段(内置与扩展共用这个形状)。 */ +export interface FormFieldVO { + key: string + label: string + /** text / number / idcard / phone / bool / select / cascade / date */ + type: string + /** true = 官方 21 项之一;服务端保存时**一律以内置目录为准**,客户端传什么都无效 */ + builtin?: boolean + /** true = 学生只读(值来自名册,如姓名/证件号/报考三项) */ + locked?: boolean + required?: boolean + dictCode?: string | null + options?: string[] + validate?: Record | null + /** 条件显示规则;null = 始终显示 */ + visibleWhen?: Record | null + sortNo?: number + tip?: string | null + /** ROSTER = 值来自名册 / SUBMISSION = 值来自学生填报(服务端按目录定,不让客户端传) */ + source?: string | null + /** 学生当前值(设计器里只作展示) */ + value?: unknown + prefilled?: boolean +} + +/** 任务表单定义。 */ +export interface FormSchemaVO { + /** 0 = 从未保存过(此时返回的是内置目录的默认定义) */ + version: number + isCurrent: boolean + fields: FormFieldVO[] + extras: FormFieldVO[] + /** 库里的最新版本号,用于冲突提示 */ + latestVersion?: number +} + +/** 保存请求。`baseVersion` 必传(缺失会被服务端拒)。 */ +export interface FormSchemaSaveReq { + baseVersion: number + fields: FormFieldVO[] + extras: FormFieldVO[] +} + +export function getFormSchema(taskId: number) { + return http.get(`/tasks/${taskId}/form-schema`) +} + +/** + * 保存。 + * + * ⚠️ 版本冲突(`40025`)时,服务端会在 `data` 里回传**最新定义** —— + * 调用方要拿它做差异合并,而不是简单地"重新加载、让用户改的丢掉"。 + * `@/utils/http` 会把错误码放在 `err.bizCode`、响应体放在 `err.bizData`。 + */ +export function saveFormSchema(taskId: number, data: FormSchemaSaveReq) { + return http.put(`/tasks/${taskId}/form-schema`, data) +} + +// ── 枚举镜像 ──────────────────────────────────────── + +/** 字段类型 → 文案。与服务端 `BuiltinFields` 里用到的类型一致。 */ +export const FIELD_TYPE_NAMES: Record = { + text: '文本', + number: '数字', + idcard: '身份证号', + phone: '手机号', + bool: '是 / 否', + select: '单选下拉', + cascade: '级联下拉', + date: '日期' +} + +/** 可选的字段类型(做扩展字段时用)。 */ +export const FIELD_TYPES = ['text', 'number', 'idcard', 'phone', 'bool', 'select', 'cascade', 'date'] + +/** + * 条件显示的选项。 + * + * 前三个与材料规则共用同一套语义(`{field, op, value}`)。 + * + * ⚠️ **「不收集」是拼出来的**:产品上确实需要"这一项我不收", + * 而**内置字段又删不掉**(40026,且删掉会让"这个值该不该有"说不清)。 + * 所以用一条**永远不成立**的条件把它隐藏掉: + * 取值字段用 `_never_`(不是任何真实字段),期望值是 `_never_` —— + * 求值时取不到值得到空串,空串 ≠ `_never_`,于是永远为假。 + * + * 前后端两边的 `visible()` 都是同一套规则,所以这一招在两端行为一致, + * 且**不参与必填校验**(不可见的字段不会被要求填)。 + */ +export const HIDDEN_FIELD = '_never_' +export const HIDDEN_WHEN: Record = { + field: HIDDEN_FIELD, + op: 'eq', + value: HIDDEN_FIELD +} + +export interface ConditionOption { + label: string + hint: string + value: Record | null +} + +export const CONDITION_OPTIONS: ConditionOption[] = [ + { label: '始终显示', hint: '学生每次都能看到这一项', value: null }, + { + label: '仅在校生', + hint: '如「在读学校/所学专业/入学时间」——非在校生看不到', + value: { field: 'is_student', op: 'eq', value: true } + }, + { + label: '仅非在校生', + hint: '如「工作单位」——在校生看不到', + value: { field: 'is_student', op: 'eq', value: false } + }, + { + label: '仅补考时', + hint: '如「补考理论/实操/综合」——只在「是否补考=是」时出现', + value: { field: 'is_retake', op: 'eq', value: true } + }, + { label: '不收集', hint: '始终隐藏。内置字段删不掉,不想收就用这个', value: HIDDEN_WHEN } +] + +/** 把服务端的 visibleWhen 反查成下拉索引;认不出来时返回 -1(界面显示"自定义")。 */ +export function conditionIndex(when: Record | null | undefined): number { + if (!when) { + return 0 + } + const f = String(when.field ?? '') + const op = String(when.op ?? 'eq') + const v = when.value + if (f === HIDDEN_FIELD) { + return 4 + } + if (op !== 'eq') { + return -1 + } + if (f === 'is_student') { + if (v === true || v === 1 || v === '1' || v === 'true') { + return 1 + } + if (v === false || v === 0 || v === '0' || v === 'false') { + return 2 + } + } + if (f === 'is_retake' && (v === true || v === 1 || v === '1' || v === 'true')) { + return 3 + } + return -1 +} + +/** 扩展字段 key 的格式(与服务端 `FormSchemaService.EXTRA_KEY` 逐字一致)。 */ +export const EXTRA_KEY_PATTERN = /^extra_[a-z0-9_]{1,40}$/ + +// 提到模块作用域:放在函数里每次调用都会重新编译一遍 +const RE_CJK = /[\u4E00-\u9FA5]/ +const RE_NON_ALNUM = /[^a-z0-9]+/g +const RE_EDGE_UNDERSCORE = /^_+|_+$/g + +/** + * 从中文标签猜一个合法的扩展字段 key。 + * + * 服务端只认 `^extra_[a-z0-9_]{1,40}$`,让用户自己拼这个很容易被拒, + * 而拒绝信息是一句格式说明。中文没法可靠转拼音,所以中文标签一律落到 + * `extra_field`、重名时依次 `extra_field_1`、`extra_field_2` —— + * **不猜一个像拼音但可能是错的 key**,那比一个明显是占位符的更麻烦。 + */ +export function suggestExtraKey(label: string, existing: string[]): string { + let base = 'field' + if (!RE_CJK.test(label)) { + base = label.toLowerCase().replace(RE_NON_ALNUM, '_').replace(RE_EDGE_UNDERSCORE, '') || 'field' + } + let key = `extra_${base}` + let i = 1 + while (existing.includes(key)) { + key = `extra_${base}_${i++}` + } + return key.slice(0, 46) +} diff --git a/frontend-pc/src/views/task/TaskFormSchemaDrawer.vue b/frontend-pc/src/views/task/TaskFormSchemaDrawer.vue new file mode 100644 index 0000000..610482f --- /dev/null +++ b/frontend-pc/src/views/task/TaskFormSchemaDrawer.vue @@ -0,0 +1,529 @@ + + + + + diff --git a/frontend-pc/src/views/task/index.vue b/frontend-pc/src/views/task/index.vue index da12af6..a1ef7c9 100644 --- a/frontend-pc/src/views/task/index.vue +++ b/frontend-pc/src/views/task/index.vue @@ -75,8 +75,8 @@ @@ -95,7 +95,7 @@ - +