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:
2026-09-29 13:48:11 +08:00
parent a4406f545a
commit ef903a55c3
6 changed files with 868 additions and 14 deletions
+198
View File
@@ -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="选项一&#10;选项二" />
</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>
+13 -3
View File
@@ -75,8 +75,8 @@
<!--
「进度」并进「状态」列,是为了给操作列腾宽度。
1366 的屏幕减去 200 侧栏,表格可用约 824px;草稿态的操作列要放
「编辑 / 配置班级 / 材料规则 / 发布 / 删除」五个链接,需要 240px
(200px 会挤成两行、160px 会挤成竖排)。
「编辑 / 配置班级 / 材料规则 / 表单设计 / 发布 / 删除」六个链接,
需要 300px(240px 会折成两行)。
进度在 M1 阶段恒为「待导入名册」,并进来不损失信息。
-->
<a-table-column title="状态 / 进度" :width="110" align="center">
@@ -95,7 +95,7 @@
</template>
</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 }">
<a-space v-if="canManage" size="mini" wrap>
<template v-if="record.status === TASK_STATUS_DRAFT">
@@ -104,6 +104,7 @@
<!-- 文案必须与服务端 40018 的提示「请到『材料规则』里配置…」一字不差,
否则用户按提示找不到地方 -->
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
<a-link @click="onConfigForm(record)">表单设计</a-link>
<a-link @click="onPublish(record)">发布</a-link>
<a-popconfirm content="删除后不可恢复,确认删除这个草稿?" type="warning"
@ok="onDelete(record)">
@@ -114,6 +115,7 @@
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
<a-link @click="onConfigOrgs(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 @click="onClose(record)">截止</a-link>
</template>
@@ -121,6 +123,7 @@
<template v-else-if="record.status === TASK_STATUS_PAUSED">
<a-link @click="onConfigOrgs(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 @click="onClose(record)">截止</a-link>
</template>
@@ -141,6 +144,7 @@
<TaskFormModal ref="formModalRef" @save-success="load" />
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
<TaskMaterialDrawer ref="materialDrawerRef" @save-success="load" />
<TaskFormSchemaDrawer ref="formDrawerRef" @save-success="load" />
</GiPageLayout>
</template>
@@ -158,6 +162,7 @@ import {
} from '@/apis/xt/task'
import { useUserStore } from '@/stores'
import TaskFormModal from './TaskFormModal.vue'
import TaskFormSchemaDrawer from './TaskFormSchemaDrawer.vue'
import TaskMaterialDrawer from './TaskMaterialDrawer.vue'
import TaskOrgDrawer from './TaskOrgDrawer.vue'
@@ -189,6 +194,7 @@ const query = reactive({ pageNum: 1, pageSize: 10, keyword: '', batchNo: '' })
const formModalRef = useTemplateRef('formModalRef')
const orgDrawerRef = useTemplateRef('orgDrawerRef')
const materialDrawerRef = useTemplateRef('materialDrawerRef')
const formDrawerRef = useTemplateRef('formDrawerRef')
/**
* 发布被拦时,每个阻断项**去哪里修**。
@@ -275,6 +281,10 @@ function onConfigMaterials(row: TaskVO) {
materialDrawerRef.value?.open(row)
}
function onConfigForm(row: TaskVO) {
formDrawerRef.value?.open(row)
}
async function onDelete(row: TaskVO) {
await taskApi.deleteTask(row.id)
Message.success('已删除')