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:
+159
@@ -3325,5 +3325,164 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
「页面内页签用工具条分段按钮,写明高度链的原因」;
|
||||
根 `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
|
||||
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及九条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及十条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||
---
|
||||
|
||||
# 项目总览(PC 管理端)
|
||||
@@ -42,7 +42,7 @@ src/
|
||||
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
|
||||
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
|
||||
|
||||
## 九条最容易写错的约定
|
||||
## 十条最容易写错的约定
|
||||
|
||||
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
|
||||
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
|
||||
@@ -67,6 +67,15 @@ src/
|
||||
9. **页面内的页签用工具条分段按钮,不要 `NTabs`**:`NTabPane` 要额外 flex 化才能维持
|
||||
`.page-card` 高度链,否则表格高度算成 0、**一行都看不见**(本仓踩过);
|
||||
且第 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
@@ -43,7 +43,7 @@ pnpm lint # ESLint(@antfu/eslint-config)
|
||||
- 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引
|
||||
- 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`)
|
||||
|
||||
## 九条最容易写错的约定
|
||||
## 十条最容易写错的约定
|
||||
|
||||
1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**,
|
||||
标签统一写 **PascalCase**(`<NButton>`)。
|
||||
@@ -82,3 +82,21 @@ pnpm lint # ESLint(@antfu/eslint-config)
|
||||
② 见第 5 条,naive 2.45.3 的 `tab="..."` 属性写法本身也是坏的。
|
||||
两个按钮做的分段控件在视觉上就是页签,且不动高度链。
|
||||
多个页签各自的 `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** 传渲染函数,那需要自写包装组件,别顺手试。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 { useRoute, useRouter } from 'vue-router'
|
||||
import { getCaptcha } from '@/apis/xt'
|
||||
@@ -25,6 +25,7 @@ defineOptions({ name: 'Login' })
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const user = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
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> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
// 校验失败时 Naive 会把每条错误显示在对应字段下,这里不需要再弹一次
|
||||
} catch (errors) {
|
||||
// 没有 `#feedback` 的字段 Naive 会自己把错误显示在字段下,这里多弹一次的收益是:
|
||||
// 「租户码」那句常驻提示不会把必填错误吃掉(见 firstError 的注释)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -105,10 +105,35 @@ function edit(row: OrgTreeVO): void {
|
||||
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> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -131,10 +131,35 @@ function edit(row: RoleVO): void {
|
||||
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> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
/**
|
||||
* 建 / 改任务弹窗(《03-接口设计》§7.2)。
|
||||
*
|
||||
* 三处刻意的设计:
|
||||
* 四处刻意的设计:
|
||||
* 1. **命名模板带实时预览**:光看 `{姓名}-{证件号}-{材料名}` 很难想象最终长什么样,
|
||||
* 而它直接决定导出包里每个文件的名字。用样例数据渲染出来一眼就能判断。
|
||||
* 2. **收集模式与认定类型做成只读**:它们决定审核流与材料清单,本期也不开放其它取值。
|
||||
* 可编辑只会让人以为能改。
|
||||
* 3. **变量用按钮插入而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
|
||||
* 2. **不展示收集模式与认定类型**:本期它们恒为「学校收集 + 自主认定」,
|
||||
* 界面上开放不了别的取值,写出来只是让人多读一段与自己无关的话。
|
||||
* 取值在 `confirm()` 里写死进请求体(后端 `assertScopeSupported` 会校验)。
|
||||
* 3. **变量用按钮追加而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
|
||||
* 服务端只会在发布时给一个 40020,而那时已经配了一堆别的东西。
|
||||
* 4. **批次号在"修改"时置灰**:`task_code` = 批次号 + 4 位序号,且它就是文件目录名;
|
||||
* 服务端 `update` 会无条件写入 `batch_no`,没有"编码已生成"的保护 —— 由前端守住。
|
||||
*/
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import type { TaskUpsertReq, TaskVO } from '@/apis/xt/task'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDatePicker,
|
||||
NDivider,
|
||||
@@ -41,18 +43,20 @@ const message = useMessage()
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
/** 当前在编辑的任务编码,只在"修改"时有值 —— 用来告诉用户批次号为什么不能改 */
|
||||
const detailTaskCode = ref('')
|
||||
|
||||
const formRef = useTemplateRef<FormInst>('formRef')
|
||||
|
||||
const isEdit = computed(() => !!detailId.value)
|
||||
const title = computed(() => (isEdit.value ? '修改任务' : '新建收集任务'))
|
||||
const title = computed(() => (isEdit.value ? '修改收集任务' : '新建收集任务'))
|
||||
|
||||
/**
|
||||
* 表单自己的类型。
|
||||
*
|
||||
* 与 {@link TaskUpsertReq} 的差别是这里的 `jobName` / `jobLevel` 用空串表示"没填"
|
||||
* (接口那边用 `null` 表示清空);另外收集模式与认定类型**界面上没有输入项**,
|
||||
* 构造请求体时写死为 1(本期只有学校收集 + 自主认定)。
|
||||
* 构造请求体时写死为 1(后端只接受这两个值,传别的返回 40044)。
|
||||
*
|
||||
* `startTime` / `endTime` 允许 `null`:Naive 的日期选择器清空时给的是 `null`。
|
||||
*/
|
||||
@@ -138,6 +142,19 @@ const namingPreview = computed(() => {
|
||||
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() {
|
||||
detailId.value = null
|
||||
detailTaskCode.value = ''
|
||||
Object.assign(form, getInitForm())
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function edit(row: TaskVO) {
|
||||
detailId.value = row.id
|
||||
detailTaskCode.value = row.taskCode ?? ''
|
||||
Object.assign(form, getInitForm())
|
||||
form.taskName = row.taskName
|
||||
form.batchNo = row.batchNo ?? ''
|
||||
@@ -176,18 +195,42 @@ function close() {
|
||||
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() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// ⚠️ Naive 的 `validate()` 在不过时是 **reject**(不是返回 false),所以必须 catch
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
const end = form.endTime ?? ''
|
||||
const payload: TaskUpsertReq = {
|
||||
taskName: form.taskName.trim(),
|
||||
// 本期只开放这两个取值,界面上也没有输入项 —— 写死而不是从表单里读
|
||||
// 界面上没有输入项 —— 写死而不是从表单里读(后端 assertScopeSupported 只认这两个值)
|
||||
collectMode: 1,
|
||||
certType: 1,
|
||||
batchNo: (form.batchNo ?? '').trim(),
|
||||
@@ -238,9 +281,19 @@ defineExpose({ add, edit })
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<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>
|
||||
<span class="task-form__hint">4~8 位数字,同时作为任务编码前缀</span>
|
||||
<span class="task-form__hint">{{ batchNoHint }}</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
@@ -272,28 +325,16 @@ defineExpose({ add, edit })
|
||||
class="task-form__date"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="task-form__hint">只选日期时按当天 23:59:59 处理</span>
|
||||
<span class="task-form__hint">选到日即可,系统按当天 23:59 计算</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
本期范围
|
||||
</NDivider>
|
||||
|
||||
<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>
|
||||
<!--
|
||||
⚠️ 这里**故意**不放「收集模式 / 认定类型」的只读展示,也不再写"本期只做学校收集 + 自主认定":
|
||||
它们的取值在系统里是恒定的,写出来只是让建任务的人多读一段与自己无关的话。
|
||||
请求体里 `collectMode: 1` / `certType: 1` 由 confirm() 写死 —— 那才是后端要的东西。
|
||||
-->
|
||||
|
||||
<NDivider title-placement="left">
|
||||
命名规则
|
||||
@@ -303,14 +344,13 @@ defineExpose({ add, edit })
|
||||
<NInput v-model:value="form.namingTemplate" :maxlength="256" clearable />
|
||||
<template #feedback>
|
||||
<span class="task-form__hint">
|
||||
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code> ——
|
||||
缺了任一项,文件名就无法唯一归属到人。
|
||||
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code>。用下面的按钮追加变量。
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<div class="task-form__vars">
|
||||
<span class="task-form__vars-label">插入变量</span>
|
||||
<span class="task-form__vars-label">追加变量</span>
|
||||
<NSpace :size="6">
|
||||
<NButton
|
||||
v-for="opt in namingVariableOptions"
|
||||
@@ -324,7 +364,8 @@ defineExpose({ add, edit })
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NFormItem label="预览(以「张同学 / 361029200912100015 / 身份证」为例)">
|
||||
<NFormItem label="文件名预览">
|
||||
<span class="task-form__preview-note">以「张同学 / 361029200912100015 / 身份证」为例</span>
|
||||
<code class="task-form__preview">{{ namingPreview }}</code>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
@@ -335,7 +376,7 @@ defineExpose({ add, edit })
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="confirm">
|
||||
保存
|
||||
{{ isEdit ? '保存修改' : '创建任务' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
@@ -357,11 +398,6 @@ defineExpose({ add, edit })
|
||||
}
|
||||
}
|
||||
|
||||
&__scope {
|
||||
margin-top: -6px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -375,6 +411,13 @@ defineExpose({ add, edit })
|
||||
|
||||
&__vars-label {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
&__preview-note {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ const extraColumns = computed<DataTableColumns<EditField>>(() => [
|
||||
onPositiveClick: () => onDeleteExtra(row)
|
||||
}, {
|
||||
trigger: () => h(NButton, { text: true, size: 'small', type: 'error' }, { default: () => '删除' }),
|
||||
default: () => '删除后学生端不再显示这一项。确认删除?'
|
||||
default: () => '删除后学生端不再显示这一项。注意:要等「保存为新版本」后才真正生效。确认删除?'
|
||||
})
|
||||
])
|
||||
}
|
||||
@@ -427,11 +427,35 @@ function onDeleteExtra(row: EditField) {
|
||||
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() {
|
||||
try {
|
||||
await extraFormRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
const opts = needOptions.value
|
||||
@@ -520,16 +544,17 @@ defineExpose({ open })
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NDivider title-placement="left">
|
||||
内置字段(官方 21 项,不可删除)
|
||||
内置字段
|
||||
</NDivider>
|
||||
<!--
|
||||
为什么内置字段不能删:删掉之后"这个值到底该不该有"就永远说不清了
|
||||
(服务端 40026)。不想收的信息用「条件显示 → 不收集」隐藏,
|
||||
这样它仍然在定义里,只是学生看不到、也不参与必填校验。
|
||||
⚠️ 标题里**不要**再写"(官方 21 项,不可删除)":数量与"不可删"下面这句已经说了,重复就是噪音。
|
||||
-->
|
||||
<div class="fd__hint">
|
||||
这些是认定机构要的官方字段。<strong>不想要某一项时不要删</strong>(也删不掉),
|
||||
把它的「条件显示」设为「不收集」即可。带「只读」的字段由老师在名册里填,学生看不到输入框。
|
||||
不想收的字段不用删(也删不掉):把它的「条件显示」设为「不收集」。
|
||||
带「只读」的字段由老师在名册里填,学生看不到输入框。
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
@@ -543,6 +568,12 @@ defineExpose({ open })
|
||||
<NDivider title-placement="left">
|
||||
扩展字段(自定义,仅展示与导出,不参与统计)
|
||||
</NDivider>
|
||||
<!--
|
||||
为什么 key 必须带 `extra_` 前缀、值落在 `extras_json`……都是实现细节,
|
||||
界面上不写:`suggestExtraKey()`(apis/xt/form-schema.ts:187)已经按字段名称自动生成
|
||||
合法 key,人不需要知道前缀规则。前缀本身是防重名的护栏 ——
|
||||
否则"这个值到底是内置的还是扩展的"永远说不清。
|
||||
-->
|
||||
<NSpace class="fd__tool" :size="10" align="center">
|
||||
<NButton v-if="!readonly" type="primary" @click="onAddExtra">
|
||||
<template #icon>
|
||||
@@ -550,10 +581,6 @@ defineExpose({ open })
|
||||
</template>
|
||||
新增扩展字段
|
||||
</NButton>
|
||||
<span class="fd__hint fd__hint--inline">
|
||||
扩展字段的值落在 <code>extras_json</code>,key 必须带 <code>extra_</code> 前缀 ——
|
||||
前缀是防重名的护栏,否则"这个值到底是内置的还是扩展的"永远说不清
|
||||
</span>
|
||||
</NSpace>
|
||||
|
||||
<NEmpty v-if="!extras.length" description="没有扩展字段" />
|
||||
@@ -581,11 +608,18 @@ defineExpose({ open })
|
||||
<NFormItemGi :span="12" label="字段名称" path="label">
|
||||
<NInput v-model:value="extraForm.label" placeholder="如 家长联系电话" :maxlength="64" clearable />
|
||||
</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 />
|
||||
<template #feedback>
|
||||
<span class="fd__hint fd__hint--inline">
|
||||
必须以 <code>extra_</code> 开头,只含小写字母、数字、下划线
|
||||
只能用小写字母、数字、下划线;改动后导出列名会跟着变
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
@@ -94,7 +94,7 @@ const templates = computed(() => catalog.value.templates)
|
||||
const visibleWhenOptions = matApi.VISIBLE_WHEN_OPTIONS
|
||||
const maxSizeMin = computed(() => catalog.value.maxSizeRange?.[0] ?? 307)
|
||||
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 OCR_TYPE_NAMES = matApi.OCR_TYPE_NAMES
|
||||
@@ -359,11 +359,35 @@ function closeForm() {
|
||||
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() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
if (!taskId.value) {
|
||||
@@ -489,16 +513,16 @@ defineExpose({ open })
|
||||
刷新
|
||||
</NButton>
|
||||
<!--
|
||||
模板是幂等的(已存在的材料码会跳过),所以先说清楚"会跳过已存在的",
|
||||
否则管理员点两次会以为第一次没生效。
|
||||
模板是幂等的(已存在的材料码会跳过),所以要说清楚"已有的不会重复添加",
|
||||
否则管理员点两次会以为第一次没生效。界面上不写"幂等"这个词 —— 那是实现语言。
|
||||
-->
|
||||
<span class="mat-drawer__hint">按模板初始化是幂等的,已有的材料不会重复添加</span>
|
||||
<span class="mat-drawer__hint">按模板初始化只补缺失的材料,已有的不会重复添加</span>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NEmpty v-if="!rows.length" description="还没有材料要求,请先「按模板初始化」或「新增材料」">
|
||||
<div class="mat-drawer__empty-hint">
|
||||
必传材料一项都没有时,任务<strong>发布不了</strong>(服务端会返回 40018)。
|
||||
必传材料一项都没有时,任务<strong>发布不了</strong>。
|
||||
</div>
|
||||
</NEmpty>
|
||||
|
||||
@@ -618,6 +642,10 @@ defineExpose({ open })
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="24" label="来源引导语" path="tipText">
|
||||
<!--
|
||||
这里**不要**再加 `#feedback`:它与 placeholder 说的是同一件事,写了就是同一句话出现两遍;
|
||||
而多一个 feedback 只会让这一格更高。
|
||||
-->
|
||||
<NInput
|
||||
v-model:value="form.tipText"
|
||||
type="textarea"
|
||||
@@ -626,18 +654,10 @@ defineExpose({ open })
|
||||
show-count
|
||||
placeholder="显示在学生端这一项下面,如:拍身份证人像面,四角要拍全"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">
|
||||
写得越具体,学生传错的比例越低 —— 这句话省下的是班主任逐人纠错的时间
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="12" label="自动识别" path="ocrType">
|
||||
<NSelect v-model:value="form.ocrType" clearable placeholder="不做识别" :options="ocrTypeOptions" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">识别结果只用于提示,不会自动改动材料归属</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="排序号" path="sortNo">
|
||||
<NInputNumber v-model:value="form.sortNo" :min="0" class="mat-form__num" />
|
||||
|
||||
@@ -190,10 +190,35 @@ function closeCreated(): void {
|
||||
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> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user