fix(pc): 找回被 #feedback 吞掉的表单校验错误 + 收集任务文案精简

naive 的 NFormItem 一旦用了 #feedback 插槽,renderExplains 就永不渲染
(children || feedback ? feedback : …),校验错误被完全遮住 —— 用户看到的是
"红框 + 一句无关的正常提示",现象上等价于"点了保存没反应"。RoleFormModal 里
早就记录过这个机制,但只在自己那个文件绕开了,没固化成约定,于是扩散到 7 处。

- 7 处 validate() 的 catch 改成 message.error(firstError(errors) ?? 兜底)
  (login / org / role / user / task 建改任务 / 材料规则 / 表单设计)
- ⚠️ firstError 必须摊平后再取:naive reject 出来的是嵌套数组,
  errors[0].message 恒为 undefined,兜底文案会把每条错误都吞成"请检查表单填写"
- 批次号在"修改"时置灰(task_code = 批次号 + 4 位序号,且同时是文件目录名,
  而服务端 update 无条件写 batch_no、没有"编码已生成"的保护)
- 删掉「本期范围」区块(收集模式 / 认定类型恒定,请求体里仍写死 1)
- 清掉教材式描述:extras_json / extra_ 前缀 / 幂等 / 接收能力 / 识别结果只用于提示
- 规则沉淀进 frontend-pc/AGENTS.md 第 10 条与交接摘要 §一·四十
This commit is contained in:
2026-10-13 15:06:34 +08:00
parent 31e7483464
commit e94e76ba0f
10 changed files with 457 additions and 73 deletions
+159
View File
@@ -3325,5 +3325,164 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
「页面内页签用工具条分段按钮,写明高度链的原因」; 「页面内页签用工具条分段按钮,写明高度链的原因」;
根 `AGENTS.md` 的接线检查说明补第 ⑩ 项。 根 `AGENTS.md` 的接线检查说明补第 ⑩ 项。
## §一·四十 收集任务页面「描述」精简 + 一处长期静默的故障(2026-10)
**起因**:用户要求「收集任务页面表单描述要简洁直观」,并点名
「新建收集任务」里的 **本期范围** 区块(`本期只做 学校收集 + 自主认定:收集模式与认定类型由系统固定,界面上不开放其它取值。`)
不该出现在表单里;同时要求审查收集任务的全部页面。
### 一、删掉「本期范围」区块(决定 + 理由)
`TaskFormModal.vue` 里删掉了 **`NDivider` + 两个 `disabled` 的 `NInput`(收集模式 / 认定类型)+ 一整块 `NAlert`**,
原位只留一段注释。三个理由,缺一个都会被后来者"改回去":
1. 它的信息是**恒定的**(`collectMode=1` / `certType=1` 由后端 `TaskService.assertScopeSupported` 强制),
对建任务的人零决策价值;
2. 它是 **PRD 口径**,不是界面文案 —— 真正该放的地方是 `docs/01-需求规格说明书`;
3. ⚠️ 保持它只会**暗示系统支持切换模式**,而范围收敛已经定了不设二期。
⚠️ **请求体里那两行写死不能跟着删**:`confirm()` 仍必须带 `collectMode: 1` / `certType: 1`
(`TaskService.java:323-332` 的 `assertScopeSupported` 对 `create`(:248) 与 `update`(:351) 都调,
传别的值返回 **40044**)。这是本轮最容易"顺手删错"的地方。
### 二、本轮最要紧的发现:`#feedback` 会**完全遮住**校验错误(长期静默故障)
**机制**(naive **2.45.3**,`frontend-pc/node_modules/naive-ui/es/form/src/FormItem.mjs:416`):
```js
const feedbackNodes = children || feedback ? (…) : this.renderExplains.length ? this.renderExplains.map(…) : null
```
`#feedback` 插槽存在时 `renderExplains` **永不渲染** —— 而 `renderExplains` 就是校验错误所在。
所以一个字段只要写了提示语,它的**必填/格式错误就一个字都显示不出来**,
用户看到的是「红框 + 一句无关的正常提示」→ 现象上等价于「点了保存没反应」。
**为什么以前没被发现**:写出这句话的人(`RoleFormModal.vue:90-97` 的注释)已经知道这个机制,
但**只在自己那个文件里绕开了**,没有固化成约定,也没有人扫过全仓 → 另外 7 处继续踩。
**⚠️ 并且这个插槽不接收任何参数**(同文件 `:412` 用的是 `_utils/vue/resolve-slot.mjs:23-25` 的
`resolveWrappedSlot(slot, wrapper)`,即 `wrapper(slot())` —— **零参数**):
所以**不存在**「`#feedback="{ status }"` 里顺手把错误渲染出来」这种优雅修法。
想内联渲染错误只有一条路:给 `NFormItem` 的 **`feedback` prop 传渲染函数**(`FormItem.mjs:420-425` 的分支),
那要自写包装组件 + `h()` 手绘插槽 + 自行转发 `label`/`path`/`validationStatus`,
而本仓**没有任何 `.tsx` 文件**。→ 列为独立一批(见末节「显式不做⑤」)。
**本轮的修法(7 处,与 `TenantEditModal.vue:119-139` 的既有写法同形)**:
`catch (errors) { message.error(firstError(errors) ?? '请检查表单填写'); return }`
| 文件 | 行 | 该表单里被遮住的错误 |
|---|---|---|
| `frontend-pc/src/views/login/index.vue` | `firstError` :127、`catch` :142 | 「租户码」必填(`#feedback` 那句常驻提示) |
| `frontend-pc/src/views/task/TaskFormModal.vue` | `firstError` :205、`catch` :225 | 批次号格式、填报截止必填、命名模板必填 |
| `frontend-pc/src/views/task/TaskMaterialDrawer.vue` | `firstError` :369、`catch` :380 | 材料类型 / 格式 / 体积 / 份数 |
| `frontend-pc/src/views/task/TaskFormSchemaDrawer.vue` | `firstError` :437、`catch` :447 | 字段标识格式(`extra_` 前缀)与重名 |
| `frontend-pc/src/views/user/UserFormModal.vue` | `firstError` :200、`catch` :210 | 手机号 / 角色类 |
| `frontend-pc/src/views/org/OrgFormModal.vue` | `firstError` :124、`catch` :134 | 组织类型必填 |
| `frontend-pc/src/views/role/RoleFormModal.vue` | `firstError` :135、`catch` :145 | 角色码必填与格式、用户类型 `type:'number'` 那个坑 |
⚠️ **`firstError` 必须摊平后再取**:naive 的 `Form.validate()` reject 出来的是**嵌套数组**
(`Form.mjs:96-107` 把每一层的 `result.errors` 整个 `push` 进 `errors`),
所以 `errors[0].message` **恒为 `undefined`**,`?? 兜底` 会把每条错误都变成「请检查表单填写」。
实测踩到过:第一版就是 `errors[0].message`,toast 出来的是兜底文案。
现在 7 份实现都是 `for (const entry of errors.flat(2)) { … }`。
**实测证据**(真浏览器,后端 8080 + `pnpm dev` 5173):
| 触发 | 弹出 |
|---|---|
| 登录页留空点登录 | `请填写账号` |
| 新建任务留空点「创建任务」 | `请填写任务名称` |
| 新增扩展字段填 `abc` 点确定 | `必须以 extra_ 开头,且只含小写字母、数字、下划线` |
| 角色弹窗留空点「创建」 | `请填写角色码`(**回归**,它本来就有这写法) |
### 三、批次号在「修改」时锁死(**用户本轮拍板**)
`TaskFormModal.vue:276-291`:编辑时 `:disabled="isEdit"`,feedback 出 `batchNoHint`(`:152-158`),
即「任务编码 **2026090241** 已按它生成,不可再改」。
依据与决策一起记:`task_code` = 批次号 + 4 位序号(`TaskService.nextTaskCode`,`:757-768`),
且它**同时是文件目录名**;而服务端 `TaskService.update:365-366` 会**无条件**写入 `batch_no`,
没有"编码已生成"的保护 —— 改了以后列表里的批次号与 `task_code` 前缀就永久对不上,且没有任何提示。
⚠️ **这个缝隙没有任何测试覆盖**:既有 e2e 改草稿时传的是**同一个** `batchNo`
(`scripts/e2e-m1-task.py:293-299`),只测过「已发布不可改 `collectMode`」(`:378-384`)。
所以这次是**由前端守住**,不是修了后端。只在界面上只读,服务端该字段仍可写。
新建态(不是编辑)保持可填,提示改成了「4~8 位数字。任务编码 = 批次号 + 4 位序号(如 202609 → 2026090001)」——
它解释的是**用户填这一格时会产生的疑问**,而不是描述实现;
`4~8 位数字` 与后端 `TaskUpsertReq.java:36` 的 `@Pattern("^\\d{4,8}$")` 逐字一致,不要改。
### 四、文案改动清单(改前 → 改后)
| 位置 | 改前 | 改后 |
|---|---|---|
| `TaskFormModal.vue` 填报截止 | 只选日期时按当天 23:59:59 处理 | 选到日即可,系统按当天 23:59 计算 |
| 同上 预览标题 | `预览(以「张同学 / … / 身份证」为例)` | 标题只留「文件名预览」,样例挪到正文(`.task-form__preview-note`) |
| 同上 弹窗标题 | 修改任务 | 修改收集任务 |
| 同上 保存按钮 | 保存(两态同一文案) | 创建任务 / 保存修改 |
| 同上 变量行标签 | 插入变量(12px) | 追加变量(11px;`appendVariable` 本来就是追加到末尾,不是插入到光标) |
| 同上 命名模板提示 | 必须含 … —— 缺了任一项,文件名就无法唯一归属到人 | 必须含 …。用下面的按钮追加变量 |
| `TaskFormSchemaDrawer.vue` 分区标题 | 内置字段(官方 21 项,不可删除) | 内置字段 |
| 同上 内置字段提示 | 这些是认定机构要的官方字段。**不想要某一项时不要删**(也删不掉),把它的「条件显示」设为「不收集」即可。… | 不想收的字段不用删(也删不掉):把它的「条件显示」设为「不收集」。… |
| 同上 扩展字段工具行 | `扩展字段的值落在 extras_json,key 必须带 extra_ 前缀 —— 前缀是防重名的护栏…` | **整条删除**(理由留在代码注释里) |
| 同上 字段标识 label / 提示 | 字段标识(key)/ 必须以 extra_ 开头,只含小写字母、数字、下划线 | 字段标识 / 只能用小写字母、数字、下划线;改动后导出列名会跟着变 |
| 同上 删除确认 | 删除后学生端不再显示这一项。确认删除? | 删除后学生端不再显示这一项。注意:要等「保存为新版本」后才真正生效。确认删除? |
| `TaskMaterialDrawer.vue` 体积上限提示 | 可配范围 307~5120KB(认定机构的接收能力) | 可配 307~5120 KB(0.3M~5M) |
| 同上 模板按钮提示 | 按模板初始化是幂等的,已有的材料不会重复添加 | 按模板初始化只补缺失的材料,已有的不会重复添加 |
| 同上 空表提示 | 必传材料一项都没有时,任务发布不了(服务端会返回 40018) | 必传材料一项都没有时,任务发布不了 |
| 同上 别名提示 | …用于从历史文件名里认出这是哪一项材料 —— 别名不全,该认出来的就认不出来 | …用于从历史文件名里认出这是哪一项材料 |
| 同上 来源引导语 | 提示 = placeholder 全文 | **删掉该 `#feedback`**(同一句话出现两遍,且多一个 feedback 只会让格子更高) |
⚠️ 两条容易改错的地方:
- 「字段标识」**不能**写「按字段名称自动生成」:中文没法可靠转拼音,
`suggestExtraKey()`(`frontend-pc/src/apis/xt/form-schema.ts:187-198`)对中文标签**一律落到 `extra_field`**
(代码注释解释了为什么不猜拼音)。实测「家长联系电话」→ `extra_field`。
- 「批次号」的提示在锁定后**变短**了:`task_code` 已经显示了
「任务编码 2026090241 已按它生成,不可再改」,再写一遍"同时作为任务编码前缀"是重复。
### 五、刻意**不**动的地方(比改了什么更容易被后来者推翻)
| 不动 | 理由 |
|---|---|
| `TaskOrgDrawer.vue` 全文 | 四句提示都对应**真实误解点**(只能勾到班级一层 / 初审人留空 = 该班负责人初审 / 应填人数留空 = 不比对 / 只读原因),改短会直接改变行为理解 |
| `TaskRosterDrawer.vue` 全文 | 说明都是「必须解释的异常」(表头认到第 6 行、列识别结果、体检阻断 vs 警告、报考范围是聚合不是上传计划);改短会让人去改自己那份**本来正确**的 Excel |
| 四个抽屉的 `EDITABLE = [0, 1, 4]` | 是**功能**,不是文案;且与服务端放行口径一致 |
| `index.vue:128-134` 的 `BLOCKING_HINTS` | 发布失败时「去哪里补」的指引,与文案优化无冲突(本轮回归验证过:仍输出「请点本行的『名单管理』…」) |
| `tenant/index.vue` 的 `catch { return }` | 该表单**没有** `#feedback`,错误本来就显示在字段下,不必多加一条 toast 约定 |
| 只读状态的操作列「只读」 | **用户本轮明确要求保留**(`index.vue:392`、`TaskFormSchemaDrawer.vue:214`/`:341` 都还在) |
| 不新建 `src/utils/first-error.ts` | 仓库里没有单函数 utils 的先例,`frontend-pc/AGENTS.md` 要求最小改动、禁止凭空引入新抽象;7 份同形实现各自留在组件里 |
### 六、验证
| 手段 | 结果 |
|---|---|
| `pnpm typecheck` | 0 错(唯一一次红是 `NAlert` 变成未使用 import,已删) |
| `pnpm lint` | 0 错 |
| `check-wiring.py` | ⑧「`<n-*>` 组件未 import 的 0 处」等 10 项全绿,未接线声明 0 条 |
| `verify-docs.py` | 22/22 |
| 真浏览器走查 | 「新建收集任务」弹窗里搜不到 `本期`/`收集模式`/`认定类型`;批次号提示与锁死、footer 两态文案、`追加变量` 标签、`文件名预览` 分栏全部符合预期 |
| 四个抽屉逐屏读文案 | 搜不到 `extras_json` / `extra_` 前缀规则 / `幂等` / `接收能力` / `纠错的时间` / `识别结果只用于提示` / `官方 21 项` |
| 发布阻断回归 | 空草稿点「发布」仍被拦下,横幅 +「请点本行的『名单管理』…」两处都在 |
**没有跑 e2e**:37 个 `scripts/e2e-*.py` 全部打 HTTP 接口,无一渲染 PC 页面;本轮**没改后端**。
契约 119 / 错误码 54 / 原型屏数 29 全不变。前端 `/assets/**` 是 immutable 缓存 → 走查后要**硬刷新**。
### 七、显式不做(已记录,不混进本轮)
1. **克隆任务**:`POST /api/v1/tasks/{id}/clone` 后端完整可跑(`TaskController.java:169-190` + `TaskCloneService`),
`docs/08-完成度盘点与问题清单.md` 标 P1-11「已完成」,`e2e-m5-notify-wiring.py` 还在测它
(班主任 → `40301`、管理员 → 成功),但**前端 `apis/xt/task.ts` 里连函数都没有**,
"宣布完成"与"界面上能用"之间的落差**没有任何检查会发现**(`check-wiring.py` 10 项里没有这一类)。
**用户本轮明确决定先不做。**
2. `POST /api/v1/tasks/{id}/save-as-template`(保存为模板)同样前端零调用。
3. `TaskPublishResult.qrcodeUrl` 发布后被丢弃(`frontend-pc/src/apis/xt/task.ts:108` 定义、全仓零使用)。
4. `formTemplateId` / `materialTemplateId` 建任务时初始化未实现(`docs/03-接口设计.md` §7.2 列了;
材料模板现在只能建完任务后从抽屉「按模板初始化」)。
5. **把校验错误内联渲染到字段下方**:见本文第二节 —— 插槽零参数,唯一的正路是包装组件 + `feedback` 渲染函数,
且需先验证「`h()` 出来的元素能否收到变更后的 `validationStatus`」,未验证前不开工。
6. `index.vue:486-487` 操作列 340px 内排 6~7 个文字动作(草稿态:编辑/配置班级/名单管理/材料规则/表单设计/发布/删除),
收成「主操作 + 下拉」会改动行内交互范式,需单独确认。
@@ -1,6 +1,6 @@
--- ---
name: project-guide name: project-guide
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及九条最容易写错的约定。开始任何 PC 端改动前加载本技能。 description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及十条最容易写错的约定。开始任何 PC 端改动前加载本技能。
--- ---
# 项目总览(PC 管理端) # 项目总览(PC 管理端)
@@ -42,7 +42,7 @@ src/
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`); - 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
不选就不发请求,否则会把各租户数据混在一起且看起来正常。 不选就不发请求,否则会把各租户数据混在一起且看起来正常。
## 九条最容易写错的约定 ## 十条最容易写错的约定
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。 1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿); 漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
@@ -67,6 +67,15 @@ src/
9. **页面内的页签用工具条分段按钮,不要 `NTabs`**:`NTabPane` 要额外 flex 化才能维持 9. **页面内的页签用工具条分段按钮,不要 `NTabs`**:`NTabPane` 要额外 flex 化才能维持
`.page-card` 高度链,否则表格高度算成 0、**一行都看不见**(本仓踩过); `.page-card` 高度链,否则表格高度算成 0、**一行都看不见**(本仓踩过);
且第 5 条那个 `tab="..."` 的坑也在这条路上。各页签的 list/page 状态分开维护。 且第 5 条那个 `tab="..."` 的坑也在这条路上。各页签的 list/page 状态分开维护。
10. **`#feedback` 会遮住校验错误**:`NFormItem` 只要用了这个插槽,
`renderExplains`(校验错误)就**永不渲染**(`children || feedback ? feedback : …`),
用户看到的是「红框 + 一句无关的正常提示」=「点了保存没反应」。
所以带 `#feedback` 的表单,`validate()` 失败必须自己
`message.error(firstError(errors) ?? '请检查表单填写')`。
⚠️ `firstError` **必须摊平后再取**(naive reject 的是**嵌套数组**,`errors[0].message` 恒为
`undefined`);写法抄 `src/views/tenant/TenantEditModal.vue`。
⚠️ 该插槽**不接收任何参数**,没有 `#feedback="{ status }"` 这种写法 ——
想内联渲染错误只能给 `feedback` **prop** 传渲染函数(要自写包装组件)。
## 开发 ## 开发
+19 -1
View File
@@ -43,7 +43,7 @@ pnpm lint # ESLint(@antfu/eslint-config)
- 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引 - 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引
- 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`) - 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`)
## 九条最容易写错的约定 ## 十条最容易写错的约定
1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**, 1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**,
标签统一写 **PascalCase**(`<NButton>`)。 标签统一写 **PascalCase**(`<NButton>`)。
@@ -82,3 +82,21 @@ pnpm lint # ESLint(@antfu/eslint-config)
② 见第 5 条,naive 2.45.3 的 `tab="..."` 属性写法本身也是坏的。 ② 见第 5 条,naive 2.45.3 的 `tab="..."` 属性写法本身也是坏的。
两个按钮做的分段控件在视觉上就是页签,且不动高度链。 两个按钮做的分段控件在视觉上就是页签,且不动高度链。
多个页签各自的 `list` / `total` / `pageNum` / `loading` 要分开维护,切换时重置到第 1 页。 多个页签各自的 `list` / `total` / `pageNum` / `loading` 要分开维护,切换时重置到第 1 页。
10. **`NFormItem` 一旦用了 `#feedback` 插槽,校验错误就被完全遮住** ——
naive 的渲染是 `children || feedback ? feedback : renderExplains`(2.45.3 的 `FormItem.mjs`),
插槽存在时 `renderExplains` **永不渲染**,用户看到的是「红框 + 一句无关的正常提示」,
现象上等价于「点了保存没反应」。所以**凡是有 `#feedback` 的表单,`validate()` 失败必须自己弹**:
```ts
} catch (errors) {
message.error(firstError(errors) ?? '请检查表单填写')
return
}
```
⚠️ `firstError` **必须摊平后再取**:naive 的 `Form.validate()` reject 的是**嵌套数组**
(`Form.mjs` 把每层 `result.errors` 整个 push 进去),`errors[0].message` 恒为 `undefined`,
兜底文案会把每条错误都吞成「请检查表单填写」。写法抄 `src/views/tenant/TenantEditModal.vue`。
⚠️ 这个插槽**不接收任何参数**(内部用 `resolveWrappedSlot(slot, wrapper)` = `wrapper(slot())`),
所以**没有**「`#feedback="{ status }"` 里顺手渲染错误」这种写法 —— 想内联显示错误只能给
`NFormItem` 的 `feedback` **prop** 传渲染函数,那需要自写包装组件,别顺手试。
+29 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormInst, FormRules } from 'naive-ui' import type { FormInst, FormRules } from 'naive-ui'
import { NAlert, NButton, NForm, NFormItem, NInput } from 'naive-ui' import { NAlert, NButton, NForm, NFormItem, NInput, useMessage } from 'naive-ui'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getCaptcha } from '@/apis/xt' import { getCaptcha } from '@/apis/xt'
@@ -25,6 +25,7 @@ defineOptions({ name: 'Login' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const user = useUserStore() const user = useUserStore()
const message = useMessage()
const formRef = ref<FormInst | null>(null) const formRef = ref<FormInst | null>(null)
const loading = ref(false) const loading = ref(false)
@@ -111,11 +112,36 @@ function handleFailure(e: unknown): void {
} }
} }
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
* 插槽存在时 `renderExplains` 永不渲染)。
* 本页的「租户码」有一句常驻提示,而它恰好是**必填**的 —— 漏填时
* 界面上那句提示原样留着、只多一个红框,所以必须自己把第一条错误弹出来。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors)) {
return null
}
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
for (const entry of errors.flat(2)) {
const message = (entry as { message?: unknown } | null)?.message
if (typeof message === 'string' && message) {
return message
}
}
return null
}
async function onSubmit(): Promise<void> { async function onSubmit(): Promise<void> {
try { try {
await formRef.value?.validate() await formRef.value?.validate()
} catch { } catch (errors) {
// 校验失败时 Naive 会把每条错误显示在对应字段下,这里不需要再弹一次 // 没有 `#feedback` 的字段 Naive 会自己把错误显示在字段下,这里多弹一次的收益是:
// 「租户码」那句常驻提示不会把必填错误吃掉(见 firstError 的注释)
message.error(firstError(errors) ?? '请检查表单填写')
return return
} }
+26 -1
View File
@@ -105,10 +105,35 @@ function edit(row: OrgTreeVO): void {
visible.value = true visible.value = true
} }
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
* 插槽存在时 `renderExplains` 永不渲染)。本表单的「组织类型」既带校验又带
* `#feedback` 提示,所以校验失败必须自己把第一条错误弹出来 ——
* 否则界面上只剩一个红框加一句无关的提示,用户看到的是"点了确定没反应"。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors)) {
return null
}
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
for (const entry of errors.flat(2)) {
const message = (entry as { message?: unknown } | null)?.message
if (typeof message === 'string' && message) {
return message
}
}
return null
}
async function confirm(): Promise<void> { async function confirm(): Promise<void> {
try { try {
await formRef.value?.validate() await formRef.value?.validate()
} catch { } catch (errors) {
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
message.error(firstError(errors) ?? '请检查表单填写')
return return
} }
+26 -1
View File
@@ -131,10 +131,35 @@ function edit(row: RoleVO): void {
visible.value = true visible.value = true
} }
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
* 插槽存在时 `renderExplains` 永不渲染)。本表单有 3 个字段带 `#feedback`,
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
* 用户看到的是"点了确定没反应"。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors)) {
return null
}
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
for (const entry of errors.flat(2)) {
const message = (entry as { message?: unknown } | null)?.message
if (typeof message === 'string' && message) {
return message
}
}
return null
}
async function confirm(): Promise<void> { async function confirm(): Promise<void> {
try { try {
await formRef.value?.validate() await formRef.value?.validate()
} catch { } catch (errors) {
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
message.error(firstError(errors) ?? '请检查表单填写')
return return
} }
+82 -39
View File
@@ -2,18 +2,20 @@
/** /**
* 建 / 改任务弹窗(《03-接口设计》§7.2)。 * 建 / 改任务弹窗(《03-接口设计》§7.2)。
* *
* 三处刻意的设计: * 四处刻意的设计:
* 1. **命名模板带实时预览**:光看 `{姓名}-{证件号}-{材料名}` 很难想象最终长什么样, * 1. **命名模板带实时预览**:光看 `{姓名}-{证件号}-{材料名}` 很难想象最终长什么样,
* 而它直接决定导出包里每个文件的名字。用样例数据渲染出来一眼就能判断。 * 而它直接决定导出包里每个文件的名字。用样例数据渲染出来一眼就能判断。
* 2. **收集模式与认定类型做成只读**:它们决定审核流与材料清单,本期也不开放其它取值。 * 2. **不展示收集模式与认定类型**:本期它们恒为「学校收集 + 自主认定」,
* 可编辑只会让人以为能改。 * 界面上开放不了别的取值,写出来只是让人多读一段与自己无关的话。
* 3. **变量用按钮插入而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`, * 取值在 `confirm()` 里写死进请求体(后端 `assertScopeSupported` 会校验)。
* 3. **变量用按钮追加而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
* 服务端只会在发布时给一个 40020,而那时已经配了一堆别的东西。 * 服务端只会在发布时给一个 40020,而那时已经配了一堆别的东西。
* 4. **批次号在"修改"时置灰**:`task_code` = 批次号 + 4 位序号,且它就是文件目录名;
* 服务端 `update` 会无条件写入 `batch_no`,没有"编码已生成"的保护 —— 由前端守住。
*/ */
import type { FormInst, FormRules } from 'naive-ui' import type { FormInst, FormRules } from 'naive-ui'
import type { TaskUpsertReq, TaskVO } from '@/apis/xt/task' import type { TaskUpsertReq, TaskVO } from '@/apis/xt/task'
import { import {
NAlert,
NButton, NButton,
NDatePicker, NDatePicker,
NDivider, NDivider,
@@ -41,18 +43,20 @@ const message = useMessage()
const visible = ref(false) const visible = ref(false)
const submitting = ref(false) const submitting = ref(false)
const detailId = ref<number | null>(null) const detailId = ref<number | null>(null)
/** 当前在编辑的任务编码,只在"修改"时有值 —— 用来告诉用户批次号为什么不能改 */
const detailTaskCode = ref('')
const formRef = useTemplateRef<FormInst>('formRef') const formRef = useTemplateRef<FormInst>('formRef')
const isEdit = computed(() => !!detailId.value) const isEdit = computed(() => !!detailId.value)
const title = computed(() => (isEdit.value ? '修改任务' : '新建收集任务')) const title = computed(() => (isEdit.value ? '修改收集任务' : '新建收集任务'))
/** /**
* 表单自己的类型。 * 表单自己的类型。
* *
* 与 {@link TaskUpsertReq} 的差别是这里的 `jobName` / `jobLevel` 用空串表示"没填" * 与 {@link TaskUpsertReq} 的差别是这里的 `jobName` / `jobLevel` 用空串表示"没填"
* (接口那边用 `null` 表示清空);另外收集模式与认定类型**界面上没有输入项**, * (接口那边用 `null` 表示清空);另外收集模式与认定类型**界面上没有输入项**,
* 构造请求体时写死为 1(本期只有学校收集 + 自主认定)。 * 构造请求体时写死为 1(后端只接受这两个值,传别的返回 40044)。
* *
* `startTime` / `endTime` 允许 `null`:Naive 的日期选择器清空时给的是 `null`。 * `startTime` / `endTime` 允许 `null`:Naive 的日期选择器清空时给的是 `null`。
*/ */
@@ -138,6 +142,19 @@ const namingPreview = computed(() => {
return out || '—' return out || '—'
}) })
/**
* 批次号提示分两态。
*
* 修改时只读:`task_code` = 批次号 + 4 位序号(后端 `TaskService.nextTaskCode`),
* 而它同时是文件目录名。服务端 `update` 会无条件写入 `batch_no`、没有"编码已生成"的保护,
* 所以由前端守住 —— 不改批次号就不会出现"列表里的批次号与任务编码前缀对不上"。
*/
const batchNoHint = computed(() =>
isEdit.value
? `任务编码 ${detailTaskCode.value || '—'} 已按它生成,不可再改`
: '4~8 位数字。任务编码 = 批次号 + 4 位序号(如 202609 → 2026090001)'
)
/** /**
* 变量按钮的文案。 * 变量按钮的文案。
* *
@@ -154,12 +171,14 @@ function appendVariable(v: string) {
function add() { function add() {
detailId.value = null detailId.value = null
detailTaskCode.value = ''
Object.assign(form, getInitForm()) Object.assign(form, getInitForm())
visible.value = true visible.value = true
} }
function edit(row: TaskVO) { function edit(row: TaskVO) {
detailId.value = row.id detailId.value = row.id
detailTaskCode.value = row.taskCode ?? ''
Object.assign(form, getInitForm()) Object.assign(form, getInitForm())
form.taskName = row.taskName form.taskName = row.taskName
form.batchNo = row.batchNo ?? '' form.batchNo = row.batchNo ?? ''
@@ -176,18 +195,42 @@ function close() {
visible.value = false visible.value = false
} }
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
* 插槽存在时 `renderExplains` 永不渲染)。本表单有 3 个字段带 `#feedback`,
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
* 用户看到的是"点了保存没反应"。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors)) {
return null
}
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
// 所以不能直接读 `errors[0].message`(那样恒为 undefined,toast 只会显示兜底文案)。
for (const entry of errors.flat(2)) {
const message = (entry as { message?: unknown } | null)?.message
if (typeof message === 'string' && message) {
return message
}
}
return null
}
async function confirm() { async function confirm() {
try { try {
await formRef.value?.validate() await formRef.value?.validate()
} catch { } catch (errors) {
// ⚠️ Naive 的 `validate()` 在不过时是 **reject**(不是返回 false),所以必须 catch // ⚠️ Naive 的 `validate()` 在不过时是 **reject**(不是返回 false),所以必须 catch
message.error(firstError(errors) ?? '请检查表单填写')
return return
} }
const end = form.endTime ?? '' const end = form.endTime ?? ''
const payload: TaskUpsertReq = { const payload: TaskUpsertReq = {
taskName: form.taskName.trim(), taskName: form.taskName.trim(),
// 本期只开放这两个取值,界面上也没有输入项 —— 写死而不是从表单里读 // 界面上没有输入项 —— 写死而不是从表单里读(后端 assertScopeSupported 只认这两个值)
collectMode: 1, collectMode: 1,
certType: 1, certType: 1,
batchNo: (form.batchNo ?? '').trim(), batchNo: (form.batchNo ?? '').trim(),
@@ -238,9 +281,19 @@ defineExpose({ add, edit })
/> />
</NFormItemGi> </NFormItemGi>
<NFormItemGi :span="10" label="批次号" path="batchNo"> <NFormItemGi :span="10" label="批次号" path="batchNo">
<NInput v-model:value="form.batchNo" placeholder="如 202609" :maxlength="8" clearable /> <!--
修改时置灰:`task_code` = 批次号 + 4 位序号,而它同时是文件目录名 ——
服务端 `update` 会无条件写入 `batch_no`,没有"编码已生成"的保护,所以由前端守住。
-->
<NInput
v-model:value="form.batchNo"
placeholder="如 202609"
:maxlength="8"
:disabled="isEdit"
clearable
/>
<template #feedback> <template #feedback>
<span class="task-form__hint">4~8 位数字,同时作为任务编码前缀</span> <span class="task-form__hint">{{ batchNoHint }}</span>
</template> </template>
</NFormItemGi> </NFormItemGi>
@@ -272,28 +325,16 @@ defineExpose({ add, edit })
class="task-form__date" class="task-form__date"
/> />
<template #feedback> <template #feedback>
<span class="task-form__hint">只选日期时按当天 23:59:59 处理</span> <span class="task-form__hint">选到日即可,系统按当天 23:59 计算</span>
</template> </template>
</NFormItemGi> </NFormItemGi>
</NGrid> </NGrid>
<NDivider title-placement="left"> <!--
本期范围 ⚠️ 这里**故意**不放「收集模式 / 认定类型」的只读展示,也不再写"本期只做学校收集 + 自主认定":
</NDivider> 它们的取值在系统里是恒定的,写出来只是让建任务的人多读一段与自己无关的话。
请求体里 `collectMode: 1` / `certType: 1` 由 confirm() 写死 —— 那才是后端要的东西。
<NGrid :cols="2" :x-gap="16"> -->
<NFormItem label="收集模式">
<NInput value="学校收集" disabled />
</NFormItem>
<NFormItem label="认定类型">
<NInput value="自主认定" disabled />
</NFormItem>
</NGrid>
<NAlert type="info" :show-icon="false" :bordered="false" class="task-form__scope">
本期只做 <strong>学校收集 + 自主认定</strong>:收集模式与认定类型由系统固定,
界面上不开放其它取值。
</NAlert>
<NDivider title-placement="left"> <NDivider title-placement="left">
命名规则 命名规则
@@ -303,14 +344,13 @@ defineExpose({ add, edit })
<NInput v-model:value="form.namingTemplate" :maxlength="256" clearable /> <NInput v-model:value="form.namingTemplate" :maxlength="256" clearable />
<template #feedback> <template #feedback>
<span class="task-form__hint"> <span class="task-form__hint">
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code> —— 必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code>。用下面的按钮追加变量。
缺了任一项,文件名就无法唯一归属到人。
</span> </span>
</template> </template>
</NFormItem> </NFormItem>
<div class="task-form__vars"> <div class="task-form__vars">
<span class="task-form__vars-label">插入变量</span> <span class="task-form__vars-label">追加变量</span>
<NSpace :size="6"> <NSpace :size="6">
<NButton <NButton
v-for="opt in namingVariableOptions" v-for="opt in namingVariableOptions"
@@ -324,7 +364,8 @@ defineExpose({ add, edit })
</NSpace> </NSpace>
</div> </div>
<NFormItem label="预览(以「张同学 / 361029200912100015 / 身份证」为例)"> <NFormItem label="文件名预览">
<span class="task-form__preview-note">以「张同学 / 361029200912100015 / 身份证」为例</span>
<code class="task-form__preview">{{ namingPreview }}</code> <code class="task-form__preview">{{ namingPreview }}</code>
</NFormItem> </NFormItem>
</NForm> </NForm>
@@ -335,7 +376,7 @@ defineExpose({ add, edit })
取消 取消
</NButton> </NButton>
<NButton type="primary" :loading="submitting" @click="confirm"> <NButton type="primary" :loading="submitting" @click="confirm">
保存 {{ isEdit ? '保存修改' : '创建任务' }}
</NButton> </NButton>
</NSpace> </NSpace>
</template> </template>
@@ -357,11 +398,6 @@ defineExpose({ add, edit })
} }
} }
&__scope {
margin-top: -6px;
line-height: 1.8;
}
&__date { &__date {
width: 100%; width: 100%;
} }
@@ -375,6 +411,13 @@ defineExpose({ add, edit })
&__vars-label { &__vars-label {
flex: none; flex: none;
font-size: 11px;
color: #8a8a8a;
}
&__preview-note {
display: block;
margin-bottom: 4px;
font-size: 12px; font-size: 12px;
color: #8a8a8a; color: #8a8a8a;
} }
@@ -346,7 +346,7 @@ const extraColumns = computed<DataTableColumns<EditField>>(() => [
onPositiveClick: () => onDeleteExtra(row) onPositiveClick: () => onDeleteExtra(row)
}, { }, {
trigger: () => h(NButton, { text: true, size: 'small', type: 'error' }, { default: () => '删除' }), trigger: () => h(NButton, { text: true, size: 'small', type: 'error' }, { default: () => '删除' }),
default: () => '删除后学生端不再显示这一项。确认删除?' default: () => '删除后学生端不再显示这一项。注意:要等「保存为新版本」后才真正生效。确认删除?'
}) })
]) ])
} }
@@ -427,11 +427,35 @@ function onDeleteExtra(row: EditField) {
message.info('已从列表移除,点「保存为新版本」后生效') message.info('已从列表移除,点「保存为新版本」后生效')
} }
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
* 插槽存在时 `renderExplains` 永不渲染)。扩展字段弹窗的「字段标识」就带 `#feedback`,
* 它的格式校验(只能小写字母/数字/下划线)**唯一**的提示路径就是这里 ——
* 不弹出来,用户看到的就是"点了确定没反应"。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors)) {
return null
}
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
for (const entry of errors.flat(2)) {
const message = (entry as { message?: unknown } | null)?.message
if (typeof message === 'string' && message) {
return message
}
}
return null
}
async function saveExtra() { async function saveExtra() {
try { try {
await extraFormRef.value?.validate() await extraFormRef.value?.validate()
} catch { } catch (errors) {
// Naive 的 `validate()` 在不过时是 reject(不是返回 false) // Naive 的 `validate()` 在不过时是 reject(不是返回 false)
message.error(firstError(errors) ?? '请检查表单填写')
return return
} }
const opts = needOptions.value const opts = needOptions.value
@@ -520,16 +544,17 @@ defineExpose({ open })
<NSpin :show="loading"> <NSpin :show="loading">
<NDivider title-placement="left"> <NDivider title-placement="left">
内置字段(官方 21 项,不可删除) 内置字段
</NDivider> </NDivider>
<!-- <!--
为什么内置字段不能删:删掉之后"这个值到底该不该有"就永远说不清了 为什么内置字段不能删:删掉之后"这个值到底该不该有"就永远说不清了
(服务端 40026)。不想收的信息用「条件显示 → 不收集」隐藏, (服务端 40026)。不想收的信息用「条件显示 → 不收集」隐藏,
这样它仍然在定义里,只是学生看不到、也不参与必填校验。 这样它仍然在定义里,只是学生看不到、也不参与必填校验。
⚠️ 标题里**不要**再写"(官方 21 项,不可删除)":数量与"不可删"下面这句已经说了,重复就是噪音。
--> -->
<div class="fd__hint"> <div class="fd__hint">
这些是认定机构要的官方字段。<strong>不想要某一项时不要删</strong>(也删不掉), 不想收的字段不用删(也删不掉):把它的「条件显示」设为「不收集」。
把它的「条件显示」设为「不收集」即可。带「只读」的字段由老师在名册里填,学生看不到输入框。 带「只读」的字段由老师在名册里填,学生看不到输入框。
</div> </div>
<NDataTable <NDataTable
@@ -543,6 +568,12 @@ defineExpose({ open })
<NDivider title-placement="left"> <NDivider title-placement="left">
扩展字段(自定义,仅展示与导出,不参与统计) 扩展字段(自定义,仅展示与导出,不参与统计)
</NDivider> </NDivider>
<!--
为什么 key 必须带 `extra_` 前缀、值落在 `extras_json`……都是实现细节,
界面上不写:`suggestExtraKey()`(apis/xt/form-schema.ts:187)已经按字段名称自动生成
合法 key,人不需要知道前缀规则。前缀本身是防重名的护栏 ——
否则"这个值到底是内置的还是扩展的"永远说不清。
-->
<NSpace class="fd__tool" :size="10" align="center"> <NSpace class="fd__tool" :size="10" align="center">
<NButton v-if="!readonly" type="primary" @click="onAddExtra"> <NButton v-if="!readonly" type="primary" @click="onAddExtra">
<template #icon> <template #icon>
@@ -550,10 +581,6 @@ defineExpose({ open })
</template> </template>
新增扩展字段 新增扩展字段
</NButton> </NButton>
<span class="fd__hint fd__hint--inline">
扩展字段的值落在 <code>extras_json</code>,key 必须带 <code>extra_</code> 前缀 ——
前缀是防重名的护栏,否则"这个值到底是内置的还是扩展的"永远说不清
</span>
</NSpace> </NSpace>
<NEmpty v-if="!extras.length" description="没有扩展字段" /> <NEmpty v-if="!extras.length" description="没有扩展字段" />
@@ -581,11 +608,18 @@ defineExpose({ open })
<NFormItemGi :span="12" label="字段名称" path="label"> <NFormItemGi :span="12" label="字段名称" path="label">
<NInput v-model:value="extraForm.label" placeholder="如 家长联系电话" :maxlength="64" clearable /> <NInput v-model:value="extraForm.label" placeholder="如 家长联系电话" :maxlength="64" clearable />
</NFormItemGi> </NFormItemGi>
<NFormItemGi :span="12" label="字段标识(key)" path="key"> <NFormItemGi :span="12" label="字段标识" path="key">
<!--
⚠️ 这里**不能**写"按字段名称自动生成":中文标签没法可靠转拼音,
`suggestExtraKey()`(apis/xt/form-schema.ts:187-198)对中文一律落到 `extra_field`,
说"自动生成"会让人以为它是从"家长联系电话"推出来的。
写"能填什么 + 改了有什么后果"才是有用的;`extra_` 前缀的规则交给校验器(:379-395),
失败时由 saveExtra 弹 toast(这个 #feedback 会把错误文案吃掉)。
-->
<NInput v-model:value="extraForm.key" placeholder="extra_xxx" :maxlength="46" clearable /> <NInput v-model:value="extraForm.key" placeholder="extra_xxx" :maxlength="46" clearable />
<template #feedback> <template #feedback>
<span class="fd__hint fd__hint--inline"> <span class="fd__hint fd__hint--inline">
必须以 <code>extra_</code> 开头,只含小写字母、数字、下划线 只能用小写字母、数字、下划线;改动后导出列名会跟着变
</span> </span>
</template> </template>
</NFormItemGi> </NFormItemGi>
@@ -94,7 +94,7 @@ const templates = computed(() => catalog.value.templates)
const visibleWhenOptions = matApi.VISIBLE_WHEN_OPTIONS const visibleWhenOptions = matApi.VISIBLE_WHEN_OPTIONS
const maxSizeMin = computed(() => catalog.value.maxSizeRange?.[0] ?? 307) const maxSizeMin = computed(() => catalog.value.maxSizeRange?.[0] ?? 307)
const maxSizeMax = computed(() => catalog.value.maxSizeRange?.[1] ?? 5120) const maxSizeMax = computed(() => catalog.value.maxSizeRange?.[1] ?? 5120)
const maxSizeHint = computed(() => `可配范围 ${maxSizeMin.value}~${maxSizeMax.value}KB(认定机构的接收能力)`) const maxSizeHint = computed(() => `可配 ${maxSizeMin.value}~${maxSizeMax.value} KB(0.3M~5M)`)
const visibleWhenIndex = matApi.visibleWhenIndex const visibleWhenIndex = matApi.visibleWhenIndex
const OCR_TYPE_NAMES = matApi.OCR_TYPE_NAMES const OCR_TYPE_NAMES = matApi.OCR_TYPE_NAMES
@@ -359,11 +359,35 @@ function closeForm() {
editingId.value = null editingId.value = null
} }
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
* 插槽存在时 `renderExplains` 永不渲染)。本表单多数字段都带 `#feedback`,
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
* 用户看到的是"点了保存没反应"。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors)) {
return null
}
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
for (const entry of errors.flat(2)) {
const message = (entry as { message?: unknown } | null)?.message
if (typeof message === 'string' && message) {
return message
}
}
return null
}
async function save() { async function save() {
try { try {
await formRef.value?.validate() await formRef.value?.validate()
} catch { } catch (errors) {
// Naive 的 `validate()` 在不过时是 reject(不是返回 false) // Naive 的 `validate()` 在不过时是 reject(不是返回 false)
message.error(firstError(errors) ?? '请检查表单填写')
return return
} }
if (!taskId.value) { if (!taskId.value) {
@@ -489,16 +513,16 @@ defineExpose({ open })
刷新 刷新
</NButton> </NButton>
<!-- <!--
模板是幂等的(已存在的材料码会跳过),所以先说清楚"会跳过已存在的", 模板是幂等的(已存在的材料码会跳过),所以要说清楚"已有的不会重复添加",
否则管理员点两次会以为第一次没生效。 否则管理员点两次会以为第一次没生效。界面上不写"幂等"这个词 —— 那是实现语言。
--> -->
<span class="mat-drawer__hint">按模板初始化是幂等的,已有的材料不会重复添加</span> <span class="mat-drawer__hint">按模板初始化只补缺失的材料,已有的不会重复添加</span>
</NSpace> </NSpace>
<NSpin :show="loading"> <NSpin :show="loading">
<NEmpty v-if="!rows.length" description="还没有材料要求,请先「按模板初始化」或「新增材料」"> <NEmpty v-if="!rows.length" description="还没有材料要求,请先「按模板初始化」或「新增材料」">
<div class="mat-drawer__empty-hint"> <div class="mat-drawer__empty-hint">
必传材料一项都没有时,任务<strong>发布不了</strong>(服务端会返回 40018)。 必传材料一项都没有时,任务<strong>发布不了</strong>。
</div> </div>
</NEmpty> </NEmpty>
@@ -618,6 +642,10 @@ defineExpose({ open })
</NFormItemGi> </NFormItemGi>
<NFormItemGi :span="24" label="来源引导语" path="tipText"> <NFormItemGi :span="24" label="来源引导语" path="tipText">
<!--
这里**不要**再加 `#feedback`:它与 placeholder 说的是同一件事,写了就是同一句话出现两遍;
而多一个 feedback 只会让这一格更高。
-->
<NInput <NInput
v-model:value="form.tipText" v-model:value="form.tipText"
type="textarea" type="textarea"
@@ -626,18 +654,10 @@ defineExpose({ open })
show-count show-count
placeholder="显示在学生端这一项下面,如:拍身份证人像面,四角要拍全" placeholder="显示在学生端这一项下面,如:拍身份证人像面,四角要拍全"
/> />
<template #feedback>
<span class="mat-form__hint">
写得越具体,学生传错的比例越低 —— 这句话省下的是班主任逐人纠错的时间
</span>
</template>
</NFormItemGi> </NFormItemGi>
<NFormItemGi :span="12" label="自动识别" path="ocrType"> <NFormItemGi :span="12" label="自动识别" path="ocrType">
<NSelect v-model:value="form.ocrType" clearable placeholder="不做识别" :options="ocrTypeOptions" /> <NSelect v-model:value="form.ocrType" clearable placeholder="不做识别" :options="ocrTypeOptions" />
<template #feedback>
<span class="mat-form__hint">识别结果只用于提示,不会自动改动材料归属</span>
</template>
</NFormItemGi> </NFormItemGi>
<NFormItemGi :span="12" label="排序号" path="sortNo"> <NFormItemGi :span="12" label="排序号" path="sortNo">
<NInputNumber v-model:value="form.sortNo" :min="0" class="mat-form__num" /> <NInputNumber v-model:value="form.sortNo" :min="0" class="mat-form__num" />
+26 -1
View File
@@ -190,10 +190,35 @@ function closeCreated(): void {
emit('save-success') emit('save-success')
} }
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
* 插槽存在时 `renderExplains` 永不渲染)。本表单有 4 个字段带 `#feedback`,
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
* 用户看到的是"点了确定没反应"。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors)) {
return null
}
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
for (const entry of errors.flat(2)) {
const message = (entry as { message?: unknown } | null)?.message
if (typeof message === 'string' && message) {
return message
}
}
return null
}
async function confirm(): Promise<void> { async function confirm(): Promise<void> {
try { try {
await formRef.value?.validate() await formRef.value?.validate()
} catch { } catch (errors) {
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
message.error(firstError(errors) ?? '请检查表单填写')
return return
} }