diff --git a/docs/交接摘要.md b/docs/交接摘要.md index d98abe5..b2ef9bd 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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` | ⑧「`` 组件未 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 个文字动作(草稿态:编辑/配置班级/名单管理/材料规则/表单设计/发布/删除), + 收成「主操作 + 下拉」会改动行内交互范式,需单独确认。 + diff --git a/frontend-pc/.agents/skills/project-guide/SKILL.md b/frontend-pc/.agents/skills/project-guide/SKILL.md index 56c65be..1dcfa16 100644 --- a/frontend-pc/.agents/skills/project-guide/SKILL.md +++ b/frontend-pc/.agents/skills/project-guide/SKILL.md @@ -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** 传渲染函数(要自写包装组件)。 ## 开发 diff --git a/frontend-pc/AGENTS.md b/frontend-pc/AGENTS.md index 964739d..4816c34 100644 --- a/frontend-pc/AGENTS.md +++ b/frontend-pc/AGENTS.md @@ -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**(``)。 @@ -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** 传渲染函数,那需要自写包装组件,别顺手试。 diff --git a/frontend-pc/src/views/login/index.vue b/frontend-pc/src/views/login/index.vue index 007940f..8b9f6d3 100644 --- a/frontend-pc/src/views/login/index.vue +++ b/frontend-pc/src/views/login/index.vue @@ -1,6 +1,6 @@