240 lines
14 KiB
Markdown
240 lines
14 KiB
Markdown
# 表单等待态 + 统一错误弹窗 + 必填字段焦点跳转 设计
|
|
|
|
- 状态: 已与用户完成澄清问答,用户确认设计方向,直接进入 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 识别时 `<a-spin :spinning="isIdCardOcrProcessing">` 包裹整个表单),本次延用同一模式。
|
|
|
|
### 2.2 `EnterpriseForm.vue`
|
|
|
|
- 用 `<a-spin :spinning="busy" :tip="busyTip">` 包裹范围从原 `<a-form>` 开始扩大到**表单 + "提交/取消"按钮区**(即 `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`
|
|
|
|
- 现有 `<a-spin :spinning="isIdCardOcrProcessing" tip="身份证识别中,请稍候...">`(`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 ''
|
|
})
|
|
```
|
|
- 包裹范围同样从 `<a-form>` 扩大到"提交/取消"按钮区。
|
|
|
|
## 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 的输入框)。
|
|
*
|
|
* 已知局限:若报错字段位于收起状态的 <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.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 生成实施计划。
|