diff --git a/docs/superpowers/specs/2026-07-21-form-loading-error-modal-focus-design.md b/docs/superpowers/specs/2026-07-21-form-loading-error-modal-focus-design.md new file mode 100644 index 0000000..8a43437 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-form-loading-error-modal-focus-design.md @@ -0,0 +1,239 @@ +# 表单等待态 + 统一错误弹窗 + 必填字段焦点跳转 设计 + +- 状态: 已与用户完成澄清问答,用户确认设计方向,直接进入 writing-plans +- 触发来源: 用户提出三点交互体验要求: + 1. 新增企业客户时,上传营业执照照片后 / 点击"提交"时需要等待态(界面转菊花,用户不能操作界面其他信息) + 2. 点击"提交"按钮时,若有必填字段未填导致无法提交,页面焦点需要直接跳转到对应必填字段 + 3. 所有页面的错误提醒统一通过弹窗显示,需用户显式关闭才能进行下一步操作 +- 上级/关联文档: + - `2026-07-21-enterprise-customer-form-enhancement-design.md`(企业客户表单结构的权威设计,本文档不改变其字段/必填结论,仅在此基础上补齐等待态与校验联动) + - `2026-07-20-personal-customer-idcard-ocr-loading-design.md`(个人客户表单已有 OCR loading 的先例模式,本文档在此基础上扩展) + +## 0. 范围确认(与用户澄清结论) + +1. 需求1、2(等待态 + 焦点跳转)**同时覆盖企业客户新增与编辑**(`EnterpriseForm.vue` 的 create/edit 两态),以及**个人客户新增与编辑**(`PersonalForm.vue`,结构高度相似,顺带一起做)。查看态(`isDetail`)本身没有上传/提交操作,不涉及。 +2. 需求3(统一错误弹窗)**覆盖全项目**:全局 axios 响应拦截器 + 所有业务组件里的 `message.error`/`message.warning` 调用,统一改为弹窗;`message.success` 保持轻提示不变。 +3. 401 静默刷新 token 失败后清空登录态并跳转 `/login` 的现有行为**保持不变**,不加弹窗确认(避免多一次点击影响登录过期体验)。 +4. 补齐必填校验缺口时,**以模板上是否已有 `required` 视觉星号为准**: + - 企业客户"证件照片(营业执照)"模板上**没有** `required` 星号,且 `2026-07-21-enterprise-customer-form-enhancement-design.md` 第3.1节已明确决策"维持不必填"——本次**不改动**其必填性。 + - 企业客户/个人客户"所属渠道"(`channelNo`)、个人客户"身份证照片"(正/反面)模板上**已有** `required` 星号但未接入 `rules`——本次**补齐**为真正必填校验。 + +## 1. 统一错误弹窗(对应需求3) + +### 1.1 新建工具 `src/utils/errorModal.js` + +```js +import { Modal } from 'ant-design-vue' + +let queue = [] +let showing = false + +function showNext() { + if (showing || queue.length === 0) return + showing = true + const { content, title } = queue.shift() + Modal.error({ + title, + content, + okText: '我知道了', + onOk: () => { + showing = false + showNext() + } + }) +} + +/** + * 统一错误弹窗:同一时刻只展示一个弹窗,多个错误依次排队, + * 用户必须点击"我知道了"关闭当前弹窗才会展示下一个/才能继续操作。 + */ +export function showErrorModal(content, title = '提示') { + queue.push({ content, title }) + showNext() +} +``` + +- 用队列而不是直接每次调用 `Modal.error()`,避免短时间内多个错误(比如并发请求同时失败)导致弹窗互相遮挡/叠加。 +- `title` 默认"提示",各调用处不强制传具体标题(与现有 `message.error/warning` 调用只传一句话文案的习惯保持一致,不额外要求所有调用处都补标题)。 + +### 1.2 全局拦截器替换(`src/api/request.js`) + +| 位置 | 现状 | 改为 | +| --- | --- | --- | +| `request.js:51`(业务 `code !== 200`) | `message.error(msgText \|\| '请求失败')` | `showErrorModal(msgText \|\| '请求失败')` | +| `request.js:85`(HTTP 403) | `message.error('无权限访问该资源')` | `showErrorModal('无权限访问该资源')` | +| `request.js:88`(网络异常) | `message.error('网络异常,请检查后端服务是否可用')` | `showErrorModal('网络异常,请检查后端服务是否可用')` | +| `request.js:73`(401 静默刷新失败) | `authStore.clear(); window.location.href = '/login'` | **不变**(按0.3节结论) | + +### 1.3 全项目业务代码替换范围 + +全项目搜索到的 `message.error`/`message.warning` 调用共 **53 处,分布 24 个文件**(不含 `request.js` 已在1.2节列出的3处),逐处替换为 `showErrorModal(原文案)`,不改写文案内容: + +``` +src/components/BindCardModal.vue (2处: L200, L218) +src/components/IdCardUpload.vue (3处: L68, L86, L90) +src/components/LicenseUpload.vue (1处: L50) +src/components/MarginModal.vue (1处: L64) +src/components/WithdrawModal.vue (3处: L79, L100, L121) +src/views/base-config/manager/ManagerList.vue (1处: L145) +src/views/credit/loan-application/EnterpriseLoanForm.vue (2处: L603, L607) +src/views/credit/loan-application/PersonalLoanForm.vue (1处: L427) +src/views/customer/enterprise/EnterpriseForm.vue (3处: L787, L806, L913,本次改造同时会调整913行上下文) +src/views/login/Login.vue (4处: L136, L192, L196, L200) +src/views/merchant/invoice/BatchImportModal.vue (5处: L160, L163, L182, L187, L213) +src/views/merchant/invoice/InvoiceChannelActionModal.vue (1处: L39) +src/views/merchant/invoice/InvoiceMatchModal.vue (2处: L133, L137) +src/views/merchant/invoice/InvoiceRegisterModal.vue (1处: L216) +src/views/merchant/management/MerchantForm.vue (1处: L272) +src/views/order/original/OriginalOrderList.vue (1处: L98) +src/views/payment/order-payment/PaymentFormModal.vue (9处: L224~L276) +src/views/system/role/RoleList.vue (1处: L198) +src/views/wallet/account/AccountDetail.vue (3处: L170, L223, L246) +src/views/wallet/enterprise-open/EnterpriseOpenForm.vue (3处: L241, L373, L377) +src/views/wallet/personal-open/PersonalOpenForm.vue (4处: L121, L144, L148, L152) +src/App.vue (1处: L25) +``` + +- 各文件里需 `import { showErrorModal } from '@/utils/errorModal'`,若替换后该文件不再使用 `message.success`/`message` 其它方法,则同步移除多余的 `message` import;若仍用 `message.success`,保留其 import。 +- `src/utils/batchDeleteResult.js` 里已使用的 `Modal.error`/`Modal.warning`(展示批量操作失败清单)**不改动**——本身已经是"弹窗 + 显式关闭",符合需求3,且这是信息汇总场景(可能包含表格化的成功/失败清单),不适合套用只接受纯文本的 `showErrorModal`。 + +## 2. 上传/提交等待态(对应需求1) + +### 2.1 技术依据 + +项目 `ant-design-vue` 版本为 `^4.2.0`,其 `Spin` 组件 `spinning=true` 时,包裹的子内容容器会加 `ant-spin-blur` class,对应样式包含 `pointer-events: none`,能天然满足"用户不能操作界面其他信息"的要求,不需要额外自定义遮罩组件。`src/views/customer/personal/PersonalForm.vue` 已有先例(OCR 识别时 `` 包裹整个表单),本次延用同一模式。 + +### 2.2 `EnterpriseForm.vue` + +- 用 `` 包裹范围从原 `` 开始扩大到**表单 + "提交/取消"按钮区**(即 `EnterpriseForm.vue:6`~`550` 整段),否则按钮位于遮罩外仍可点击。 +- 新增计算属性: + ```js + const busy = computed(() => submitting.value || businessLicenseUploading.value) + const busyTip = computed(() => { + if (businessLicenseUploading.value) return '营业执照上传中,请稍候...' + if (submitting.value) return '提交中,请稍候...' + return '' + }) + ``` +- 原 `:disabled="businessLicenseUploading"`(`EnterpriseForm.vue:546`)可以去掉——spin 遮罩已经阻止点击,不需要按钮再单独禁用(避免"遮罩+disabled"双重视觉状态叠加造成按钮显示異常)。`:loading="submitting"` 保留,给按钮本身也加个小菊花,双重视觉反馈不冲突。 +- `handleSubmit` 内原有的二次防护(`if (businessLicenseUploading.value) { message.warning(...); return }`,`EnterpriseForm.vue:912-915`)保留作为兜底(理论上遮罩生效后此分支不会被触发,但保留以防未来交互改动引入遗漏),提示方式按第1节改为 `showErrorModal`。 +- 原模板里的文字提示"营业执照上传中,请稍候再提交..."(`EnterpriseForm.vue:21`)与新的 spin `tip` 文案重复,予以移除,改由 spin 的 `tip` 统一呈现。 + +### 2.3 `PersonalForm.vue` + +- 现有 ``(`PersonalForm.vue:6`)扩大 `spinning` 条件与包裹范围: + ```js + const busy = computed( + () => isIdCardOcrProcessing.value || idCardFrontUploading.value || idCardBackUploading.value || submitting.value + ) + const busyTip = computed(() => { + if (isIdCardOcrProcessing.value) return '身份证识别中,请稍候...' + if (idCardFrontUploading.value || idCardBackUploading.value) return '身份证照片上传中,请稍候...' + if (submitting.value) return '提交中,请稍候...' + return '' + }) + ``` +- 包裹范围同样从 `` 扩大到"提交/取消"按钮区。 + +## 3. 必填字段焦点跳转(对应需求2) + +### 3.1 校验缺口修复(先决条件) + +要让"提交失败自动跳到必填字段"成立,涉及字段必须先被 `a-form` 的 `rules` 真正校验到(否则永远不会触发跳转)。按0.4节结论,修复范围: + +**`EnterpriseForm.vue`**: +- `channelNo` 从游离的 `ref(undefined)`(`EnterpriseForm.vue:616`)迁移进 `form.channelNo`(reactive 属性),模板对应 `a-form-item`(`EnterpriseForm.vue:10`)加 `name="channelNo"`,`rules` 新增 `channelNo: [{ required: true, message: '请选择所属渠道' }]`。 +- 同步替换文件内其余 `channelNo` 引用(`EnterpriseForm.vue:11` 的 `v-model:model-value`、`EnterpriseForm.vue:16` 的 `:channel-no` 绑定)为 `form.channelNo`。 +- 证件照片(`businessLicenseFileNo`)**不改动**,保持无 `name`/无 `rule`(按0.4节结论)。 + +**`PersonalForm.vue`**: +- `channelNo` 同样从游离 `ref` 迁移进 `form.channelNo`,补 `name="channelNo"` + 必填 `rule`;同步替换 `PersonalForm.vue:281/9/16/25/457/458` 等处引用(`channelNo.value` → `form.channelNo`)。 +- 身份证正面 `idCardFrontFileNo`、反面 `idCardBackFileNo` 对应的 `a-form-item`(`PersonalForm.vue:12`,目前正反面共用一个 `a-form-item` 且没有 `name`)拆成两个独立校验点,或在同一 `a-form-item` 下用一个自定义字段名(如 `idCardPhotos`)统一校验"正反面是否都已上传",二者选择哪种由实施阶段按现有 DOM 结构决定,校验规则文案:"请上传身份证正面照片" / "请上传身份证反面照片"(或合并为"请上传身份证正反面照片")。 + +### 3.2 焦点跳转机制 + +`ant-design-vue` 4.x 的 `formRef.value.scrollToField(name)`(源码 `node_modules/ant-design-vue/es/form/Form.js:164-177`)内部只调用 `scrollIntoView`,**不会**对 DOM 节点调用 `.focus()`,不满足"焦点跳转"的字面要求,因此不直接使用该 API。 + +新建通用工具 `src/utils/formFocus.js`: + +```js +import { nextTick } from 'vue' + +/** + * 表单校验失败后,定位并聚焦第一个报错字段。 + * 不依赖字段名拼接 antd 内部 id 规则,而是直接查找第一个带 + * .ant-form-item-has-error 的节点,兼容自定义封装组件 + * (ChannelSelect/OrgTreeSelect/RegionCascader 等内部都渲染真实可 focus 的输入框)。 + * + * 已知局限:若报错字段位于收起状态的 内(display:none), + * .focus() 不会生效。当前项目所有必填字段均在展开区域,暂不处理该场景。 + */ +export function focusFirstInvalidField() { + nextTick(() => { + const errorItem = document.querySelector('.ant-form-item-has-error') + if (!errorItem) return + errorItem.scrollIntoView({ behavior: 'smooth', block: 'center' }) + const focusable = errorItem.querySelector('input, textarea, select, [tabindex]') + focusable?.focus?.() + }) +} +``` + +`EnterpriseForm.vue` / `PersonalForm.vue` 的 `handleSubmit` 中: + +```js +try { + await formRef.value.validate() +} catch { + focusFirstInvalidField() + return +} +``` + +## 4. 涉及文件清单 + +``` +新增: + src/utils/errorModal.js # 统一错误弹窗队列 + src/utils/formFocus.js # 校验失败焦点跳转 + +修改: + src/api/request.js # 3处 message.error -> showErrorModal(401分支不变) + src/views/customer/enterprise/EnterpriseForm.vue # channelNo迁移进form+补rule、a-spin扩大包裹范围、handleSubmit加focusFirstInvalidField、3处message.*替换 + src/views/customer/personal/PersonalForm.vue # channelNo/身份证正反面补rule、a-spin条件与包裹范围扩大、handleSubmit加focusFirstInvalidField + 以下22个文件的 message.error/message.warning 调用改为 showErrorModal(逐处替换,不改文案,详见1.3节清单): + src/components/BindCardModal.vue + src/components/IdCardUpload.vue + src/components/LicenseUpload.vue + src/components/MarginModal.vue + src/components/WithdrawModal.vue + src/views/base-config/manager/ManagerList.vue + src/views/credit/loan-application/EnterpriseLoanForm.vue + src/views/credit/loan-application/PersonalLoanForm.vue + src/views/login/Login.vue + src/views/merchant/invoice/BatchImportModal.vue + src/views/merchant/invoice/InvoiceChannelActionModal.vue + src/views/merchant/invoice/InvoiceMatchModal.vue + src/views/merchant/invoice/InvoiceRegisterModal.vue + src/views/merchant/management/MerchantForm.vue + src/views/order/original/OriginalOrderList.vue + src/views/payment/order-payment/PaymentFormModal.vue + src/views/system/role/RoleList.vue + src/views/wallet/account/AccountDetail.vue + src/views/wallet/enterprise-open/EnterpriseOpenForm.vue + src/views/wallet/personal-open/PersonalOpenForm.vue + src/App.vue + +不改动: + src/utils/batchDeleteResult.js # 已是弹窗形式,符合需求3,原因见1.3节 + EnterpriseForm.vue 的证件照片(businessLicenseFileNo)必填性 # 按已确认设计维持不必填 +``` + +## 5. 自查(占位符/矛盾/歧义/范围) + +- 全文无 "TBD/待定" 类占位符,`message.error/warning` 转换清单为搜索实际结果,非估算。 +- 与 `2026-07-21-enterprise-customer-form-enhancement-design.md` 的"证件照片不必填"结论无冲突(已在0.4节与3.1节明确排除该字段)。 +- 401 静默刷新失败的现有行为与"统一错误弹窗"需求存在天然张力(该场景故意不弹窗),已在0.3节明确记录为用户确认的例外,不是遗漏。 +- 范围严格对应用户提出的三点需求:①②限定在企业客户+个人客户表单(用户已确认顺带处理个人客户),③覆盖全项目错误提醒,未扩展到其它未提及的交互改动。 + +设计自查通过,进入 writing-plans 生成实施计划。