hookehuyr

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

- 添加 PaginationField 分页组件,支持上一页/下一页/提交按钮控制
- 在 index.vue 中实现分页逻辑,包括分页构建、当前页字段过滤和校验
- 修改表单渲染逻辑,仅显示当前页字段
- 添加分页切换时的校验功能,确保数据有效性
## 目标
-`src/components/PaginationField/index.vue` 新增分页组件,控制当前页的字段显示/隐藏。
- 首页仅显示“下一页”,中间页显示“上一页/下一页”,最后一页显示“上一页/提交”。
- 切页前主动校验当前页字段;最后页点击“提交”调用现有 `van-form``onSubmit``src/views/index.vue:26`)。
## 总体思路
- 在父页面 `src/views/index.vue` 中引入分页组件,基于“页配置”过滤 `formData`,使 `v-for` 仅渲染当前页字段(`src/views/index.vue:28-29`)。
- 校验采用 Vant Form 校验能力:仅渲染的字段会被 `myForm.validate()` 校验;自定义组件使用现有 `validOther()` 进行补充校验。
- 提交沿用现有 `myForm.submit()``onSubmit` 全流程逻辑,无需改接口与数据流。
## 关键点
- 字段显示/隐藏:通过“当前页的字段 key 列表”过滤 `formData`,只渲染当前页字段;规则隐藏(`checkRules`)仍生效,提交时继续移除 `disabled` 字段。
- 切页校验:点击“下一页”先调用 `myForm.validate()` + `validOther()`,不通过则提示并停留该页;通过才切页。
- 提交触发:最后一页点击“提交”直接 `myForm.submit()`,沿用现有 `onSubmit` 分支处理。
## 组件设计(PaginationField)
- Props:`current`(当前页索引)、`total`(总页数)、`isLast`(是否最后页)。
- Emits:`prev``next``submit`
- UI:底部固定区域,首页仅“下一页”,中间页“上一页/下一页”,最后页“上一页/提交”。样式用 less,遵循项目风格。
## 页面改动(index.vue)
- 引入并放置分页组件:在 `van-form` 的字段列表之后、提交按钮之前插入 `PaginationField`;当启用分页时隐藏原提交按钮(非流程版 `PCommit.visible` 处)。
- 计算当前页可见字段:
- 维护 `pages`:数组形式,每页是一组字段 `key`
- 维护 `current_page_index`;计算 `visible_keys``visible_form_data = formData.filter(key ∈ visible_keys)`
- 将现有 `v-for` 的数据源换为 `visible_form_data`(对应 `src/views/index.vue:28-29`)。
## 校验逻辑
- 收集当前页渲染的字段 `name`(动态组件普遍设置 `item.name = item.key`,参见 `src/hooks/useComponentType.js:32-214`,以及各组件中的 `van-field :name`)。
- 点击“下一页”:
- 执行 `myForm.validate()`(仅当前页渲染字段会被校验)。
- 执行 `validOther()` 校验图片/文件/表格等自定义组件(当前页渲染的才在 ref 列表内)。
- 任一失败:根据 `onFailed` 提示规则,Toast 显示第一个错误并阻止切页(`src/views/index.vue:1303-1330`)。
- 点击“提交”:直接 `myForm.submit()`,触发既有 `onSubmit` 流程(`src/views/index.vue:1141-1301`)。
## Mock 分页策略(后端未定)
- 优先按“分割线组件 divider”自动分页:遇到 `DividerField` 开始新页;若无,则按数量(如 5~8 个字段/页)进行等分。
- `pages` 结构为 `[ ['field_a','field_b',...], ['field_x',...], ... ]`,后续可由后端下发或用户手动配置覆盖。
## 兼容与边界
- 规则隐藏:`checkRules()` 仍会设置 `disabled`,过滤时排除 `disabled` 字段以避免误校验与误提交。
- 流程页(`formSetting.is_flow`)不受影响:分页仅控制字段渲染,提交行为沿用现有流程分支;如有需要可在流程页也显示分页按钮,最终依然走 `myForm.submit()`
- 历史数据与 Cookie 回填:不影响分页,仍在挂载阶段写入默认值(`src/views/index.vue:480-540`)。
## 实施步骤
1. 完成 `PaginationField` 组件开发(props/emits/按钮/less)。
2.`index.vue` 引入组件,新增 `pages``current_page_index` 状态,改造 `v-for``visible_form_data`
3. 实现 `handlePrev/handleNext/handleSubmit`,接入 `myForm.validate()` + `validOther()` + `myForm.submit()`
4. 当启用分页时隐藏原“提交”按钮(保留流程提交按钮逻辑)。
## 验收
- 首页仅“下一页”,中间页“上一页/下一页”,最后页“上一页/提交”。
- 当前页校验失败时 Toast 提示并停留该页;通过时切页。
- 最后一页点击“提交”走既有 `onSubmit` 流程,提交成功行为不变。
<!--
* @Date: 2025-11-18 16:17:40
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-18 16:48:02
* @FilePath: /data-table/src/components/PaginationField/index.vue
* @Description: 分页组件
-->
<template>
<div class="pagination-field">
<div class="indicator">第{{ current + 1 }}页 / 共{{ total }}页</div>
<div class="actions">
<van-button v-if="showPrev" round type="primary" class="btn" @click="onPrev">上一页</van-button>
<van-button v-if="showNext" round type="primary" class="btn" @click="onNext">下一页</van-button>
<van-button v-if="showSubmit" round type="primary" class="btn" @click="onSubmit">提交</van-button>
</div>
</div>
<div class="placeholder" />
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
current: { type: Number, default: 0 },
total: { type: Number, default: 1 },
isLast: { type: Boolean, default: false },
})
const emit = defineEmits(['prev', 'next', 'submit'])
const showPrev = computed(() => props.current > 0)
const showNext = computed(() => props.current < props.total - 1)
const showSubmit = computed(() => props.current === props.total - 1)
const onPrev = () => emit('prev')
const onNext = () => emit('next')
const onSubmit = () => emit('submit')
</script>
<style lang="less" scoped>
.pagination-field {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 0.75rem 1rem;
border-top: 1px solid #eaeaea;
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
.indicator {
font-size: 0.85rem;
color: #666;
margin-bottom: 0.5rem;
}
.actions {
display: flex;
gap: 0.75rem;
.btn {
min-width: 6rem;
}
}
}
.placeholder {
height: 3.75rem;
}
</style>
<!--
* @Date: 2022-07-18 10:22:22
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-01 15:41:31
* @LastEditTime: 2025-11-18 17:09:46
* @FilePath: /data-table/src/views/index.vue
* @Description: 首页
-->
......@@ -25,11 +25,12 @@
<van-config-provider :theme-vars="themeVars">
<van-form ref="myForm" @submit="onSubmit" @failed="onFailed" :scroll-to-error="true">
<van-cell-group :border="false">
<component v-for="(item, index) in formData" :id="item.key" :ref="(el) => setRefMap(el, item)" :key="index"
<component v-for="(item, index) in visible_form_data" :id="item.key" :ref="(el) => setRefMap(el, item)" :key="index"
:is="item.component" :item="item" @active="onActive" @remove="onRemove" @blur="onBlur" />
</van-cell-group>
<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" />
<!-- 非流程版表单 -->
<div v-if="formData.length && PCommit.visible && !formSetting.is_flow" style="margin: 16px">
<div v-if="formData.length && PCommit.visible && !formSetting.is_flow && !enable_pagination" style="margin: 16px">
<van-button round block type="primary" native-type="submit" :disabled="submitStatus">
{{ PCommit.text ? PCommit.text : '提交' }}
</van-button>
......@@ -204,6 +205,7 @@ import { styleColor } from "@/constant.js";
import { sharePage } from '@/composables/useShare.js'
import wx from 'weixin-js-sdk'
import LoginBox from '@/components/LoginBox/index.vue';
import PaginationField from '@/components/PaginationField/index.vue';
const $route = useRoute();
const $router = useRouter();
......@@ -238,6 +240,18 @@ const PCommit = ref({});
* 表单结构数据
*/
const formData = ref([]);
const pages = ref([]);
// 分页配置
const enable_pagination = ref(false);
const current_page_index = ref(0);
const visible_keys = computed(() => {
if (!pages.value.length) return formData.value.map(i => i.key);
return pages.value[current_page_index.value] || [];
});
const visible_form_data = computed(() => {
const set = new Set(visible_keys.value);
return formData.value.filter(i => set.has(i.key) && !i.component_props?.disabled);
});
/**
* 格式化表单数据
......@@ -476,6 +490,12 @@ onMounted(async () => {
// })
formData.value = formatData(page_form);
/**
* * TAG: 构建分页
*/
buildPages();
enable_pagination.value = pages.value.length > 1;
/**** END ****/
// TAG:获取原来表单数据
if (data_id) { // 如果有data_id,则获取历史数据 否则获取表单默认值
......@@ -1408,6 +1428,84 @@ const setVolunteerData = async (volunteer_phone) => {
});
}
}
// 构建分页
const buildPages = () => {
const result = [];
let cur = [];
formData.value.forEach(item => {
const tag = item.component_props?.tag;
// TODO: 分页组件标识暂时不确定
if (tag === 'divider1') {
if (cur.length) result.push(cur);
cur = [];
return;
}
cur.push(item.key);
});
if (cur.length) result.push(cur);
if (result.length <= 1) {
const size = 8;
const keys = formData.value.filter(i => i.component_props?.tag !== 'divider').map(i => i.key);
const chunked = [];
for (let i = 0; i < keys.length; i += size) {
chunked.push(keys.slice(i, i + size));
}
pages.value = chunked.length ? chunked : [keys];
} else {
pages.value = result;
}
};
// 上一页
const handlePrev = async () => {
if (current_page_index.value === 0) return;
current_page_index.value -= 1;
image_uploader.value = [];
file_uploader.value = [];
table_editor.value = [];
await nextTick();
};
// 校验当前页
const validateCurrentPage = async () => {
try {
await myForm.value.validate();
} catch (e) {
const err = Array.isArray(e?.errors) ? e.errors[0] : null;
const name = err?.name || '';
let error_label = '';
formData.value.forEach(item => { if (item.key === name) { error_label = item.component_props?.label || name; } });
const msg = err?.message || '验证失败';
showToast((error_label || '表单') + ': ' + msg);
return false;
}
const other = validOther();
if (!other.status) {
showToast('验证失败');
return false;
}
return true;
};
// 下一页
const handleNext = async () => {
const ok = await validateCurrentPage();
if (!ok) return;
if (current_page_index.value < pages.value.length - 1) {
current_page_index.value += 1;
image_uploader.value = [];
file_uploader.value = [];
table_editor.value = [];
await nextTick();
window.scrollTo({ top: 0 });
}
};
// 最后一页提交
const handleSubmit = () => {
myForm.value.submit();
};
</script>
<style lang="less">
......