feat(分页): 新增分页组件及分页功能实现
- 添加 PaginationField 分页组件,支持上一页/下一页/提交按钮控制 - 在 index.vue 中实现分页逻辑,包括分页构建、当前页字段过滤和校验 - 修改表单渲染逻辑,仅显示当前页字段 - 添加分页切换时的校验功能,确保数据有效性
Showing
3 changed files
with
228 additions
and
3 deletions
.trae/documents/实现表单分页与校验联动.md
0 → 100644
| 1 | +## 目标 | ||
| 2 | +- 在 `src/components/PaginationField/index.vue` 新增分页组件,控制当前页的字段显示/隐藏。 | ||
| 3 | +- 首页仅显示“下一页”,中间页显示“上一页/下一页”,最后一页显示“上一页/提交”。 | ||
| 4 | +- 切页前主动校验当前页字段;最后页点击“提交”调用现有 `van-form` 的 `onSubmit`(`src/views/index.vue:26`)。 | ||
| 5 | + | ||
| 6 | +## 总体思路 | ||
| 7 | +- 在父页面 `src/views/index.vue` 中引入分页组件,基于“页配置”过滤 `formData`,使 `v-for` 仅渲染当前页字段(`src/views/index.vue:28-29`)。 | ||
| 8 | +- 校验采用 Vant Form 校验能力:仅渲染的字段会被 `myForm.validate()` 校验;自定义组件使用现有 `validOther()` 进行补充校验。 | ||
| 9 | +- 提交沿用现有 `myForm.submit()` 与 `onSubmit` 全流程逻辑,无需改接口与数据流。 | ||
| 10 | + | ||
| 11 | +## 关键点 | ||
| 12 | +- 字段显示/隐藏:通过“当前页的字段 key 列表”过滤 `formData`,只渲染当前页字段;规则隐藏(`checkRules`)仍生效,提交时继续移除 `disabled` 字段。 | ||
| 13 | +- 切页校验:点击“下一页”先调用 `myForm.validate()` + `validOther()`,不通过则提示并停留该页;通过才切页。 | ||
| 14 | +- 提交触发:最后一页点击“提交”直接 `myForm.submit()`,沿用现有 `onSubmit` 分支处理。 | ||
| 15 | + | ||
| 16 | +## 组件设计(PaginationField) | ||
| 17 | +- Props:`current`(当前页索引)、`total`(总页数)、`isLast`(是否最后页)。 | ||
| 18 | +- Emits:`prev`、`next`、`submit`。 | ||
| 19 | +- UI:底部固定区域,首页仅“下一页”,中间页“上一页/下一页”,最后页“上一页/提交”。样式用 less,遵循项目风格。 | ||
| 20 | + | ||
| 21 | +## 页面改动(index.vue) | ||
| 22 | +- 引入并放置分页组件:在 `van-form` 的字段列表之后、提交按钮之前插入 `PaginationField`;当启用分页时隐藏原提交按钮(非流程版 `PCommit.visible` 处)。 | ||
| 23 | +- 计算当前页可见字段: | ||
| 24 | + - 维护 `pages`:数组形式,每页是一组字段 `key`。 | ||
| 25 | + - 维护 `current_page_index`;计算 `visible_keys` 与 `visible_form_data = formData.filter(key ∈ visible_keys)`。 | ||
| 26 | + - 将现有 `v-for` 的数据源换为 `visible_form_data`(对应 `src/views/index.vue:28-29`)。 | ||
| 27 | + | ||
| 28 | +## 校验逻辑 | ||
| 29 | +- 收集当前页渲染的字段 `name`(动态组件普遍设置 `item.name = item.key`,参见 `src/hooks/useComponentType.js:32-214`,以及各组件中的 `van-field :name`)。 | ||
| 30 | +- 点击“下一页”: | ||
| 31 | + - 执行 `myForm.validate()`(仅当前页渲染字段会被校验)。 | ||
| 32 | + - 执行 `validOther()` 校验图片/文件/表格等自定义组件(当前页渲染的才在 ref 列表内)。 | ||
| 33 | + - 任一失败:根据 `onFailed` 提示规则,Toast 显示第一个错误并阻止切页(`src/views/index.vue:1303-1330`)。 | ||
| 34 | +- 点击“提交”:直接 `myForm.submit()`,触发既有 `onSubmit` 流程(`src/views/index.vue:1141-1301`)。 | ||
| 35 | + | ||
| 36 | +## Mock 分页策略(后端未定) | ||
| 37 | +- 优先按“分割线组件 divider”自动分页:遇到 `DividerField` 开始新页;若无,则按数量(如 5~8 个字段/页)进行等分。 | ||
| 38 | +- `pages` 结构为 `[ ['field_a','field_b',...], ['field_x',...], ... ]`,后续可由后端下发或用户手动配置覆盖。 | ||
| 39 | + | ||
| 40 | +## 兼容与边界 | ||
| 41 | +- 规则隐藏:`checkRules()` 仍会设置 `disabled`,过滤时排除 `disabled` 字段以避免误校验与误提交。 | ||
| 42 | +- 流程页(`formSetting.is_flow`)不受影响:分页仅控制字段渲染,提交行为沿用现有流程分支;如有需要可在流程页也显示分页按钮,最终依然走 `myForm.submit()`。 | ||
| 43 | +- 历史数据与 Cookie 回填:不影响分页,仍在挂载阶段写入默认值(`src/views/index.vue:480-540`)。 | ||
| 44 | + | ||
| 45 | +## 实施步骤 | ||
| 46 | +1. 完成 `PaginationField` 组件开发(props/emits/按钮/less)。 | ||
| 47 | +2. 在 `index.vue` 引入组件,新增 `pages` 与 `current_page_index` 状态,改造 `v-for` 为 `visible_form_data`。 | ||
| 48 | +3. 实现 `handlePrev/handleNext/handleSubmit`,接入 `myForm.validate()` + `validOther()` + `myForm.submit()`。 | ||
| 49 | +4. 当启用分页时隐藏原“提交”按钮(保留流程提交按钮逻辑)。 | ||
| 50 | + | ||
| 51 | +## 验收 | ||
| 52 | +- 首页仅“下一页”,中间页“上一页/下一页”,最后页“上一页/提交”。 | ||
| 53 | +- 当前页校验失败时 Toast 提示并停留该页;通过时切页。 | ||
| 54 | +- 最后一页点击“提交”走既有 `onSubmit` 流程,提交成功行为不变。 |
src/components/PaginationField/index.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-11-18 16:17:40 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-11-18 16:48:02 | ||
| 5 | + * @FilePath: /data-table/src/components/PaginationField/index.vue | ||
| 6 | + * @Description: 分页组件 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <div class="pagination-field"> | ||
| 10 | + <div class="indicator">第{{ current + 1 }}页 / 共{{ total }}页</div> | ||
| 11 | + <div class="actions"> | ||
| 12 | + <van-button v-if="showPrev" round type="primary" class="btn" @click="onPrev">上一页</van-button> | ||
| 13 | + <van-button v-if="showNext" round type="primary" class="btn" @click="onNext">下一页</van-button> | ||
| 14 | + <van-button v-if="showSubmit" round type="primary" class="btn" @click="onSubmit">提交</van-button> | ||
| 15 | + </div> | ||
| 16 | + </div> | ||
| 17 | + <div class="placeholder" /> | ||
| 18 | +</template> | ||
| 19 | + | ||
| 20 | +<script setup> | ||
| 21 | +import { computed } from 'vue' | ||
| 22 | + | ||
| 23 | +const props = defineProps({ | ||
| 24 | + current: { type: Number, default: 0 }, | ||
| 25 | + total: { type: Number, default: 1 }, | ||
| 26 | + isLast: { type: Boolean, default: false }, | ||
| 27 | +}) | ||
| 28 | + | ||
| 29 | +const emit = defineEmits(['prev', 'next', 'submit']) | ||
| 30 | + | ||
| 31 | +const showPrev = computed(() => props.current > 0) | ||
| 32 | +const showNext = computed(() => props.current < props.total - 1) | ||
| 33 | +const showSubmit = computed(() => props.current === props.total - 1) | ||
| 34 | + | ||
| 35 | +const onPrev = () => emit('prev') | ||
| 36 | +const onNext = () => emit('next') | ||
| 37 | +const onSubmit = () => emit('submit') | ||
| 38 | +</script> | ||
| 39 | + | ||
| 40 | +<style lang="less" scoped> | ||
| 41 | +.pagination-field { | ||
| 42 | + position: fixed; | ||
| 43 | + left: 0; | ||
| 44 | + right: 0; | ||
| 45 | + bottom: 0; | ||
| 46 | + background: #fff; | ||
| 47 | + padding: 0.75rem 1rem; | ||
| 48 | + border-top: 1px solid #eaeaea; | ||
| 49 | + display: flex; | ||
| 50 | + flex-direction: column; | ||
| 51 | + align-items: center; | ||
| 52 | + z-index: 10; | ||
| 53 | + | ||
| 54 | + .indicator { | ||
| 55 | + font-size: 0.85rem; | ||
| 56 | + color: #666; | ||
| 57 | + margin-bottom: 0.5rem; | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + .actions { | ||
| 61 | + display: flex; | ||
| 62 | + gap: 0.75rem; | ||
| 63 | + | ||
| 64 | + .btn { | ||
| 65 | + min-width: 6rem; | ||
| 66 | + } | ||
| 67 | + } | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +.placeholder { | ||
| 71 | + height: 3.75rem; | ||
| 72 | +} | ||
| 73 | +</style> |
| 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-10-01 15:41:31 | 4 | + * @LastEditTime: 2025-11-18 17:09:46 |
| 5 | * @FilePath: /data-table/src/views/index.vue | 5 | * @FilePath: /data-table/src/views/index.vue |
| 6 | * @Description: 首页 | 6 | * @Description: 首页 |
| 7 | --> | 7 | --> |
| ... | @@ -25,11 +25,12 @@ | ... | @@ -25,11 +25,12 @@ |
| 25 | <van-config-provider :theme-vars="themeVars"> | 25 | <van-config-provider :theme-vars="themeVars"> |
| 26 | <van-form ref="myForm" @submit="onSubmit" @failed="onFailed" :scroll-to-error="true"> | 26 | <van-form ref="myForm" @submit="onSubmit" @failed="onFailed" :scroll-to-error="true"> |
| 27 | <van-cell-group :border="false"> | 27 | <van-cell-group :border="false"> |
| 28 | - <component v-for="(item, index) in formData" :id="item.key" :ref="(el) => setRefMap(el, item)" :key="index" | 28 | + <component v-for="(item, index) in visible_form_data" :id="item.key" :ref="(el) => setRefMap(el, item)" :key="index" |
| 29 | :is="item.component" :item="item" @active="onActive" @remove="onRemove" @blur="onBlur" /> | 29 | :is="item.component" :item="item" @active="onActive" @remove="onRemove" @blur="onBlur" /> |
| 30 | </van-cell-group> | 30 | </van-cell-group> |
| 31 | + <pagination-field v-if="enable_pagination" :current="current_page_index" :total="pages.length" :is-last="current_page_index === pages.length - 1" @prev="handlePrev" @next="handleNext" @submit="handleSubmit" /> | ||
| 31 | <!-- 非流程版表单 --> | 32 | <!-- 非流程版表单 --> |
| 32 | - <div v-if="formData.length && PCommit.visible && !formSetting.is_flow" style="margin: 16px"> | 33 | + <div v-if="formData.length && PCommit.visible && !formSetting.is_flow && !enable_pagination" style="margin: 16px"> |
| 33 | <van-button round block type="primary" native-type="submit" :disabled="submitStatus"> | 34 | <van-button round block type="primary" native-type="submit" :disabled="submitStatus"> |
| 34 | {{ PCommit.text ? PCommit.text : '提交' }} | 35 | {{ PCommit.text ? PCommit.text : '提交' }} |
| 35 | </van-button> | 36 | </van-button> |
| ... | @@ -204,6 +205,7 @@ import { styleColor } from "@/constant.js"; | ... | @@ -204,6 +205,7 @@ import { styleColor } from "@/constant.js"; |
| 204 | import { sharePage } from '@/composables/useShare.js' | 205 | import { sharePage } from '@/composables/useShare.js' |
| 205 | import wx from 'weixin-js-sdk' | 206 | import wx from 'weixin-js-sdk' |
| 206 | import LoginBox from '@/components/LoginBox/index.vue'; | 207 | import LoginBox from '@/components/LoginBox/index.vue'; |
| 208 | +import PaginationField from '@/components/PaginationField/index.vue'; | ||
| 207 | 209 | ||
| 208 | const $route = useRoute(); | 210 | const $route = useRoute(); |
| 209 | const $router = useRouter(); | 211 | const $router = useRouter(); |
| ... | @@ -238,6 +240,18 @@ const PCommit = ref({}); | ... | @@ -238,6 +240,18 @@ const PCommit = ref({}); |
| 238 | * 表单结构数据 | 240 | * 表单结构数据 |
| 239 | */ | 241 | */ |
| 240 | const formData = ref([]); | 242 | const formData = ref([]); |
| 243 | +const pages = ref([]); | ||
| 244 | +// 分页配置 | ||
| 245 | +const enable_pagination = ref(false); | ||
| 246 | +const current_page_index = ref(0); | ||
| 247 | +const visible_keys = computed(() => { | ||
| 248 | + if (!pages.value.length) return formData.value.map(i => i.key); | ||
| 249 | + return pages.value[current_page_index.value] || []; | ||
| 250 | +}); | ||
| 251 | +const visible_form_data = computed(() => { | ||
| 252 | + const set = new Set(visible_keys.value); | ||
| 253 | + return formData.value.filter(i => set.has(i.key) && !i.component_props?.disabled); | ||
| 254 | +}); | ||
| 241 | 255 | ||
| 242 | /** | 256 | /** |
| 243 | * 格式化表单数据 | 257 | * 格式化表单数据 |
| ... | @@ -476,6 +490,12 @@ onMounted(async () => { | ... | @@ -476,6 +490,12 @@ onMounted(async () => { |
| 476 | // }) | 490 | // }) |
| 477 | 491 | ||
| 478 | formData.value = formatData(page_form); | 492 | formData.value = formatData(page_form); |
| 493 | + /** | ||
| 494 | + * * TAG: 构建分页 | ||
| 495 | + */ | ||
| 496 | + buildPages(); | ||
| 497 | + enable_pagination.value = pages.value.length > 1; | ||
| 498 | + /**** END ****/ | ||
| 479 | 499 | ||
| 480 | // TAG:获取原来表单数据 | 500 | // TAG:获取原来表单数据 |
| 481 | if (data_id) { // 如果有data_id,则获取历史数据 否则获取表单默认值 | 501 | if (data_id) { // 如果有data_id,则获取历史数据 否则获取表单默认值 |
| ... | @@ -1408,6 +1428,84 @@ const setVolunteerData = async (volunteer_phone) => { | ... | @@ -1408,6 +1428,84 @@ const setVolunteerData = async (volunteer_phone) => { |
| 1408 | }); | 1428 | }); |
| 1409 | } | 1429 | } |
| 1410 | } | 1430 | } |
| 1431 | + | ||
| 1432 | +// 构建分页 | ||
| 1433 | +const buildPages = () => { | ||
| 1434 | + const result = []; | ||
| 1435 | + let cur = []; | ||
| 1436 | + formData.value.forEach(item => { | ||
| 1437 | + const tag = item.component_props?.tag; | ||
| 1438 | + // TODO: 分页组件标识暂时不确定 | ||
| 1439 | + if (tag === 'divider1') { | ||
| 1440 | + if (cur.length) result.push(cur); | ||
| 1441 | + cur = []; | ||
| 1442 | + return; | ||
| 1443 | + } | ||
| 1444 | + cur.push(item.key); | ||
| 1445 | + }); | ||
| 1446 | + if (cur.length) result.push(cur); | ||
| 1447 | + if (result.length <= 1) { | ||
| 1448 | + const size = 8; | ||
| 1449 | + const keys = formData.value.filter(i => i.component_props?.tag !== 'divider').map(i => i.key); | ||
| 1450 | + const chunked = []; | ||
| 1451 | + for (let i = 0; i < keys.length; i += size) { | ||
| 1452 | + chunked.push(keys.slice(i, i + size)); | ||
| 1453 | + } | ||
| 1454 | + pages.value = chunked.length ? chunked : [keys]; | ||
| 1455 | + } else { | ||
| 1456 | + pages.value = result; | ||
| 1457 | + } | ||
| 1458 | +}; | ||
| 1459 | + | ||
| 1460 | +// 上一页 | ||
| 1461 | +const handlePrev = async () => { | ||
| 1462 | + if (current_page_index.value === 0) return; | ||
| 1463 | + current_page_index.value -= 1; | ||
| 1464 | + image_uploader.value = []; | ||
| 1465 | + file_uploader.value = []; | ||
| 1466 | + table_editor.value = []; | ||
| 1467 | + await nextTick(); | ||
| 1468 | +}; | ||
| 1469 | + | ||
| 1470 | +// 校验当前页 | ||
| 1471 | +const validateCurrentPage = async () => { | ||
| 1472 | + try { | ||
| 1473 | + await myForm.value.validate(); | ||
| 1474 | + } catch (e) { | ||
| 1475 | + const err = Array.isArray(e?.errors) ? e.errors[0] : null; | ||
| 1476 | + const name = err?.name || ''; | ||
| 1477 | + let error_label = ''; | ||
| 1478 | + formData.value.forEach(item => { if (item.key === name) { error_label = item.component_props?.label || name; } }); | ||
| 1479 | + const msg = err?.message || '验证失败'; | ||
| 1480 | + showToast((error_label || '表单') + ': ' + msg); | ||
| 1481 | + return false; | ||
| 1482 | + } | ||
| 1483 | + const other = validOther(); | ||
| 1484 | + if (!other.status) { | ||
| 1485 | + showToast('验证失败'); | ||
| 1486 | + return false; | ||
| 1487 | + } | ||
| 1488 | + return true; | ||
| 1489 | +}; | ||
| 1490 | + | ||
| 1491 | +// 下一页 | ||
| 1492 | +const handleNext = async () => { | ||
| 1493 | + const ok = await validateCurrentPage(); | ||
| 1494 | + if (!ok) return; | ||
| 1495 | + if (current_page_index.value < pages.value.length - 1) { | ||
| 1496 | + current_page_index.value += 1; | ||
| 1497 | + image_uploader.value = []; | ||
| 1498 | + file_uploader.value = []; | ||
| 1499 | + table_editor.value = []; | ||
| 1500 | + await nextTick(); | ||
| 1501 | + window.scrollTo({ top: 0 }); | ||
| 1502 | + } | ||
| 1503 | +}; | ||
| 1504 | + | ||
| 1505 | +// 最后一页提交 | ||
| 1506 | +const handleSubmit = () => { | ||
| 1507 | + myForm.value.submit(); | ||
| 1508 | +}; | ||
| 1411 | </script> | 1509 | </script> |
| 1412 | 1510 | ||
| 1413 | <style lang="less"> | 1511 | <style lang="less"> | ... | ... |
-
Please register or login to post a comment