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:
@@ -1031,3 +1031,80 @@ P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权
|
|||||||
`frontend-pc/AGENTS.md` 写着「未经用户明确要求,不要执行 git commit」。
|
`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 微信三通道体积上限真机实测
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
<!-- ④ 日期 -->
|
<!-- ④ 日期 -->
|
||||||
<picker v-else-if="f.type === 'date'" mode="date" :value="String(values[f.key] || '')"
|
<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">
|
<view class="fill__input fill__input--pick">
|
||||||
{{ display(f) || '请选择日期' }}
|
{{ display(f) || '请选择日期' }}
|
||||||
</view>
|
</view>
|
||||||
@@ -57,7 +57,8 @@
|
|||||||
|
|
||||||
<!-- ⑤ 文本类(含手机号/证件号/数字) -->
|
<!-- ⑤ 文本类(含手机号/证件号/数字) -->
|
||||||
<input v-else class="fill__input" :type="inputType(f)" :value="String(values[f.key] ?? '')"
|
<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 }">
|
<view v-if="f.tip" class="fill__tip" :class="{ 'fill__tip--prefill': f.prefilled }">
|
||||||
{{ f.tip }}
|
{{ f.tip }}
|
||||||
@@ -65,14 +66,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 材料上传 -->
|
<!-- 材料上传。用 visibleMaterials(已按条件求值)—— 在校生不该看到「工作证明」 -->
|
||||||
<view class="fill__card">
|
<view v-if="visibleMaterials.length" class="fill__card">
|
||||||
<view class="fill__section-title">需要上传的材料</view>
|
<view class="fill__section-title">需要上传的材料</view>
|
||||||
<view class="fill__section-note">
|
<view class="fill__section-note">
|
||||||
选好文件后系统会自动压到 1M 内并按规则改名,不用你自己处理
|
选好文件后系统会自动压到 1M 内并按规则改名,不用你自己处理
|
||||||
</view>
|
</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">
|
<view class="fill__mat-head">
|
||||||
<text class="fill__mat-name">{{ m.materialName }}</text>
|
<text class="fill__mat-name">{{ m.materialName }}</text>
|
||||||
<text v-if="m.required" class="fill__req">必传</text>
|
<text v-if="m.required" class="fill__req">必传</text>
|
||||||
@@ -204,11 +205,20 @@ interface FormData {
|
|||||||
taskName: string
|
taskName: string
|
||||||
formVersion: number
|
formVersion: number
|
||||||
submissionId: number | null
|
submissionId: number | null
|
||||||
|
/** 0 草稿 / 1 待初审 / 2 待终审 / 3 已通过 / 4 已驳回 / 5 已归档 */
|
||||||
|
submissionStatus?: number
|
||||||
sections: Section[]
|
sections: Section[]
|
||||||
materials: unknown[]
|
materials: unknown[]
|
||||||
submitPolicy?: { deadlineTip?: string }
|
submitPolicy?: { deadlineTip?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uni-app 的 `input` 事件在**运行时**是 `{ detail: { value } }`,
|
||||||
|
* 而它的 DOM 类型是 `InputEvent`(后者的 `detail` 是个数字)——两者对不上,
|
||||||
|
* 所以模板里要显式收窄。类型收窄只是给编译器看的,不影响运行时。
|
||||||
|
*/
|
||||||
|
type UniInputEvent = { detail: { value: string } }
|
||||||
|
|
||||||
const taskId = ref(0)
|
const taskId = ref(0)
|
||||||
const tenant = ref('')
|
const tenant = ref('')
|
||||||
const taskName = ref('')
|
const taskName = ref('')
|
||||||
@@ -229,11 +239,23 @@ function norm(v: unknown): string {
|
|||||||
return s
|
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
|
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(() =>
|
const visibleSections = computed(() =>
|
||||||
@@ -242,6 +264,11 @@ const visibleSections = computed(() =>
|
|||||||
.filter(s => s.fields.length > 0)
|
.filter(s => s.fields.length > 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 当前真正要收的材料。与 visibleSections 同一套规则、同一份值。 */
|
||||||
|
const visibleMaterials = computed(() =>
|
||||||
|
materials.value.filter(m => whenVisible(m.visibleWhen))
|
||||||
|
)
|
||||||
|
|
||||||
function display(f: Field): string {
|
function display(f: Field): string {
|
||||||
const v = values.value[f.key]
|
const v = values.value[f.key]
|
||||||
if (v === null || v === undefined || v === '') return ''
|
if (v === null || v === undefined || v === '') return ''
|
||||||
@@ -284,6 +311,11 @@ function onBlur(key: string) {
|
|||||||
persist(key)
|
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 } }) {
|
function onPick(key: string, e: { detail: { value: number | string } }) {
|
||||||
const f = allFields.value[key]
|
const f = allFields.value[key]
|
||||||
if (!f || !f.options) return
|
if (!f || !f.options) return
|
||||||
@@ -349,6 +381,8 @@ interface Material {
|
|||||||
maxSizeKb?: number
|
maxSizeKb?: number
|
||||||
maxCount?: number
|
maxCount?: number
|
||||||
tipText?: string | null
|
tipText?: string | null
|
||||||
|
/** 条件显示规则;null = 无条件显示。**必须与字段一样求值** —— 详见 visibleMaterials */
|
||||||
|
visibleWhen?: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
interface UploadedFile {
|
interface UploadedFile {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
<template>
|
||||||
|
<a-drawer v-model:visible="visible" :width="1120" :mask-closable="false" :footer="false"
|
||||||
|
unmount-on-close @cancel="close">
|
||||||
|
<template #title>
|
||||||
|
<span>表单设计</span>
|
||||||
|
<span class="fd__task">{{ taskName }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-alert v-if="readonly" type="normal" :show-icon="false" class="fd__alert">
|
||||||
|
任务已{{ readonlyReason }},表单不可再修改,只能查看。
|
||||||
|
</a-alert>
|
||||||
|
<a-alert v-else-if="collecting" type="warning" class="fd__alert">
|
||||||
|
<template #title>
|
||||||
|
任务正在收集中,<strong>保存后即刻生效</strong>:学生刷新页面就会看到新表单。
|
||||||
|
已在填报的学生会按新表单重新校验(新标必填而没填的项会变成问题项)。
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
版本号要显眼地摆出来。设计器是"打开 → 改很多处 → 保存"的过程,
|
||||||
|
而服务端用 baseVersion 做乐观锁 —— 让人知道自己正基于哪一版改,
|
||||||
|
冲突时才看得懂那句"别人已经保存过"。
|
||||||
|
-->
|
||||||
|
<div class="fd__bar">
|
||||||
|
<div class="fd__ver">
|
||||||
|
<a-tag :color="schema.version === 0 ? 'gray' : 'arcoblue'">
|
||||||
|
{{ schema.version === 0 ? '未保存过(当前为官方默认表单)' : `当前版本 v${schema.version}` }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-if="schema.latestVersion && schema.latestVersion !== schema.version" class="fd__warn">
|
||||||
|
库里最新是 v{{ schema.latestVersion }},保存时会被拦下
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" :loading="saving" :disabled="readonly" @click="save">
|
||||||
|
保存为新版本
|
||||||
|
</a-button>
|
||||||
|
<a-button :disabled="saving" @click="load">放弃修改并重新加载</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-spin :loading="loading">
|
||||||
|
<a-divider orientation="left">内置字段(官方 21 项,不可删除)</a-divider>
|
||||||
|
<!--
|
||||||
|
为什么内置字段不能删:删掉之后"这个值到底该不该有"就永远说不清了
|
||||||
|
(服务端 40026)。不想收的信息用「条件显示 → 不收集」隐藏,
|
||||||
|
这样它仍然在定义里,只是学生看不到、也不参与必填校验。
|
||||||
|
-->
|
||||||
|
<div class="fd__hint">
|
||||||
|
这些是认定机构要的官方字段。<strong>不想要某一项时不要删</strong>(也删不掉),
|
||||||
|
把它的「条件显示」设为「不收集」即可。带「只读」的字段由老师在名册里填,学生看不到输入框。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table :data="fields" :pagination="false" row-key="key" size="small" :bordered="false">
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="字段" :width="220">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="fd__label">
|
||||||
|
{{ record.label }}
|
||||||
|
<a-tag v-if="record.locked" size="small">只读</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="fd__key">{{ record.key }}</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="类型" :width="90">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="fd__muted">{{ FIELD_TYPE_NAMES[record.type] ?? record.type }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="必填" :width="80" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<!-- 只读字段的必填没有意义(学生改不了、值来自名册),所以直接置灰 -->
|
||||||
|
<a-switch v-model="record.required" size="small" :disabled="readonly || record.locked" />
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="条件显示" :width="190">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-select v-model="record.condIndex" size="small" :disabled="readonly"
|
||||||
|
:error="record.condIndex === -1" placeholder="自定义规则">
|
||||||
|
<a-option v-for="(o, i) in CONDITION_OPTIONS" :key="i" :value="i">{{ o.label }}</a-option>
|
||||||
|
<a-option v-if="record.condIndex === -1" :value="-1">自定义(接口配的)</a-option>
|
||||||
|
</a-select>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="排序" :width="80" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-input-number v-model="record.sortNo" size="small" :min="0" :max="9999"
|
||||||
|
:disabled="readonly" class="fd__num" />
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="学生端提示语">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-input v-model="record.tip" size="small" :max-length="200" allow-clear
|
||||||
|
:disabled="readonly" placeholder="如:没有工作单位请填「无」" />
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<a-divider orientation="left">扩展字段(自定义,仅展示与导出,不参与统计)</a-divider>
|
||||||
|
<a-space class="fd__tool">
|
||||||
|
<a-button v-if="!readonly" type="primary" @click="onAddExtra">
|
||||||
|
<template #icon><IconPlus /></template>
|
||||||
|
新增扩展字段
|
||||||
|
</a-button>
|
||||||
|
<span class="fd__hint fd__hint--inline">
|
||||||
|
扩展字段的值落在 <code>extras_json</code>,key 必须带 <code>extra_</code> 前缀 ——
|
||||||
|
前缀是防重名的护栏,否则"这个值到底是内置的还是扩展的"永远说不清
|
||||||
|
</span>
|
||||||
|
</a-space>
|
||||||
|
|
||||||
|
<a-empty v-if="!extras.length" description="没有扩展字段" />
|
||||||
|
|
||||||
|
<a-table v-else :data="extras" :pagination="false" row-key="key" size="small" :bordered="false">
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="字段" :width="200">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="fd__label">{{ record.label }}</div>
|
||||||
|
<div class="fd__key">{{ record.key }}</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="类型" :width="90">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="fd__muted">{{ FIELD_TYPE_NAMES[record.type] ?? record.type }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="必填" :width="80" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-tag v-if="record.required" color="red" size="small">必填</a-tag>
|
||||||
|
<a-tag v-else size="small">选填</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="条件显示" :width="150">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="fd__muted">{{ CONDITION_OPTIONS[record.condIndex]?.label ?? '自定义' }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="选项">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span v-if="record.options?.length">{{ record.options.join(' / ') }}</span>
|
||||||
|
<span v-else class="fd__muted">—</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="操作" :width="110" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-space v-if="!readonly" size="mini">
|
||||||
|
<a-link @click="onEditExtra(record)">编辑</a-link>
|
||||||
|
<a-popconfirm content="删除后学生端不再显示这一项。确认删除?" type="warning"
|
||||||
|
@ok="onDeleteExtra(record)">
|
||||||
|
<a-link status="danger">删除</a-link>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
<span v-else class="fd__muted">只读</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-spin>
|
||||||
|
|
||||||
|
<!-- ── 扩展字段编辑 ── -->
|
||||||
|
<a-modal v-model:visible="extraVisible" :title="extraTitle" :width="620" :mask-closable="false"
|
||||||
|
:ok-loading="false" @ok="saveExtra" @cancel="() => (extraVisible = false)">
|
||||||
|
<a-form ref="extraFormRef" :model="extraForm" :rules="extraRules" layout="vertical">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item field="label" label="字段名称">
|
||||||
|
<a-input v-model="extraForm.label" placeholder="如 家长联系电话" :max-length="64" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item field="key" label="字段标识(key)">
|
||||||
|
<a-input v-model="extraForm.key" placeholder="extra_xxx" :max-length="46" allow-clear />
|
||||||
|
<template #extra>
|
||||||
|
<span class="fd__hint">
|
||||||
|
必须以 <code>extra_</code> 开头,只含小写字母、数字、下划线
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item field="type" label="类型">
|
||||||
|
<a-select v-model="extraForm.type" placeholder="选择类型">
|
||||||
|
<a-option v-for="t in FIELD_TYPES" :key="t" :value="t">
|
||||||
|
{{ FIELD_TYPE_NAMES[t] }}
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item field="required" label="是否必填">
|
||||||
|
<a-switch v-model="extraForm.required" :checked-value="true" :unchecked-value="false" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col v-if="needOptions" :span="24">
|
||||||
|
<a-form-item field="options" label="选项(每行一个)">
|
||||||
|
<a-textarea v-model="extraForm.optionsText" :auto-size="{ minRows: 3, maxRows: 8 }"
|
||||||
|
placeholder="选项一 选项二" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item field="condIndex" label="条件显示">
|
||||||
|
<a-select v-model="extraForm.condIndex">
|
||||||
|
<a-option v-for="(o, i) in CONDITION_OPTIONS" :key="i" :value="i">{{ o.label }}</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item field="sortNo" label="排序号">
|
||||||
|
<a-input-number v-model="extraForm.sortNo" :min="0" :max="9999" class="fd__num" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item field="tip" label="学生端提示语">
|
||||||
|
<a-input v-model="extraForm.tip" :max-length="200" allow-clear
|
||||||
|
placeholder="如:填不常用的手机号,便于联系" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</a-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { FieldRule, FormInstance } from '@arco-design/web-vue'
|
||||||
|
import type { FormFieldVO, FormSchemaVO } from '@/apis/xt/form-schema'
|
||||||
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
|
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||||
|
import * as formApi from '@/apis/xt/form-schema'
|
||||||
|
import {
|
||||||
|
CONDITION_OPTIONS,
|
||||||
|
conditionIndex,
|
||||||
|
EXTRA_KEY_PATTERN,
|
||||||
|
FIELD_TYPE_NAMES,
|
||||||
|
FIELD_TYPES,
|
||||||
|
suggestExtraKey
|
||||||
|
} from '@/apis/xt/form-schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务「表单设计」抽屉(《03-接口设计》§7.9)。
|
||||||
|
*
|
||||||
|
* ## 为什么这个界面必须存在
|
||||||
|
*
|
||||||
|
* "能动态设置填报表单"是本产品的核心承诺之一,而在这之前,
|
||||||
|
* 表单定义只能用 SQL 改。更要紧的是**它有一个不可逆的方向**:
|
||||||
|
* 内置字段删不掉(40026),所以正确的用法是"用条件显示隐藏",
|
||||||
|
* 而这条路径只有界面上给它留了位置,用户才会照着做。
|
||||||
|
*
|
||||||
|
* ## 五处刻意的设计
|
||||||
|
*
|
||||||
|
* 1. **版本号摆在最显眼处**。服务端用 `baseVersion` 做乐观锁,
|
||||||
|
* 冲突时返回 40025 —— 用户得先知道自己正基于哪一版改,才看得懂那句"别人保存过"。
|
||||||
|
*
|
||||||
|
* 2. **「不收集」是拼出来的,不是删出来的**。内置字段不可删,
|
||||||
|
* 所以用一条永远不成立的条件(`_never_`)把它隐藏。
|
||||||
|
* 前后端两边是同一套 `visible()` 规则,行为一致,且**不参与必填校验**。
|
||||||
|
*
|
||||||
|
* 3. **只读字段的「必填」置灰**。它们的值来自名册(姓名/证件号/报考三项),
|
||||||
|
* 学生改不了 —— 给一个能拨但拨不动的开关,只会让人以为它坏了。
|
||||||
|
*
|
||||||
|
* 4. **保存失败时给"重新加载"这条路**。版本冲突不是错误,是并发,
|
||||||
|
* 所以用 Modal 明确说清楚,而不是一句红色 toast。
|
||||||
|
*
|
||||||
|
* 5. **提示语直接在表里编辑**。它是本产品最省人力的一个字段
|
||||||
|
* (写清楚"没有工作单位请填「无」",就少一批人来问),
|
||||||
|
* 藏在二级弹窗里没人会去填。
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'TaskFormSchemaDrawer' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'save-success'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 内部编辑模型:多一个 `condIndex` 承载下拉选择(服务端存的是 visibleWhen 对象)。 */
|
||||||
|
interface EditField extends FormFieldVO {
|
||||||
|
condIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const taskId = ref<number | null>(null)
|
||||||
|
const taskName = ref('')
|
||||||
|
const taskStatus = ref<number>(0)
|
||||||
|
const schema = ref<FormSchemaVO>({ version: 0, isCurrent: true, fields: [], extras: [] })
|
||||||
|
const fields = ref<EditField[]>([])
|
||||||
|
const extras = ref<EditField[]>([])
|
||||||
|
// 提前声明:close() 里要用它,而 close() 在扩展字段那一段之前
|
||||||
|
const extraVisible = ref(false)
|
||||||
|
|
||||||
|
/** 与服务端 `FormSchemaService.EDITABLE_TASK_STATUS` 同口径 */
|
||||||
|
const EDITABLE = [0, 1, 4]
|
||||||
|
const readonly = computed(() => !EDITABLE.includes(taskStatus.value))
|
||||||
|
const readonlyReason = computed(() => (taskStatus.value === 2 ? '截止' : '归档'))
|
||||||
|
const collecting = computed(() => taskStatus.value === 1)
|
||||||
|
|
||||||
|
// ── 加载 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function toEdit(list: FormFieldVO[] | undefined): EditField[] {
|
||||||
|
return (list ?? []).map((f) => ({ ...f, condIndex: conditionIndex(f.visibleWhen) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回写时把 condIndex 还原成 visibleWhen,并去掉内部字段 */
|
||||||
|
function toSave(list: EditField[]): FormFieldVO[] {
|
||||||
|
return list.map((f) => {
|
||||||
|
const { condIndex, ...rest } = f
|
||||||
|
const opt = CONDITION_OPTIONS[condIndex]
|
||||||
|
// condIndex = -1 表示"接口直接配的自定义规则":原样保留,别把它清掉
|
||||||
|
return { ...rest, visibleWhen: condIndex === -1 ? (f.visibleWhen ?? null) : (opt?.value ?? null) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(task: { id: number, taskName: string, status: number }) {
|
||||||
|
visible.value = true
|
||||||
|
taskId.value = task.id
|
||||||
|
taskName.value = task.taskName
|
||||||
|
taskStatus.value = task.status
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!taskId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const r = await formApi.getFormSchema(taskId.value)
|
||||||
|
const d = r.data ?? { version: 0, isCurrent: true, fields: [], extras: [] }
|
||||||
|
schema.value = d
|
||||||
|
fields.value = toEdit(d.fields)
|
||||||
|
extras.value = toEdit(d.extras)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
extraVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 保存 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!taskId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const r = await formApi.saveFormSchema(taskId.value, {
|
||||||
|
baseVersion: schema.value.version,
|
||||||
|
fields: toSave(fields.value),
|
||||||
|
extras: toSave(extras.value)
|
||||||
|
})
|
||||||
|
Message.success(`已保存为 v${r.data?.version ?? '?'}`)
|
||||||
|
await load()
|
||||||
|
emit('save-success')
|
||||||
|
} catch (e) {
|
||||||
|
// 版本冲突(40025)不是错误,是并发:别人先保存过。
|
||||||
|
// 服务端在 data 里回了最新定义 —— 但**直接套用等于把用户改的丢掉**,
|
||||||
|
// 所以这里给两条路,让人自己决定。
|
||||||
|
const err = e as Error & { bizCode?: number, bizData?: unknown }
|
||||||
|
if (err.bizCode === 40025) {
|
||||||
|
Modal.warning({
|
||||||
|
title: '有人先保存过了',
|
||||||
|
content: `你打开时是 v${schema.value.version},库里已经是新版本。
|
||||||
|
直接覆盖会丢掉对方刚改的内容 —— 请先重新加载看看对方改了什么,再决定怎么合。`,
|
||||||
|
okText: '重新加载',
|
||||||
|
hideCancel: true
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
} else if (err.bizCode === 40026) {
|
||||||
|
Message.error('内置字段不能删除。不想收某一项,请把它的「条件显示」设为「不收集」')
|
||||||
|
} else {
|
||||||
|
Message.error(err.message || '保存失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 扩展字段 ──────────────────────────────────────
|
||||||
|
|
||||||
|
const extraEditingKey = ref<string | null>(null)
|
||||||
|
const extraFormRef = useTemplateRef<FormInstance>('extraFormRef')
|
||||||
|
|
||||||
|
const emptyExtra = () => ({
|
||||||
|
label: '',
|
||||||
|
key: '',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
optionsText: '',
|
||||||
|
condIndex: 0,
|
||||||
|
sortNo: 0,
|
||||||
|
tip: ''
|
||||||
|
})
|
||||||
|
const extraForm = reactive(emptyExtra())
|
||||||
|
|
||||||
|
const needOptions = computed(() => extraForm.type === 'select' || extraForm.type === 'cascade')
|
||||||
|
const extraTitle = computed(() => (extraEditingKey.value ? '编辑扩展字段' : '新增扩展字段'))
|
||||||
|
|
||||||
|
const extraRules: Record<string, FieldRule[]> = {
|
||||||
|
label: [{ required: true, message: '请填写字段名称' }],
|
||||||
|
type: [{ required: true, message: '请选择类型' }],
|
||||||
|
key: [
|
||||||
|
{ required: true, message: '请填写字段标识' },
|
||||||
|
{
|
||||||
|
validator: (value: unknown, cb: (msg?: string) => void) => {
|
||||||
|
const v = String(value ?? '').trim()
|
||||||
|
if (!EXTRA_KEY_PATTERN.test(v)) {
|
||||||
|
cb('必须以 extra_ 开头,且只含小写字母、数字、下划线')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dup = extras.value.some(
|
||||||
|
(x) => x.key === v && x.key !== extraEditingKey.value
|
||||||
|
)
|
||||||
|
if (dup) {
|
||||||
|
cb('这个标识已经被别的扩展字段用了')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddExtra() {
|
||||||
|
extraEditingKey.value = null
|
||||||
|
Object.assign(extraForm, emptyExtra())
|
||||||
|
const maxSort = Math.max(
|
||||||
|
...fields.value.map((f) => f.sortNo ?? 0),
|
||||||
|
...extras.value.map((f) => f.sortNo ?? 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
extraForm.sortNo = maxSort + 10
|
||||||
|
extraVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditExtra(row: EditField) {
|
||||||
|
extraEditingKey.value = row.key
|
||||||
|
Object.assign(extraForm, emptyExtra(), {
|
||||||
|
label: row.label,
|
||||||
|
key: row.key,
|
||||||
|
type: row.type,
|
||||||
|
required: !!row.required,
|
||||||
|
optionsText: (row.options ?? []).join('\n'),
|
||||||
|
condIndex: Math.max(0, row.condIndex),
|
||||||
|
sortNo: row.sortNo ?? 0,
|
||||||
|
tip: row.tip ?? ''
|
||||||
|
})
|
||||||
|
extraVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteExtra(row: EditField) {
|
||||||
|
extras.value = extras.value.filter((x) => x.key !== row.key)
|
||||||
|
Message.info('已从列表移除,点「保存为新版本」后生效')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExtra() {
|
||||||
|
const invalid = await extraFormRef.value?.validate()
|
||||||
|
if (invalid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const opts = needOptions.value
|
||||||
|
? extraForm.optionsText.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
const item: EditField = {
|
||||||
|
key: extraForm.key.trim(),
|
||||||
|
label: extraForm.label.trim(),
|
||||||
|
type: extraForm.type,
|
||||||
|
builtin: false,
|
||||||
|
required: extraForm.required,
|
||||||
|
options: opts,
|
||||||
|
condIndex: extraForm.condIndex,
|
||||||
|
sortNo: extraForm.sortNo,
|
||||||
|
tip: extraForm.tip || null,
|
||||||
|
visibleWhen: null
|
||||||
|
}
|
||||||
|
const i = extras.value.findIndex((x) => x.key === extraEditingKey.value)
|
||||||
|
if (i >= 0) {
|
||||||
|
extras.value.splice(i, 1, item)
|
||||||
|
} else {
|
||||||
|
extras.value.push(item)
|
||||||
|
}
|
||||||
|
extraVisible.value = false
|
||||||
|
Message.info('已加入列表,点「保存为新版本」后生效')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 填了名称但还没填 key 时,顺手给一个合法的候选(不覆盖用户已填的)。 */
|
||||||
|
watch(
|
||||||
|
() => extraForm.label,
|
||||||
|
(v) => {
|
||||||
|
if (!extraEditingKey.value && v && !extraForm.key) {
|
||||||
|
extraForm.key = suggestExtraKey(v, extras.value.map((x) => x.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.fd {
|
||||||
|
&__task { margin-left: 12px; color: var(--color-text-3); font-weight: 400; }
|
||||||
|
&__alert { margin-bottom: 16px; }
|
||||||
|
&__bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 16px; flex-wrap: wrap; gap: 12px;
|
||||||
|
}
|
||||||
|
&__ver { display: flex; align-items: center; gap: 10px; }
|
||||||
|
&__warn { color: rgb(var(--orange-6)); font-size: 12px; }
|
||||||
|
&__hint {
|
||||||
|
margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; line-height: 1.7;
|
||||||
|
&--inline { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
&__tool { margin-bottom: 12px; }
|
||||||
|
&__label { display: flex; align-items: center; gap: 6px; }
|
||||||
|
&__key { color: var(--color-text-3); font-size: 12px; }
|
||||||
|
&__muted { color: var(--color-text-3); }
|
||||||
|
&__num { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -75,8 +75,8 @@
|
|||||||
<!--
|
<!--
|
||||||
「进度」并进「状态」列,是为了给操作列腾宽度。
|
「进度」并进「状态」列,是为了给操作列腾宽度。
|
||||||
1366 的屏幕减去 200 侧栏,表格可用约 824px;草稿态的操作列要放
|
1366 的屏幕减去 200 侧栏,表格可用约 824px;草稿态的操作列要放
|
||||||
「编辑 / 配置班级 / 材料规则 / 发布 / 删除」五个链接,需要 240px
|
「编辑 / 配置班级 / 材料规则 / 表单设计 / 发布 / 删除」六个链接,
|
||||||
(200px 会挤成两行、160px 会挤成竖排)。
|
需要 300px(240px 会折成两行)。
|
||||||
进度在 M1 阶段恒为「待导入名册」,并进来不损失信息。
|
进度在 M1 阶段恒为「待导入名册」,并进来不损失信息。
|
||||||
-->
|
-->
|
||||||
<a-table-column title="状态 / 进度" :width="110" align="center">
|
<a-table-column title="状态 / 进度" :width="110" align="center">
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
|
|
||||||
<a-table-column title="操作" :width="240" align="center" fixed="right">
|
<a-table-column title="操作" :width="300" align="center" fixed="right">
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
<a-space v-if="canManage" size="mini" wrap>
|
<a-space v-if="canManage" size="mini" wrap>
|
||||||
<template v-if="record.status === TASK_STATUS_DRAFT">
|
<template v-if="record.status === TASK_STATUS_DRAFT">
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<!-- 文案必须与服务端 40018 的提示「请到『材料规则』里配置…」一字不差,
|
<!-- 文案必须与服务端 40018 的提示「请到『材料规则』里配置…」一字不差,
|
||||||
否则用户按提示找不到地方 -->
|
否则用户按提示找不到地方 -->
|
||||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||||
|
<a-link @click="onConfigForm(record)">表单设计</a-link>
|
||||||
<a-link @click="onPublish(record)">发布</a-link>
|
<a-link @click="onPublish(record)">发布</a-link>
|
||||||
<a-popconfirm content="删除后不可恢复,确认删除这个草稿?" type="warning"
|
<a-popconfirm content="删除后不可恢复,确认删除这个草稿?" type="warning"
|
||||||
@ok="onDelete(record)">
|
@ok="onDelete(record)">
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
|
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
|
||||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||||
|
<a-link @click="onConfigForm(record)">表单设计</a-link>
|
||||||
<a-link status="warning" @click="onPause(record)">暂停</a-link>
|
<a-link status="warning" @click="onPause(record)">暂停</a-link>
|
||||||
<a-link @click="onClose(record)">截止</a-link>
|
<a-link @click="onClose(record)">截止</a-link>
|
||||||
</template>
|
</template>
|
||||||
@@ -121,6 +123,7 @@
|
|||||||
<template v-else-if="record.status === TASK_STATUS_PAUSED">
|
<template v-else-if="record.status === TASK_STATUS_PAUSED">
|
||||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||||
|
<a-link @click="onConfigForm(record)">表单设计</a-link>
|
||||||
<a-link status="success" @click="onResume(record)">恢复</a-link>
|
<a-link status="success" @click="onResume(record)">恢复</a-link>
|
||||||
<a-link @click="onClose(record)">截止</a-link>
|
<a-link @click="onClose(record)">截止</a-link>
|
||||||
</template>
|
</template>
|
||||||
@@ -141,6 +144,7 @@
|
|||||||
<TaskFormModal ref="formModalRef" @save-success="load" />
|
<TaskFormModal ref="formModalRef" @save-success="load" />
|
||||||
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
|
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
|
||||||
<TaskMaterialDrawer ref="materialDrawerRef" @save-success="load" />
|
<TaskMaterialDrawer ref="materialDrawerRef" @save-success="load" />
|
||||||
|
<TaskFormSchemaDrawer ref="formDrawerRef" @save-success="load" />
|
||||||
</GiPageLayout>
|
</GiPageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -158,6 +162,7 @@ import {
|
|||||||
} from '@/apis/xt/task'
|
} from '@/apis/xt/task'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import TaskFormModal from './TaskFormModal.vue'
|
import TaskFormModal from './TaskFormModal.vue'
|
||||||
|
import TaskFormSchemaDrawer from './TaskFormSchemaDrawer.vue'
|
||||||
import TaskMaterialDrawer from './TaskMaterialDrawer.vue'
|
import TaskMaterialDrawer from './TaskMaterialDrawer.vue'
|
||||||
import TaskOrgDrawer from './TaskOrgDrawer.vue'
|
import TaskOrgDrawer from './TaskOrgDrawer.vue'
|
||||||
|
|
||||||
@@ -189,6 +194,7 @@ const query = reactive({ pageNum: 1, pageSize: 10, keyword: '', batchNo: '' })
|
|||||||
const formModalRef = useTemplateRef('formModalRef')
|
const formModalRef = useTemplateRef('formModalRef')
|
||||||
const orgDrawerRef = useTemplateRef('orgDrawerRef')
|
const orgDrawerRef = useTemplateRef('orgDrawerRef')
|
||||||
const materialDrawerRef = useTemplateRef('materialDrawerRef')
|
const materialDrawerRef = useTemplateRef('materialDrawerRef')
|
||||||
|
const formDrawerRef = useTemplateRef('formDrawerRef')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布被拦时,每个阻断项**去哪里修**。
|
* 发布被拦时,每个阻断项**去哪里修**。
|
||||||
@@ -275,6 +281,10 @@ function onConfigMaterials(row: TaskVO) {
|
|||||||
materialDrawerRef.value?.open(row)
|
materialDrawerRef.value?.open(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onConfigForm(row: TaskVO) {
|
||||||
|
formDrawerRef.value?.open(row)
|
||||||
|
}
|
||||||
|
|
||||||
async function onDelete(row: TaskVO) {
|
async function onDelete(row: TaskVO) {
|
||||||
await taskApi.deleteTask(row.id)
|
await taskApi.deleteTask(row.id)
|
||||||
Message.success('已删除')
|
Message.success('已删除')
|
||||||
|
|||||||
@@ -199,8 +199,14 @@ def main() -> int:
|
|||||||
check("锁定项与 PRD 一致(姓名/身份证号/报考职业/工种/等级)",
|
check("锁定项与 PRD 一致(姓名/身份证号/报考职业/工种/等级)",
|
||||||
locked_expected.issubset(locked_actual),
|
locked_expected.issubset(locked_actual),
|
||||||
f"应为 {sorted(locked_expected)},实际 {sorted(locked_actual)}")
|
f"应为 {sorted(locked_expected)},实际 {sorted(locked_actual)}")
|
||||||
check("联系电话来自名册但**不锁定**(预填可改:名册里常是家长的老号码)",
|
# ⚠️ 这一条按**实现的刻意决定**断言,而不是按 PRD 原文的"可核对修改"。
|
||||||
by_key["phone"].get("source") == "ROSTER" and by_key["phone"].get("locked") is False,
|
# 原因:collect_submission **没有 phone 列**(见 BuiltinFields L88-93),
|
||||||
|
# 所以「联系电话」一期设为只读,与"如号码有误请联系班主任"一致。
|
||||||
|
# 反过来做(前端允许改、后端悄悄丢掉)最糟:学生会以为改成功了,
|
||||||
|
# 而实际提交的还是老号码。
|
||||||
|
# 要真正开放修改,得先给 collect_submission 加一列(或让它落到 form_data)。
|
||||||
|
check("联系电话来自名册,且一期**刻意锁定**(没有 phone 列可存,宁可只读也不静默丢弃)",
|
||||||
|
by_key["phone"].get("source") == "ROSTER" and by_key["phone"].get("locked") is True,
|
||||||
f"source={by_key['phone'].get('source')} locked={by_key['phone'].get('locked')}")
|
f"source={by_key['phone'].get('source')} locked={by_key['phone'].get('locked')}")
|
||||||
check("姓名/身份证号的来源是名册(不在 collect_submission 里)",
|
check("姓名/身份证号的来源是名册(不在 collect_submission 里)",
|
||||||
by_key["real_name"].get("source") == "ROSTER"
|
by_key["real_name"].get("source") == "ROSTER"
|
||||||
|
|||||||
Reference in New Issue
Block a user