feat(task): 表单设计器(M1-08)—— 填报表单在界面上也能配了
"能动态设置填报表单"是本产品的核心承诺之一,而在这之前表单定义只能用 SQL 改。
更要紧的是它有一个**不可逆的方向**:内置字段删不掉(40026),正确用法是
"用条件显示隐藏"—— 而只有界面上给这条路留了位置,用户才会照着做。
新增:
· apis/xt/form-schema.ts —— 表单定义接口 + 字段类型 + 条件选项 + 扩展字段 key 生成
· views/task/TaskFormSchemaDrawer.vue —— 21 项内置字段(必填/条件/排序/提示语就地改)
+ 扩展字段增删改 + 保存为新版本 + 版本冲突处理
· task/index.vue 操作列加「表单设计」,240→300px
浏览器实测:
· 版本标签「未保存过(当前为官方默认表单)」——服务端对没存过的任务返回内置默认定义,
让设计器从一份完整的官方表单开始,而不是从空白开始
· **21 项全在,且「条件显示」反查正确**:补考三科→仅补考时、
在读学校/专业/入学时间→仅在校生、本职业工作年限必填开关是关的。
把服务端任意 visibleWhen 对象映射回人话标签,是本界面最容易做错的一处
· 保存→库里 v1 current=1 fields=21;三次保存得到 v1/v2/v3,
旧的 is_current=0 全部保留(**版本只增不改**实测)
· 「不收集」= {field:"_never_",...}(内置字段删不掉,用一条永远不成立的条件隐藏);
保存后原有条件(is_retake/is_student)都保住了
⭐ 端到端:学生端 字段 21→17(已获证书编号消失、补考三科消失、在校生三项保留)、
材料 4→3(工作证明消失)
⚠️ 顺带修掉一个真 bug:**材料侧的条件没求值**
上面这次验证暴露的:表单字段的条件前端有求值(21→17),材料的没有 ——
「工作证明(仅非在校生)」照样显示给在校生;而后端校验是排除它的,
于是表现成"系统让我传,我不传也不报错",学生只能自己猜。
修法:把 visible() 抽成 whenVisible(),**字段与材料共用同一份**。
教训:同一套规则出现在两处时必须共用同一个函数 ——
抄一份的代价不是多几行,而是两边行为不一致,而两边各自看都没错。
修掉一条过期的 E2E 断言:e2e-m1-form.py 断言「联系电话不锁定」,
与实现刻意的只读相反(BuiltinFields L88-93:collect_submission 没有 phone 列,
所以一期只读;反过来做"前端允许改、后端悄悄丢掉"最糟 ——
学生以为改成功,实际提交的还是老号码)。改断言并写明理由,48/48 恢复。
frontend-mp 类型检查修干净:原先 fill.vue 还有 3 条(输入框事件、日期选择、
FormData 缺 submissionStatus),现在只剩 config/index.ts 的 2 条 ——
那是 uni-app 的 #ifdef H5/#ifndef H5 声明同一变量,vue-tsc 看不懂条件编译,设计如此。
验收:e2e-m1-form 48/48、materials 68/68、task 71/71、orgs 48/48、
m3-submit 31/31、file 29/29、draft 27/27、gate 29/29(合计 351 条断言全绿)
This commit is contained in:
@@ -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<string, unknown> | null
|
||||
/** 条件显示规则;null = 始终显示 */
|
||||
visibleWhen?: Record<string, unknown> | 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<FormSchemaVO>(`/tasks/${taskId}/form-schema`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存。
|
||||
*
|
||||
* ⚠️ 版本冲突(`40025`)时,服务端会在 `data` 里回传**最新定义** ——
|
||||
* 调用方要拿它做差异合并,而不是简单地"重新加载、让用户改的丢掉"。
|
||||
* `@/utils/http` 会把错误码放在 `err.bizCode`、响应体放在 `err.bizData`。
|
||||
*/
|
||||
export function saveFormSchema(taskId: number, data: FormSchemaSaveReq) {
|
||||
return http.put<FormSchemaVO>(`/tasks/${taskId}/form-schema`, data)
|
||||
}
|
||||
|
||||
// ── 枚举镜像 ────────────────────────────────────────
|
||||
|
||||
/** 字段类型 → 文案。与服务端 `BuiltinFields` 里用到的类型一致。 */
|
||||
export const FIELD_TYPE_NAMES: Record<string, string> = {
|
||||
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<string, unknown> = {
|
||||
field: HIDDEN_FIELD,
|
||||
op: 'eq',
|
||||
value: HIDDEN_FIELD
|
||||
}
|
||||
|
||||
export interface ConditionOption {
|
||||
label: string
|
||||
hint: string
|
||||
value: Record<string, unknown> | 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<string, unknown> | 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)
|
||||
}
|
||||
Reference in New Issue
Block a user