feat(表单分页): 重构下一页校验逻辑以统一处理表单提交
将下一页校验逻辑改为通过触发表单的 submit 事件统一处理 新增 navigate_next_pending 标记和 afterValidatedNavigateNext 方法 在 index.vue 的 onSubmit 中拦截下一页意图并处理翻页与状态重置 统一组件 name 属性赋值逻辑
Showing
5 changed files
with
60 additions
and
28 deletions
| 1 | ### | 1 | ### |
| 2 | # @Date: 2023-02-13 14:56:34 | 2 | # @Date: 2023-02-13 14:56:34 |
| 3 | # @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | # @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - # @LastEditTime: 2025-09-09 17:56:45 | 4 | + # @LastEditTime: 2025-11-27 16:16:59 |
| 5 | # @FilePath: /data-table/.env.development | 5 | # @FilePath: /data-table/.env.development |
| 6 | # @Description: 文件描述 | 6 | # @Description: 文件描述 |
| 7 | ### | 7 | ### | ... | ... |
| 1 | 自定义表单 | 1 | 自定义表单 |
| 2 | + | ||
| 3 | + 项目目标 | ||
| 4 | + - 支持多页表单的分页、校验与提交流程。 | ||
| 5 | + | ||
| 6 | + 技术栈 | ||
| 7 | + - 构建:vite;包管理:pnpm;框架:vue3(setup);移动端组件库:vant。 | ||
| 8 | + - 路由:router;状态:pinia;样式:less + tailwindcss(若已引入)。 | ||
| 9 | + | ||
| 10 | + 功能模块 | ||
| 11 | + - 表单分页:通过 `usePagination` 管理页签与可视字段。 | ||
| 12 | + - 表单校验:使用 `van-form` 的 `@submit` 与 `@failed` 事件统一处理。 | ||
| 13 | + | ||
| 14 | + 迭代变更(2025-11-27) | ||
| 15 | + - 下一页校验逻辑改为触发表单的 `onSubmit`,不再直接调用 `validateCurrentPage`。 | ||
| 16 | + - 新增 `navigate_next_pending` 标记与 `afterValidatedNavigateNext` 方法: | ||
| 17 | + - 当点击“下一页”时置标记,`onSubmit` 校验通过后执行翻页并调用 `resetValidation()` 重置后续页的错误显示状态。 | ||
| 18 | + - `onFailed` 时清除该标记,避免误判后续提交动作。 | ||
| 19 | + - `index.vue` 在 `onSubmit` 顶部拦截“下一页意图”,短路真实提交,仅做翻页与状态重置。 | ||
| 20 | + - 保持分页最后一页提示与按钮颜色逻辑同步。 | ... | ... |
| ... | @@ -18,6 +18,8 @@ export function usePagination(formDataRef, options = {}) { | ... | @@ -18,6 +18,8 @@ export function usePagination(formDataRef, options = {}) { |
| 18 | const current_page_index = ref(0) | 18 | const current_page_index = ref(0) |
| 19 | // 每一页的导航配置(由相邻的 paginator 控件决定) | 19 | // 每一页的导航配置(由相邻的 paginator 控件决定) |
| 20 | const page_nav_props_by_index = ref([]) | 20 | const page_nav_props_by_index = ref([]) |
| 21 | + // 下一页意图标记:通过 onSubmit 校验通过后再真正切页 | ||
| 22 | + const navigate_next_pending = ref(false) | ||
| 21 | 23 | ||
| 22 | /** | 24 | /** |
| 23 | * 过滤后的分页 | 25 | * 过滤后的分页 |
| ... | @@ -219,11 +221,27 @@ export function usePagination(formDataRef, options = {}) { | ... | @@ -219,11 +221,27 @@ export function usePagination(formDataRef, options = {}) { |
| 219 | * 下一页 | 221 | * 下一页 |
| 220 | * @description 验证当前页,通过后切换到下一页并执行页面切换后的回调 | 222 | * @description 验证当前页,通过后切换到下一页并执行页面切换后的回调 |
| 221 | */ | 223 | */ |
| 224 | + /** | ||
| 225 | + * 下一页 | ||
| 226 | + * @description 不直接调用 validate,而是触发表单 submit,让视图层的 onSubmit 统一处理校验 | ||
| 227 | + */ | ||
| 222 | const handleNext = async () => { | 228 | const handleNext = async () => { |
| 223 | - const ok = await validateCurrentPage() | 229 | + // 标记为“下一页校验意图”,供 onSubmit 判定 |
| 224 | - if (!ok) return | 230 | + navigate_next_pending.value = true |
| 231 | + // 触发表单校验(验证失败将触发 onFailed,成功将触发 onSubmit) | ||
| 232 | + await myFormRef?.value?.submit?.() | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + /** | ||
| 236 | + * 校验通过后的翻页动作 | ||
| 237 | + * @description onSubmit 检验通过且为“下一页意图”时调用;同时重置后续页面的错误状态 | ||
| 238 | + */ | ||
| 239 | + const afterValidatedNavigateNext = async () => { | ||
| 240 | + navigate_next_pending.value = false | ||
| 225 | if (current_page_index.value < filtered_pages.value.length - 1) { | 241 | if (current_page_index.value < filtered_pages.value.length - 1) { |
| 226 | current_page_index.value += 1 | 242 | current_page_index.value += 1 |
| 243 | + // 重置所有校验状态,避免新页显示历史错误 | ||
| 244 | + try { myFormRef?.value?.resetValidation?.() } catch (e) {} | ||
| 227 | if (typeof afterSwitch === 'function') await afterSwitch() | 245 | if (typeof afterSwitch === 'function') await afterSwitch() |
| 228 | await nextTick() | 246 | await nextTick() |
| 229 | window.scrollTo({ top: 0 }) | 247 | window.scrollTo({ top: 0 }) |
| ... | @@ -248,6 +266,8 @@ export function usePagination(formDataRef, options = {}) { | ... | @@ -248,6 +266,8 @@ export function usePagination(formDataRef, options = {}) { |
| 248 | buildPages, | 266 | buildPages, |
| 249 | page_nav, | 267 | page_nav, |
| 250 | is_last_page, | 268 | is_last_page, |
| 269 | + navigate_next_pending, | ||
| 270 | + afterValidatedNavigateNext, | ||
| 251 | validateCurrentPage, | 271 | validateCurrentPage, |
| 252 | handlePrev, | 272 | handlePrev, |
| 253 | handleNext, | 273 | handleNext, | ... | ... |
| ... | @@ -76,20 +76,19 @@ export function createComponentType(data) { | ... | @@ -76,20 +76,19 @@ export function createComponentType(data) { |
| 76 | if (item.component_props.required) { | 76 | if (item.component_props.required) { |
| 77 | item.rules = [{ required: true, message: item.placeholder ? item.placeholder : '必填项不能为空' }] | 77 | item.rules = [{ required: true, message: item.placeholder ? item.placeholder : '必填项不能为空' }] |
| 78 | } | 78 | } |
| 79 | + // 统一名称:确保所有控件都有稳定的 name,用于 van-form 错误项定位 | ||
| 80 | + item.name = item.key | ||
| 79 | if (item.component_props.tag === 'input') { | 81 | if (item.component_props.tag === 'input') { |
| 80 | item.type = 'text'; | 82 | item.type = 'text'; |
| 81 | - item.name = item.key; | ||
| 82 | item.component = TextField; | 83 | item.component = TextField; |
| 83 | } | 84 | } |
| 84 | if (item.component_props.tag === 'textarea') { | 85 | if (item.component_props.tag === 'textarea') { |
| 85 | item.type = 'textarea'; | 86 | item.type = 'textarea'; |
| 86 | - item.name = item.key; | ||
| 87 | // item.rows = 10; | 87 | // item.rows = 10; |
| 88 | item.autosize = true; | 88 | item.autosize = true; |
| 89 | item.component = TextareaField; | 89 | item.component = TextareaField; |
| 90 | } | 90 | } |
| 91 | if (item.component_props.tag === 'number') { | 91 | if (item.component_props.tag === 'number') { |
| 92 | - item.name = item.key; | ||
| 93 | item.component = NumberField; | 92 | item.component = NumberField; |
| 94 | } | 93 | } |
| 95 | if (item.component_props.tag === 'radio') { | 94 | if (item.component_props.tag === 'radio') { |
| ... | @@ -120,35 +119,27 @@ export function createComponentType(data) { | ... | @@ -120,35 +119,27 @@ export function createComponentType(data) { |
| 120 | item.component = FileUploaderField; | 119 | item.component = FileUploaderField; |
| 121 | } | 120 | } |
| 122 | if (item.component_props.tag === 'phone') { | 121 | if (item.component_props.tag === 'phone') { |
| 123 | - item.name = item.key; | ||
| 124 | item.component = PhoneField; | 122 | item.component = PhoneField; |
| 125 | } | 123 | } |
| 126 | if (item.component_props.tag === 'email') { | 124 | if (item.component_props.tag === 'email') { |
| 127 | - item.name = item.key; | ||
| 128 | item.component = EmailField; | 125 | item.component = EmailField; |
| 129 | } | 126 | } |
| 130 | if (item.component_props.tag === 'sign') { | 127 | if (item.component_props.tag === 'sign') { |
| 131 | - item.name = item.key; | ||
| 132 | item.component = SignField; | 128 | item.component = SignField; |
| 133 | } | 129 | } |
| 134 | if (item.component_props.tag === 'rate') { | 130 | if (item.component_props.tag === 'rate') { |
| 135 | - item.name = item.key; | ||
| 136 | item.component = RatePickerField; | 131 | item.component = RatePickerField; |
| 137 | } | 132 | } |
| 138 | if (item.component_props.tag === 'calendar') { | 133 | if (item.component_props.tag === 'calendar') { |
| 139 | - item.name = item.key; | ||
| 140 | item.component = CalendarField; | 134 | item.component = CalendarField; |
| 141 | } | 135 | } |
| 142 | if (item.component_props.tag === 'id_card') { | 136 | if (item.component_props.tag === 'id_card') { |
| 143 | - item.name = item.key; | ||
| 144 | item.component = IdentityField; | 137 | item.component = IdentityField; |
| 145 | } | 138 | } |
| 146 | if (item.component_props.tag === 'desc') { | 139 | if (item.component_props.tag === 'desc') { |
| 147 | - item.name = item.key; | ||
| 148 | item.component = DesField; | 140 | item.component = DesField; |
| 149 | } | 141 | } |
| 150 | if (item.component_props.tag === 'divider') { | 142 | if (item.component_props.tag === 'divider') { |
| 151 | - item.name = item.key; | ||
| 152 | item.component = DividerField; | 143 | item.component = DividerField; |
| 153 | } | 144 | } |
| 154 | // if (item.component_props.tag === 'video') { | 145 | // if (item.component_props.tag === 'video') { |
| ... | @@ -156,52 +147,40 @@ export function createComponentType(data) { | ... | @@ -156,52 +147,40 @@ export function createComponentType(data) { |
| 156 | // item.component = VideoField; | 147 | // item.component = VideoField; |
| 157 | // } | 148 | // } |
| 158 | if (item.component_props.tag === 'marquee') { | 149 | if (item.component_props.tag === 'marquee') { |
| 159 | - item.name = item.key; | ||
| 160 | item.component = MarqueeField; | 150 | item.component = MarqueeField; |
| 161 | } | 151 | } |
| 162 | if (item.component_props.tag === 'contact') { | 152 | if (item.component_props.tag === 'contact') { |
| 163 | - item.name = item.key; | ||
| 164 | item.component = ContactField; | 153 | item.component = ContactField; |
| 165 | } | 154 | } |
| 166 | if (item.component_props.tag === 'rule') { | 155 | if (item.component_props.tag === 'rule') { |
| 167 | - item.name = item.key; | ||
| 168 | item.component = RuleField; | 156 | item.component = RuleField; |
| 169 | } | 157 | } |
| 170 | if (item.component_props.tag === 'button') { | 158 | if (item.component_props.tag === 'button') { |
| 171 | - item.name = item.key; | ||
| 172 | item.component = ButtonField; | 159 | item.component = ButtonField; |
| 173 | } | 160 | } |
| 174 | if (item.component_props.tag === 'multi_rule') { | 161 | if (item.component_props.tag === 'multi_rule') { |
| 175 | - item.name = item.key; | ||
| 176 | item.value = []; | 162 | item.value = []; |
| 177 | item.component = MultiRuleField; | 163 | item.component = MultiRuleField; |
| 178 | } | 164 | } |
| 179 | if (item.component_props.tag === 'note') { | 165 | if (item.component_props.tag === 'note') { |
| 180 | - item.name = item.key; | ||
| 181 | item.component = NoteField; | 166 | item.component = NoteField; |
| 182 | } | 167 | } |
| 183 | if (item.component_props.tag === 'name') { | 168 | if (item.component_props.tag === 'name') { |
| 184 | - item.name = item.key; | ||
| 185 | item.component = NameField; | 169 | item.component = NameField; |
| 186 | } | 170 | } |
| 187 | if (item.component_props.tag === 'gender') { | 171 | if (item.component_props.tag === 'gender') { |
| 188 | - item.name = item.key; | ||
| 189 | item.component = GenderField; | 172 | item.component = GenderField; |
| 190 | } | 173 | } |
| 191 | if (item.component_props.tag === 'appointment') { | 174 | if (item.component_props.tag === 'appointment') { |
| 192 | - item.name = item.key; | ||
| 193 | item.component = AppointmentField; | 175 | item.component = AppointmentField; |
| 194 | } | 176 | } |
| 195 | if (item.component_props.tag === 'custom') { | 177 | if (item.component_props.tag === 'custom') { |
| 196 | - item.name = item.key; | ||
| 197 | item.component = CustomField; | 178 | item.component = CustomField; |
| 198 | } | 179 | } |
| 199 | if (item.component_props.tag === 'group') { | 180 | if (item.component_props.tag === 'group') { |
| 200 | - item.name = item.key; | ||
| 201 | item.component = GroupField; | 181 | item.component = GroupField; |
| 202 | } | 182 | } |
| 203 | if (item.component_props.tag === 'org_picker') { | 183 | if (item.component_props.tag === 'org_picker') { |
| 204 | - item.name = item.key; | ||
| 205 | item.component = OrgPickerField; | 184 | item.component = OrgPickerField; |
| 206 | } | 185 | } |
| 207 | if (item.component_props.tag === 'volunteer_group') { | 186 | if (item.component_props.tag === 'volunteer_group') { | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2022-07-18 10:22:22 | 2 | * @Date: 2022-07-18 10:22:22 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-11-27 15:33:12 | 4 | + * @LastEditTime: 2025-11-27 16:25:10 |
| 5 | * @FilePath: /data-table/src/views/index.vue | 5 | * @FilePath: /data-table/src/views/index.vue |
| 6 | * @Description: 首页 | 6 | * @Description: 首页 |
| 7 | --> | 7 | --> |
| ... | @@ -1191,6 +1191,12 @@ const successHandle = () => { // 表单成功提交后续操作 | ... | @@ -1191,6 +1191,12 @@ const successHandle = () => { // 表单成功提交后续操作 |
| 1191 | } | 1191 | } |
| 1192 | 1192 | ||
| 1193 | const onSubmit = async (values) => { // 表单提交回调 | 1193 | const onSubmit = async (values) => { // 表单提交回调 |
| 1194 | + // TAG:下一页校验入口 | ||
| 1195 | + // 当为“下一页意图”时,不进行真正提交,仅执行翻页并重置校验状态 | ||
| 1196 | + if (navigate_next_pending?.value) { | ||
| 1197 | + await afterValidatedNavigateNext() | ||
| 1198 | + return false | ||
| 1199 | + } | ||
| 1194 | // 表单数据处理 | 1200 | // 表单数据处理 |
| 1195 | postData.value = preValidData(values); | 1201 | postData.value = preValidData(values); |
| 1196 | // 合并扩展字段 | 1202 | // 合并扩展字段 |
| ... | @@ -1353,6 +1359,10 @@ const onSubmit = async (values) => { // 表单提交回调 | ... | @@ -1353,6 +1359,10 @@ const onSubmit = async (values) => { // 表单提交回调 |
| 1353 | }; | 1359 | }; |
| 1354 | 1360 | ||
| 1355 | const onFailed = (errorInfo) => { // 提交表单且验证不通过后触发 | 1361 | const onFailed = (errorInfo) => { // 提交表单且验证不通过后触发 |
| 1362 | + // TAG:当为“下一页意图”时,验证失败需清除意图标记,避免误判 | ||
| 1363 | + if (navigate_next_pending?.value) { | ||
| 1364 | + navigate_next_pending.value = false | ||
| 1365 | + } | ||
| 1356 | console.log('表单验证失败:', errorInfo); // 添加调试信息 | 1366 | console.log('表单验证失败:', errorInfo); // 添加调试信息 |
| 1357 | 1367 | ||
| 1358 | // 检查是否有错误信息 | 1368 | // 检查是否有错误信息 |
| ... | @@ -1367,8 +1377,10 @@ const onFailed = (errorInfo) => { // 提交表单且验证不通过后触发 | ... | @@ -1367,8 +1377,10 @@ const onFailed = (errorInfo) => { // 提交表单且验证不通过后触发 |
| 1367 | 1377 | ||
| 1368 | // 通过name找到对应的label | 1378 | // 通过name找到对应的label |
| 1369 | let error_name = ''; | 1379 | let error_name = ''; |
| 1380 | + // 兼容:有的组件 name 使用 key,有的使用 '_' + key 或单独的 name | ||
| 1370 | formData.value.forEach(item => { | 1381 | formData.value.forEach(item => { |
| 1371 | - if (item.key === error_item.name) { | 1382 | + const n = error_item?.name |
| 1383 | + if (item.key === n || item.name === n || ('_' + item.key) === n) { | ||
| 1372 | error_name = item.component_props.label | 1384 | error_name = item.component_props.label |
| 1373 | } | 1385 | } |
| 1374 | }); | 1386 | }); |
| ... | @@ -1473,6 +1485,8 @@ const { | ... | @@ -1473,6 +1485,8 @@ const { |
| 1473 | buildPages, | 1485 | buildPages, |
| 1474 | page_nav, | 1486 | page_nav, |
| 1475 | is_last_page, | 1487 | is_last_page, |
| 1488 | + navigate_next_pending, | ||
| 1489 | + afterValidatedNavigateNext, | ||
| 1476 | handlePrev, | 1490 | handlePrev, |
| 1477 | handleNext, | 1491 | handleNext, |
| 1478 | handleSubmit, | 1492 | handleSubmit, | ... | ... |
-
Please register or login to post a comment