# 表单等待态 + 统一错误弹窗 + 必填字段焦点跳转 设计 - 状态: 已与用户完成澄清问答,用户确认设计方向,直接进入 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 生成实施计划。