feat(quick-page, components): add pagination and pull refresh, refine UI and component logic

- add @lucide/vue dependency and replace van-icon with CalendarDays icon
- refactor TopNavBar to use vue-router back navigation instead of uni API
- update Card component styles for better visual appearance
- fix touch event formatting in CardSwiper and switch tap to click events
- major refactor of Calendar component: implement v-model:show, add exposed open/close methods, improve date handling logic
- add TypeScript types and update vant component declarations
- add pull-to-refresh and pagination support to quick booking page
This commit is contained in:
duanshuwen
2026-05-31 19:46:36 +08:00
parent 5064d7b444
commit 83279ca9bd
8 changed files with 192 additions and 139 deletions

4
components.d.ts vendored
View File

@@ -50,7 +50,9 @@ declare module 'vue' {
VanCheckbox: typeof import('vant/es')['Checkbox']
VanIcon: typeof import('vant/es')['Icon']
VanIcons: typeof import('vant/es')['Icons']
VanList: typeof import('vant/es')['List']
VanPopup: typeof import('vant/es')['Popup']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanSwipe: typeof import('vant/es')['Swipe']
VanSwipeItem: typeof import('vant/es')['SwipeItem']
VanSwiperItem: typeof import('vant/es')['SwiperItem']
@@ -98,7 +100,9 @@ declare global {
const VanCheckbox: typeof import('vant/es')['Checkbox']
const VanIcon: typeof import('vant/es')['Icon']
const VanIcons: typeof import('vant/es')['Icons']
const VanList: typeof import('vant/es')['List']
const VanPopup: typeof import('vant/es')['Popup']
const VanPullRefresh: typeof import('vant/es')['PullRefresh']
const VanSwipe: typeof import('vant/es')['Swipe']
const VanSwipeItem: typeof import('vant/es')['SwipeItem']
const VanSwiperItem: typeof import('vant/es')['SwiperItem']

View File

@@ -11,6 +11,7 @@
"test": "node --test src/**/*.test.ts"
},
"dependencies": {
"@lucide/vue": "^1.17.0",
"axios": "^1.16.1",
"mitt": "^3.0.1",
"pinia": "^3.0.3",

View File

@@ -1,5 +1,6 @@
<template>
<van-popup ref="popup" position="bottom" @maskClick="handleMaskClick">
<van-popup ref="popup" position="bottom" v-model:show="show" style="background: transparent;"
@click-overlay="handleClose">
<!-- 弹窗主体 -->
<div class="relative w-full bg-white rounded-[12px] overflow-hidden">
<!-- 头部区域 -->
@@ -27,20 +28,20 @@
<!-- 全年月份显示区域 -->
<div class="flex flex-col gap-[32px]">
<div class="flex flex-col" v-for="monthData in yearMonthsGrid" :key="monthData.key">
<span class="text-[18px] font-semibold text-[#262626] text-center mb-4 mt-2 leading-[1.4]">{{
<span class="text-[18px] font-medium text-[#262626] text-center mb-4 mt-2 leading-[1.4]">{{
monthData.title }}</span>
<div class="grid grid-cols-7 gap-[4px]">
<div v-for="(dateInfo, index) in monthData.grid" :key="index" :class="getDateCellClass(dateInfo)"
@tap="handleDateClick(dateInfo)">
@click="handleDateClick(dateInfo)">
<template v-if="dateInfo">
<span v-if="dateInfo.label"
class="text-[10px] px-1 py-[1px] rounded-[2px] whitespace-nowrap font-medium text-center min-h-[12px]"
class="text-[10px] leading-[12px] px-1 rounded-[2px] whitespace-nowrap font-medium text-center"
:class="getDateLabelClass(dateInfo)">
{{ dateInfo.label }}
</span>
<span class="text-[16px] font-medium leading-none flex-1 flex items-center justify-center"
<span class="text-[16px] font-medium leading-[18px] flex items-center justify-center"
:class="getDateNumberClass(dateInfo)">{{ dateInfo.day }}</span>
<span class="text-[12px] leading-none font-normal text-center min-h-[14px]"
<span v-if="formatPrice(dateInfo.price)" class="text-[12px] leading-[14px] font-normal text-center"
:class="getDatePriceClass(dateInfo)">{{ formatPrice(dateInfo.price) }}</span>
</template>
</div>
@@ -58,7 +59,6 @@ import {
reactive,
computed,
watch,
onMounted,
onBeforeUnmount,
} from "vue";
@@ -69,6 +69,7 @@ defineOptions({
// van-popup组件引用
const popup = ref(null);
const show = ref(false);
// 定义Props
const props = defineProps({
@@ -76,7 +77,6 @@ const props = defineProps({
visible: {
type: Boolean,
default: false,
required: true,
},
// 价格数据数组
@@ -88,7 +88,7 @@ const props = defineProps({
// 默认选中日期
defaultValue: {
type: [String, Array],
type: [String, Array, Object],
default: "",
},
@@ -173,20 +173,6 @@ const yearMonthsGrid = computed(() => {
return months;
});
// 方法定义
// 处理遮罩点击
const handleMaskClick = () => {
handleClose();
};
// 处理关闭
const handleClose = () => {
if (popup.value) {
popup.value.close();
}
emit("close");
};
// 获取月份天数
const getDaysInMonth = (year, month) => {
return new Date(year, month, 0).getDate();
@@ -330,21 +316,21 @@ const getDateCellClass = (dateInfo) => {
if (!dateInfo) return "w-[40px] h-[40px] bg-transparent cursor-default";
const base =
"relative w-[40px] h-[40px] flex flex-col items-center justify-between p-[2px] rounded-[6px] transition-all duration-200 ease-in-out cursor-pointer bg-white border border-transparent hover:bg-[#f5f5f5] active:scale-[0.95]";
"relative w-[40px] h-[40px] flex flex-col items-center justify-center gap-[2px] p-[2px] rounded-[6px] transition-all duration-200 ease-in-out cursor-pointer border active:scale-[0.95]";
if (dateInfo.disabled) {
return `${base} bg-[#f5f5f5] text-[#bfbfbf] cursor-not-allowed hover:bg-[#f5f5f5] active:scale-100`;
return `${base} border-transparent bg-[#f5f5f5] text-[#bfbfbf] cursor-not-allowed active:scale-100`;
}
if (dateInfo.selected) {
return `${base} bg-[#1890ff] border-[#1890ff] hover:bg-[#1890ff]`;
return `${base} border-[#1989fa] bg-[#1989fa]`;
}
if (dateInfo.inRange) {
return `${base} bg-[#e6f7ff] border-[#e6f7ff]`;
return `${base} border-[#e6f4ff] bg-[#e6f4ff]`;
}
return base;
return `${base} border-transparent bg-white hover:bg-[#f5f5f5]`;
};
const formatPrice = (price) => {
@@ -577,35 +563,53 @@ const calculateDaysBetween = (startDate, endDate) => {
return days === 0 ? 1 : days;
};
// 监听visible属性变化控制van-popup显示
watch(
() => props.visible,
(newVisible) => {
if (newVisible) {
popup.value?.open();
} else {
popup.value?.close();
}
},
{ immediate: true },
);
const syncDefaultValue = (value) => {
selectedDates.value = [];
rangeStart.value = null;
rangeEnd.value = null;
// 生命周期钩子
onMounted(() => {
// 初始化选中状态
if (props.defaultValue) {
if (Array.isArray(props.defaultValue)) {
rangeStart.value = props.defaultValue[0];
rangeEnd.value = props.defaultValue[1];
} else {
selectedDates.value = [props.defaultValue];
}
if (!value) return;
if (Array.isArray(value)) {
rangeStart.value = value[0] || null;
rangeEnd.value = value[1] || null;
return;
}
});
if (typeof value === "object") {
rangeStart.value = value.startDate || null;
rangeEnd.value = value.endDate || null;
return;
}
if (props.mode === "range") {
rangeStart.value = value;
return;
}
selectedDates.value = [value];
};
watch(
() => props.defaultValue,
(newValue) => {
syncDefaultValue(newValue);
},
{ immediate: true, deep: true },
);
onBeforeUnmount(() => {
if (clickTimer.value) {
clearTimeout(clickTimer.value);
}
});
const open = () => show.value = true;
const close = () => show.value = false;
const handleClose = () => {
close();
emit("close");
};
defineExpose({ open, close });
</script>

View File

@@ -29,7 +29,10 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from 'vue-router'
const router = useRouter()
// 定义props
const props = defineProps({
// 标题文本
@@ -80,26 +83,10 @@ const props = defineProps({
},
});
// 定义emits
const emit = defineEmits(["back"]);
// 系统信息
const statusBarHeight = ref(0);
const navBarHeight = ref(44); // 默认导航栏高度
// 获取系统信息
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
statusBarHeight.value = systemInfo.statusBarHeight || 0;
// 根据平台设置导航栏高度
if (systemInfo.platform === "ios") {
navBarHeight.value = 44;
} else {
navBarHeight.value = 48;
}
});
// 计算导航栏样式类
const navBarClass = computed(() => {
return [
@@ -120,12 +107,6 @@ const navBarStyle = computed(() => {
// 处理返回事件
const handleBack = () => {
emit("back");
// 如果没有监听back事件默认执行返回上一页
if (!emit("back")) {
uni.navigateBack({
delta: 1,
});
}
router.back()
};
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div class="w-full" @touchstart="handleTouchStart" @touchmove="handleTouchMove"
@touchend="handleTouchEnd" @touchcancel="handleTouchCancel">
<div class="w-full" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd"
@touchcancel="handleTouchCancel">
<div :class="['relative w-full h-[270px] overflow-hidden', list.length <= 1 ? 'overflow-visible' : '']">
<div v-for="slot in renderSlots" :key="slot.key"
class="absolute left-1/2 top-2 w-[236px] h-[234px] max-w-[calc(100%-56px)] origin-center [will-change:transform,opacity]"
:style="slot.style" @tap="handleCardTap(slot)">
:style="slot.style" @click="handleCardTap(slot)">
<div
:class="['relative w-full h-full p-2 overflow-hidden rounded-3xl bg-white', slot.role === 'current' ? '[box-shadow:0_12px_20px_rgba(15,23,42,0.18)]' : '[box-shadow:0_12px_20px_rgba(15,23,42,0.14)]']">
<div class="w-full h-[142px] m-0 overflow-hidden rounded-[20px]">

View File

@@ -13,9 +13,9 @@
</div>
<div class="flex items-center justify-end">
<div class="mr-[4px]">
<div class="mr-[4px] text-[#FF3D60]">
<span class="text-[12px] mr-[4px]">¥</span>
<span class="text-[18px] font-semibold font-family-misans-vf leading-[24px] text-[#FF3D60]">
<span class="text-[18px] font-medium font-family-misans-vf leading-[24px]">
{{ item.specificationPrice }}
</span>
</div>
@@ -23,7 +23,7 @@
/{{ item.stockUnitLabel }}
</span>
<span
class="p-[4px_8px] bg-linear-[90deg, #ff3d60_57%, #ff990c_100%] rounded-[10px] text-white ml-[16px]"></span>
class="ml-[16px] rounded-[10px] px-[8px] py-[4px] text-white [background:linear-gradient(90deg,#ff3d60_57%,#ff990c_100%)]"></span>
</div>
</div>
</div>

View File

@@ -1,45 +1,49 @@
<template>
<div>
<div class="flex h-dvh flex-col overflow-hidden">
<TopNavBar title="快速预定" />
<Tabs @change="handleTabChange" />
<div>
<!-- 选择入住离店日期 0:是酒店 -->
<div v-if="didSelectedTabItem && didSelectedTabItem.orderType == 0" class="bg-white flex items-center p-[12px]">
<div class="in flex items-center">
<span class="text-[11px] font-medium text-ink-400 mr-[4px]">入住</span>
<span class="text-[14px] font-medium text-[#171717]">
{{ selectedDate.startDate }}
<div class="min-h-0 flex-1 overflow-y-auto scrollbar-none [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<div v-if="didSelectedTabItem && didSelectedTabItem.orderType == 0"
class="bg-white flex items-center p-[12px]">
<div class="in flex items-center">
<span class="text-[11px] font-medium text-ink-400 mr-[4px]">入住</span>
<span class="text-[14px] font-medium text-[#171717]">
{{ selectedDate.startDate }}
</span>
</div>
<span
class="py-[3px] px-[6px] bg-[#E5E8EE] text-[11px] font-medium text-ink-600 rounded-[5px] ml-[8px] mr-[8px]">
{{ selectedDate.totalDays }}
</span>
<div class="out flex items-center">
<span class="text-[11px] font-medium text-ink-400 mr-[4px]">离店</span>
<span class="text-[14px] font-medium text-[#171717]">
{{ selectedDate.endDate }}
</span>
</div>
<CalendarDays class="ml-auto" color="#0CCD58" @click="handleCalendarClick" />
</div>
<!-- 几晚 -->
<span
class="py-[3px] px-[6px] bg-[#E5E8EE] text-[11px] font-medium text-ink-600 rounded-[5px] ml-[8px] mr-[8px]">
{{ selectedDate.totalDays }}
</span>
<div class="out flex items-center">
<span class="text-[11px] font-medium text-ink-400 mr-[4px]">离店</span>
<span class="text-[14px] font-medium text-[#171717]">
{{ selectedDate.endDate }}
</span>
</div>
<!-- 日期图标 -->
<van-icon class="ml-auto" type="calendar" size="24" color="#0CCD58" @click="calendarVisible = true" />
</div>
<Card v-for="(item, index) in dataList" :key="index" :item="item" :selectedDate="selectedDate" />
<van-list v-model:loading="loading" v-model:error="listError" :finished="finished" :immediate-check="false"
finished-text="没有更多了" error-text="加载失败点击重试" @load="onLoad">
<Card v-for="(item, index) in dataList" :key="index" :item="item" :selectedDate="selectedDate" />
</van-list>
</van-pull-refresh>
</div>
<!-- 日历组件 -->
<Calender :visible="calendarVisible" mode="range" :default-value="selectedDate" @close="handleCalendarClose"
<Calender ref="calenderRef" mode="range" :default-value="selectedDate" @close="handleCalendarClose"
@range-select="handleDateSelect" />
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { CalendarDays } from '@lucide/vue';
import TopNavBar from "@/components/TopNavBar/index.vue";
import Calender from "@/components/Calender/index.vue";
import Tabs from "./components/Tabs/index.vue";
@@ -47,24 +51,50 @@ import Card from "./components/Card/index.vue";
import { quickBookingList } from "@/api/goods";
import { DateUtils } from "@/utils/dateUtils";
const calendarVisible = ref(false);
const selectedDate = ref({
startDate: DateUtils.formatDate(), // 当天日期
endDate: DateUtils.formatDate(new Date(Date.now() + 24 * 60 * 60 * 1000)), // 第二天日期
type SelectedDate = {
startDate: string;
endDate: string;
totalDays: number;
};
type QuickTabItem = {
typeCode?: string;
orderType?: number | string;
};
type QuickListItem = Record<string, any>;
const PAGE_SIZE = 10;
const calenderRef = ref();
const selectedDate = ref<SelectedDate>({
startDate: DateUtils.formatDate(),
endDate: DateUtils.formatDate(new Date(Date.now() + 24 * 60 * 60 * 1000)),
totalDays: 1,
});
const dataList = ref([]);
const paging = ref(null);
// 当前选中项
const didSelectedTabItem = ref(null);
const dataList = ref<QuickListItem[]>([]);
const currentPage = ref(1);
const loading = ref(false);
const finished = ref(false);
const refreshing = ref(false);
const listError = ref(false);
const didSelectedTabItem = ref<QuickTabItem | null>(null);
const queryList = async (pageNum = 1, pageSize = 10) => {
const finishLoading = () => {
loading.value = false;
refreshing.value = false;
};
const queryList = async (pageNum = currentPage.value, pageSize = PAGE_SIZE) => {
if (!didSelectedTabItem.value) {
paging.value.complete([]);
dataList.value = [];
finished.value = true;
listError.value = false;
finishLoading();
return;
}
try {
const params = {
const params: Record<string, any> = {
commodityTypeCode: didSelectedTabItem.value.typeCode,
size: pageSize,
current: pageNum,
@@ -76,40 +106,68 @@ const queryList = async (pageNum = 1, pageSize = 10) => {
}
const res = await quickBookingList(params);
console.log("API响应:", res.data.records);
const records = Array.isArray(res?.data?.records) ? res.data.records : [];
const total = Number(res?.data?.total ?? 0);
const hasTotal = Number.isFinite(total) && total > 0;
if (res && res.data && res.data.records) {
const records = res.data.records;
// 完成数据加载,第二个参数表示是否还有更多数据
paging.value.complete(records);
if (pageNum === 1) {
dataList.value = records;
} else {
// 没有数据
paging.value.complete([]);
dataList.value = [...dataList.value, ...records];
}
finished.value =
records.length < pageSize || (hasTotal && dataList.value.length >= total);
if (!finished.value) {
currentPage.value = pageNum + 1;
}
listError.value = false;
} catch (error) {
console.error("查询列表失败:", error);
// 加载失败
paging.value.complete(false);
listError.value = true;
} finally {
finishLoading();
}
};
const handleTabChange = ({ item }) => {
console.log("选中的tab item: ", item);
const reloadList = () => {
currentPage.value = 1;
finished.value = false;
listError.value = false;
loading.value = true;
queryList(1);
};
const onLoad = () => {
queryList();
};
const onRefresh = () => {
currentPage.value = 1;
finished.value = false;
listError.value = false;
loading.value = true;
queryList(1);
};
const handleTabChange = ({ item }: { item: QuickTabItem }) => {
didSelectedTabItem.value = item;
paging.value.reload();
dataList.value = [];
reloadList();
};
// 处理日历关闭
const handleCalendarClose = () => {
calendarVisible.value = false;
calenderRef.value.close();
};
// 处理日期选择
const handleDateSelect = (data) => {
const handleDateSelect = (data: SelectedDate) => {
selectedDate.value = data;
calendarVisible.value = false;
console.log("选择的日期:", data);
paging.value.reload();
calenderRef.value?.close?.();
dataList.value = [];
reloadList();
};
const handleCalendarClick = () => {
calenderRef.value.open();
};
</script>

View File

@@ -1039,6 +1039,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@lucide/vue@^1.17.0":
version "1.17.0"
resolved "https://registry.npmmirror.com/@lucide/vue/-/vue-1.17.0.tgz#63d2a7e61f6fb180bdcbaf0446a81aaffd995482"
integrity sha512-6Q1ZHgr5FbmJzKWe5BxlNdjLj2lbmuH1zwDtVzUJofX0w9UREwKgq4F4jwKqFYyyIS4Rj3FiJvDi2k6djukmmw==
"@napi-rs/wasm-runtime@^1.1.4":
version "1.1.4"
resolved "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"