hookehuyr

feat(分页): 新增分页组件及分页功能实现

- 添加 PaginationField 分页组件,支持上一页/下一页/提交按钮控制
- 在 index.vue 中实现分页逻辑,包括分页构建、当前页字段过滤和校验
- 修改表单渲染逻辑,仅显示当前页字段
- 添加分页切换时的校验功能,确保数据有效性
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` 流程,提交成功行为不变。
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,则获取历史数据 否则获取表单默认值
...@@ -1302,17 +1322,17 @@ const onSubmit = async (values) => { // 表单提交回调 ...@@ -1302,17 +1322,17 @@ const onSubmit = async (values) => { // 表单提交回调
1302 1322
1303 const onFailed = (errorInfo) => { // 提交表单且验证不通过后触发 1323 const onFailed = (errorInfo) => { // 提交表单且验证不通过后触发
1304 console.log('表单验证失败:', errorInfo); // 添加调试信息 1324 console.log('表单验证失败:', errorInfo); // 添加调试信息
1305 - 1325 +
1306 // 检查是否有错误信息 1326 // 检查是否有错误信息
1307 if (!errorInfo || !errorInfo.errors || errorInfo.errors.length === 0) { 1327 if (!errorInfo || !errorInfo.errors || errorInfo.errors.length === 0) {
1308 console.warn('没有具体的错误信息'); 1328 console.warn('没有具体的错误信息');
1309 showToast('表单验证失败,请检查输入内容'); 1329 showToast('表单验证失败,请检查输入内容');
1310 return; 1330 return;
1311 } 1331 }
1312 - 1332 +
1313 const error_item = errorInfo.errors[0]; 1333 const error_item = errorInfo.errors[0];
1314 console.log('第一个错误项:', error_item); // 添加调试信息 1334 console.log('第一个错误项:', error_item); // 添加调试信息
1315 - 1335 +
1316 // 通过name找到对应的label 1336 // 通过name找到对应的label
1317 let error_name = ''; 1337 let error_name = '';
1318 formData.value.forEach(item => { 1338 formData.value.forEach(item => {
...@@ -1320,11 +1340,11 @@ const onFailed = (errorInfo) => { // 提交表单且验证不通过后触发 ...@@ -1320,11 +1340,11 @@ const onFailed = (errorInfo) => { // 提交表单且验证不通过后触发
1320 error_name = item.component_props.label 1340 error_name = item.component_props.label
1321 } 1341 }
1322 }); 1342 });
1323 - 1343 +
1324 // 确保有错误名称和消息 1344 // 确保有错误名称和消息
1325 const finalErrorName = error_name || error_item.name || '未知字段'; 1345 const finalErrorName = error_name || error_item.name || '未知字段';
1326 const finalErrorMessage = error_item.message || '验证失败'; 1346 const finalErrorMessage = error_item.message || '验证失败';
1327 - 1347 +
1328 console.log('显示错误提示:', finalErrorName + ': ' + finalErrorMessage); 1348 console.log('显示错误提示:', finalErrorName + ': ' + finalErrorMessage);
1329 showToast(finalErrorName + ': ' + finalErrorMessage); 1349 showToast(finalErrorName + ': ' + finalErrorMessage);
1330 } 1350 }
...@@ -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">
......