feat: 新增快速预定页面

This commit is contained in:
duanshuwen
2025-10-18 15:44:48 +08:00
parent d71e63b666
commit 4b066626cf
13 changed files with 624 additions and 9 deletions

View File

@@ -0,0 +1,66 @@
<template>
<view
class="card bg-white border-box p-8 rounded-12 flex flex-items-start m-12"
@click="handleClick(item)"
>
<image
class="left rounded-10"
:src="item.commodityIcon"
mode="aspectFill"
/>
<view class="right border-box flex-full pl-12">
<view class="font-size-16 line-height-24 color-171717 mb-4">
{{ item.commodityName }}
</view>
<view class="font-size-12 line-height-16 color-99A0AE mb-4">
1 1.8 米大床
</view>
<view class="font-size-12 line-height-18 color-43669A">
{{ item.commodityTradeRuleList.join(" / ") }}
</view>
<view class="flex flex-items-center flex-justify-end">
<text
class="amt font-size-18 font-500 font-family-misans-vf line-height-24 color-FF3D60 mr-4"
>
{{ item.commodityPrice }}
</text>
<text class="font-size-12 line-height-16 color-99A0AE">
/{{ item.stockUnitLabel }}
</text>
<text class="btn border-box rounded-10 color-white ml-16"></text>
</view>
</view>
</view>
</template>
<script setup>
import { defineProps } from "vue";
// Props
const props = defineProps({
item: {
type: Object,
required: true,
default: () => ({
commodityIcon: "",
commodityId: "",
commodityName: "",
commodityPrice: "",
commodityServices: [],
commodityTags: [], // 商品标签
commodityTradeRuleList: [], // 交易规则列表
specificationId: "", // 规格ID
stockUnitLabel: "", // 库存单位
}),
},
});
const handleClick = ({ commodityId }) => {
uni.navigateTo({ url: `/pages/goods/index?commodityId=${commodityId}` });
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,21 @@
.left {
height: 107px;
width: 80px;
}
.right {
height: 100%;
}
.amt {
&::before {
content: "¥";
font-size: 12px;
margin-right: 4px;
}
}
.btn {
background: linear-gradient(90deg, #ff3d60 57%, #ff990c 100%);
padding: 4px 8px;
}

View File

@@ -0,0 +1,276 @@
<template>
<view class="tab-container relative">
<view class="tab-wrapper flex flex-items-center flex-justify-center">
<view
v-for="(item, index) in tabList"
:key="index"
:class="[
'tab-item flex flex-full flex-items-center flex-justify-center relative',
activeIndex === index && 'tab-item-active',
]"
@click="handleTabClick(index)"
>
<text
:class="[
'font-size-16 font-500 color-525866 absolute',
activeIndex === index && 'tab-text-active',
]"
>
{{ item.label }}
</text>
</view>
</view>
<!-- 下划线指示器 -->
<view
:class="[
'tab-indicator absolute',
indicatorAnimating && 'animating',
indicatorInitialized && 'initialized',
]"
:style="{
left: indicatorLeft + 'px',
width: indicatorWidth + 'px',
}"
></view>
</view>
</template>
<script setup>
import {
ref,
reactive,
onMounted,
nextTick,
watch,
getCurrentInstance,
} from "vue";
// 获取组件实例
const instance = getCurrentInstance();
// Props
const props = defineProps({
tabs: {
type: Array,
default: () => [
{ label: "客房", value: "0" },
{ label: "门票", value: "1" },
{ label: "餐食", value: "2" },
{ label: "套餐", value: "3" },
],
},
defaultActive: {
type: Number,
default: 0,
},
indicatorColor: {
type: String,
default: "#007AFF",
},
});
// Emits
const emit = defineEmits(["change", "update:modelValue"]);
// 响应式数据
const activeIndex = ref(props.defaultActive);
const tabList = ref(props.tabs);
const indicatorLeft = ref(0);
const indicatorWidth = ref(0);
const tabItemRects = reactive([]);
const isUpdating = ref(false);
const indicatorAnimating = ref(false);
const indicatorInitialized = ref(false);
// 处理Tab点击
const handleTabClick = (index) => {
if (activeIndex.value === index) return;
activeIndex.value = index;
indicatorAnimating.value = true;
updateIndicator();
// 动画结束后移除动画类
setTimeout(() => {
indicatorAnimating.value = false;
}, 300);
emit("change", {
index,
item: tabList.value[index],
});
emit("update:modelValue", index);
};
// 更新指示器位置
const updateIndicator = async () => {
if (isUpdating.value) return;
isUpdating.value = true;
await nextTick();
// 检查实例是否存在
if (!instance) {
console.warn("Component instance not available");
isUpdating.value = false;
return;
}
// 检查 uni.createSelectorQuery 是否可用
if (!uni || !uni.createSelectorQuery) {
console.warn("uni.createSelectorQuery not available");
isUpdating.value = false;
return;
}
const query = uni.createSelectorQuery().in(instance);
// 同时获取tab项和容器的位置信息
query.selectAll(".tab-item").boundingClientRect();
query.select(".tab-wrapper").boundingClientRect();
query.exec((res) => {
try {
const [tabRects, wrapperRect] = res || [];
if (tabRects && tabRects.length > 0 && wrapperRect) {
tabItemRects.splice(0, tabItemRects.length, ...tabRects);
const activeRect = tabRects[activeIndex.value];
if (activeRect) {
// 计算相对于容器的位置,居中显示
const tabCenter =
activeRect.left - wrapperRect.left + activeRect.width / 2;
indicatorLeft.value = tabCenter - 10; // 15px宽度的一半
// 固定宽度15px不动态计算宽度
indicatorWidth.value = 20;
// 标记为已初始化
if (!indicatorInitialized.value) {
indicatorInitialized.value = true;
}
}
} else {
console.warn("Failed to get tab rects or wrapper rect");
}
} catch (error) {
console.error("Error in updateIndicator exec:", error);
} finally {
isUpdating.value = false;
}
});
};
// 监听activeIndex变化
watch(
() => activeIndex.value,
() => {
// 如果是初始化阶段使用initIndicator
if (indicatorLeft.value === 0 && indicatorWidth.value === 0) {
initIndicator();
} else {
updateIndicator();
}
}
);
// 监听tabs变化
watch(
() => props.tabs,
(newTabs) => {
tabList.value = newTabs;
// 重置初始化状态
indicatorInitialized.value = false;
// 重新初始化指示器
initIndicator();
},
{ deep: true }
);
// 监听defaultActive变化
watch(
() => props.defaultActive,
(newActive) => {
if (newActive !== activeIndex.value) {
activeIndex.value = newActive;
// 重置初始化状态
indicatorInitialized.value = false;
initIndicator();
}
}
);
// 初始化指示器
const initIndicator = async (retryCount = 0) => {
// 等待DOM完全渲染
await nextTick();
// 延迟一帧确保布局完成
setTimeout(() => {
// 检查实例是否存在
if (!instance) {
console.warn("Component instance not available in initIndicator");
return;
}
// 检查 uni.createSelectorQuery 是否可用
if (!uni || !uni.createSelectorQuery) {
console.warn("uni.createSelectorQuery not available in initIndicator");
return;
}
const query = uni.createSelectorQuery().in(instance);
query.selectAll(".tab-item").boundingClientRect();
query.select(".tab-wrapper").boundingClientRect();
query.exec((res) => {
try {
const [tabRects, wrapperRect] = res || [];
// 如果DOM元素还未准备好重试
if (
(!tabRects || tabRects.length === 0 || !wrapperRect) &&
retryCount < 3
) {
setTimeout(() => {
initIndicator(retryCount + 1);
}, 100);
return;
}
// 执行正常的更新逻辑
updateIndicator();
} catch (error) {
console.error("Error in initIndicator exec:", error);
// 如果出错且还有重试次数,尝试重试
if (retryCount < 3) {
setTimeout(() => {
initIndicator(retryCount + 1);
}, 100);
}
}
});
}, 50);
};
// 组件挂载后初始化指示器
onMounted(() => {
initIndicator();
});
// 暴露方法
defineExpose({
setActiveIndex: (index) => {
if (index >= 0 && index < tabList.value.length) {
handleTabClick(index);
}
},
getActiveIndex: () => activeIndex.value,
getActiveItem: () => tabList.value[activeIndex.value],
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,67 @@
.tab-wrapper {
background-color: #d9eeff;
height: 48px;
}
.tab-item {
height: 100%;
}
.tab-item-active {
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: #fff;
border-radius: 20px 20px 0 0;
transform: perspective(40px) rotateX(6deg) translate(0, -1px);
transform-origin: bottom bottom;
box-shadow: 0 -0.5px 0 #2d91ff;
}
}
.tab-text-active {
color: #2d91ff;
z-index: 3;
}
.tab-indicator {
bottom: 0;
height: 3px;
background-color: #2d91ff;
border-radius: 4px 4px 0 0;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 3;
transform: translateZ(0); /* 启用硬件加速 */
will-change: left, width; /* 优化动画性能 */
/* 初始状态:未初始化时隐藏 */
opacity: 0;
width: 20px; /* 默认宽度15px */
left: 0;
}
/* 已初始化状态 */
.tab-indicator.initialized {
opacity: 1;
}
/* 动画增强 */
@keyframes tabSwitch {
0% {
transform: translateZ(0) scaleX(0.8);
opacity: 0.6;
}
100% {
transform: translateZ(0) scaleX(1);
opacity: 1;
}
}
.tab-indicator.animating {
animation: tabSwitch 0.3s ease-out;
}

118
src/pages-quick/list.vue Normal file
View File

@@ -0,0 +1,118 @@
<template>
<z-paging
ref="paging"
v-model="dataList"
use-virtual-list
:force-close-inner-list="true"
cell-height-mode="dynamic"
safe-area-inset-bottom
@query="queryList"
>
<template #top>
<TopNavBar title="快速预定" />
<header>
<Tabs @change="handleTabChange" />
<!-- 选择入住离店日期 -->
<view class="bg-white border-box flex flex-items-center p-12">
<view class="in flex flex-items-center">
<text class="font-size-11 font-500 color-99A0AE mr-4">入住</text>
<text class="font-size-14 font-500 color-171717">
{{ DateUtils.formatDate() }}
</text>
</view>
<!-- 几晚 -->
<text
class="nights bg-E5E8EE border-box font-size-11 font-500 color-525866 rounded-50 ml-8 mr-8"
>
{{ 1 }}
</text>
<view class="out flex flex-items-center">
<text class="font-size-11 font-500 color-99A0AE mr-4">离店</text>
<text class="font-size-14 font-500 color-171717">
{{ DateUtils.formatDate() }}
</text>
</view>
<!-- 日期图标 -->
<uni-icons
class="ml-auto"
type="calendar"
size="24"
color="#2D91FF"
@click="calendarVisible = true"
/>
</view>
</header>
</template>
<Card v-for="(item, index) in dataList" :key="index" :item="item" />
</z-paging>
<!-- 日历组件 -->
<Calender
:visible="calendarVisible"
mode="single"
:default-value="selectedDate"
@close="handleCalendarClose"
@select="handleDateSelect"
/>
</template>
<script setup>
import { ref } from "vue";
import TopNavBar from "@/components/TopNavBar/index.vue";
import Calender from "@/components/Calender/index.vue";
import Tabs from "./components/Tabs/index.vue";
import Card from "./components/Card/index.vue";
import { quickBookingComponent } from "@/request/api/MainPageDataApi";
import { DateUtils } from "@/utils";
const calendarVisible = ref(false);
const selectedDate = ref("");
const dataList = ref([]);
const paging = ref(null);
const queryList = async (pageNum = 1, pageSize = 10) => {
try {
const res = await quickBookingComponent(DateUtils.formatDate());
console.log("API响应:", res.data.commodityGroupDTOList);
if (res && res.data && res.data.commodityGroupDTOList) {
const records = res.data.commodityGroupDTOList[0].commodityList;
// 完成数据加载,第二个参数表示是否还有更多数据
paging.value.complete(records);
} else {
// 没有数据
paging.value.complete([]);
}
} catch (error) {
console.error("查询列表失败:", error);
// 加载失败
paging.value.complete(false);
}
};
const handleTabChange = ({ index, item }) => {};
// 处理日历关闭
const handleCalendarClose = () => {
calendarVisible.value = false;
};
// 处理日期选择
const handleDateSelect = (data) => {
selectedDate.value = data.date;
calendarVisible.value = false;
console.log("选择的日期:", data.date);
};
</script>
<style scoped lang="scss">
.nights {
padding: 3px 6px;
}
</style>