SFT/docs/superpowers/specs/2026-07-21-form-loading-err...

14 KiB

表单等待态 + 统一错误弹窗 + 必填字段焦点跳转 设计

  • 状态: 已与用户完成澄清问答,用户确认设计方向,直接进入 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

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 识别时 <a-spin :spinning="isIdCardOcrProcessing"> 包裹整个表单),本次延用同一模式。

2.2 EnterpriseForm.vue

  • <a-spin :spinning="busy" :tip="busyTip"> 包裹范围从原 <a-form> 开始扩大到表单 + "提交/取消"按钮区(即 EnterpriseForm.vue:6~550 整段),否则按钮位于遮罩外仍可点击。
  • 新增计算属性:
    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

  • 现有 <a-spin :spinning="isIdCardOcrProcessing" tip="身份证识别中,请稍候...">(PersonalForm.vue:6)扩大 spinning 条件与包裹范围:
    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 ''
    })
    
  • 包裹范围同样从 <a-form> 扩大到"提交/取消"按钮区。

3. 必填字段焦点跳转(对应需求2)

3.1 校验缺口修复(先决条件)

要让"提交失败自动跳到必填字段"成立,涉及字段必须先被 a-formrules 真正校验到(否则永远不会触发跳转)。按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:11v-model:model-valueEnterpriseForm.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.valueform.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:

import { nextTick } from 'vue'

/**
 * 表单校验失败后,定位并聚焦第一个报错字段。
 * 不依赖字段名拼接 antd 内部 id 规则,而是直接查找第一个带
 * .ant-form-item-has-error 的节点,兼容自定义封装组件
 * (ChannelSelect/OrgTreeSelect/RegionCascader 等内部都渲染真实可 focus 的输入框)。
 *
 * 已知局限:若报错字段位于收起状态的 <a-collapse-panel> 内(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.vuehandleSubmit 中:

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 生成实施计划。