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:
@@ -49,7 +49,7 @@
|
||||
|
||||
<!-- ④ 日期 -->
|
||||
<picker v-else-if="f.type === 'date'" mode="date" :value="String(values[f.key] || '')"
|
||||
@change="(e) => setValue(f.key, e.detail.value)">
|
||||
@change="onDatePick(f.key, $event)">
|
||||
<view class="fill__input fill__input--pick">
|
||||
{{ display(f) || '请选择日期' }}
|
||||
</view>
|
||||
@@ -57,7 +57,8 @@
|
||||
|
||||
<!-- ⑤ 文本类(含手机号/证件号/数字) -->
|
||||
<input v-else class="fill__input" :type="inputType(f)" :value="String(values[f.key] ?? '')"
|
||||
:placeholder="placeholder(f)" @input="onInput(f.key, $event)" @blur="onBlur(f.key)" />
|
||||
:placeholder="placeholder(f)"
|
||||
@input="onInput(f.key, $event as unknown as UniInputEvent)" @blur="onBlur(f.key)" />
|
||||
|
||||
<view v-if="f.tip" class="fill__tip" :class="{ 'fill__tip--prefill': f.prefilled }">
|
||||
{{ f.tip }}
|
||||
@@ -65,14 +66,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 材料上传 -->
|
||||
<view class="fill__card">
|
||||
<!-- 材料上传。用 visibleMaterials(已按条件求值)—— 在校生不该看到「工作证明」 -->
|
||||
<view v-if="visibleMaterials.length" class="fill__card">
|
||||
<view class="fill__section-title">需要上传的材料</view>
|
||||
<view class="fill__section-note">
|
||||
选好文件后系统会自动压到 1M 内并按规则改名,不用你自己处理
|
||||
</view>
|
||||
|
||||
<view v-for="m in materials" :key="m.materialCode" class="fill__mat">
|
||||
<view v-for="m in visibleMaterials" :key="m.materialCode" class="fill__mat">
|
||||
<view class="fill__mat-head">
|
||||
<text class="fill__mat-name">{{ m.materialName }}</text>
|
||||
<text v-if="m.required" class="fill__req">必传</text>
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null
|
||||
}
|
||||
interface UploadedFile {
|
||||
id: number
|
||||
|
||||
Reference in New Issue
Block a user