This commit is contained in:
dzc committed 2026-10-20 13:10:01 +08:00
commit 88fcff26be
4 files changed
+467 -6

No files matched your search

+76 -5
View File
@@ -52,13 +52,21 @@
<text class="ht-dates__val">{{ formatMd(query.checkOut) }}</text>
</view>
</tm-picker-date>
<view class="ht-dates__item">
<text class="ht-dates__label">共住</text>
<text class="ht-dates__val">{{ nightCount }}晚</text>
<view class="ht-dates__item" @tap="guestVisible = true">
<text class="ht-dates__label">{{ nightCount }}晚 · 间数人数</text>
<text class="ht-dates__val">{{ guestSummaryShort }}</text>
</view>
</view>
</view>
<hotel-guest-sheet
v-model="guestVisible"
:rooms="query.rooms"
:adults="query.adults"
:children="query.children"
@confirm="onGuestConfirm"
/>
<!-- 首页带入的城市 / 区域筛选:可一键清除 -->
<view v-if="destinationLabel" class="ht-dest">
<text class="ht-dest__lab">当前目的地</text>
@@ -276,7 +284,11 @@
@scrolltolower="loadMore"
>
<view class="ht-list">
<view v-if="state.loading && !state.list.length && !refreshing" class="ht-empty">加载中…</view>
<view v-if="state.loading && !state.list.length && !refreshing" class="ht-loading">
<tm-icon name="loader-4-line" :size="56" color="#1570ef" spin />
<text class="ht-loading__title">正在努力查找酒店中</text>
<text class="ht-loading__sub">请稍候…</text>
</view>
<view v-else-if="!state.list.length" class="ht-empty">暂无符合条件的酒店</view>
<view v-for="(item, idx) in state.list" :key="item.id" class="ht-card" @tap="goDetail(item)">
@@ -319,7 +331,10 @@
</view>
</view>
<view v-if="state.loading && state.list.length" class="ht-empty">加载更多…</view>
<view v-if="state.loading && state.list.length" class="ht-loading ht-loading--more">
<tm-icon name="loader-4-line" :size="36" color="#1570ef" spin />
<text class="ht-loading__sub">正在努力查找更多酒店…</text>
</view>
<view v-else-if="state.finished && state.list.length" class="ht-empty">没有更多了</view>
</view>
</scroll-view>
@@ -333,10 +348,12 @@ import dayjs from "dayjs"
import { hotelApi } from "@@/api"
import { router } from "@@/index"
import { filePath } from "@@/utils/file"
import HotelGuestSheet from "@@/components/hotel/hotel-guest-sheet.vue"
const statusBarHeight = ref(20)
const scrollHeight = ref("100vh")
const refreshing = ref(false)
const guestVisible = ref(false)
const panel = ref<"" | "area" | "price" | "score" | "more">("")
const filterTabs = [
@@ -366,10 +383,12 @@ const query = reactive({
checkOut: dayjs().add(1, "day").format("YYYY-MM-DD"),
rooms: 1,
adults: 1,
children: 0,
})
const listCheckOutMin = computed(() => dayjs(query.checkIn).add(1, "day").format("YYYY-MM-DD"))
const listCheckOutMax = computed(() => dayjs(query.checkIn).add(MAX_STAY_NIGHTS, "day").format("YYYY-MM-DD"))
const guestSummaryShort = computed(() => `${query.rooms}间${query.adults + query.children}人`)
const normalizeListDate = (value: string) => dayjs(String(value || "").replace(/\//g, "-")).format("YYYY-MM-DD")
@@ -391,6 +410,7 @@ const onListCheckInConfirm = (value: string) => {
query.checkOut = dayjs(query.checkIn).add(1, "day").format("YYYY-MM-DD")
}
clampListStayRange(true)
reload()
}
const onListCheckOutConfirm = (value: string) => {
@@ -403,6 +423,26 @@ const onListCheckOutConfirm = (value: string) => {
}
query.checkOut = d
clampListStayRange(true)
reload()
}
const onGuestConfirm = (v: { rooms: number; adults: number; children: number }) => {
query.rooms = v.rooms
query.adults = v.adults
query.children = v.children
hotelApi
.saveSearchPref({
rooms: query.rooms,
adults: query.adults,
children: query.children,
nights: nightCount.value,
city: query.city,
keyword: query.keyword,
checkIn: query.checkIn,
checkOut: query.checkOut,
})
.catch(() => {})
reload()
}
const filter = reactive({
@@ -895,6 +935,11 @@ const fetchPage = async (reset = false) => {
minPrice: filter.minPrice != null ? filter.minPrice : undefined,
maxPrice: filter.maxPrice != null ? filter.maxPrice : undefined,
facilityTags: filter.facilityTags.length ? filter.facilityTags.join(",") : undefined,
checkIn: query.checkIn || undefined,
checkOut: query.checkOut || undefined,
rooms: query.rooms || undefined,
adults: query.adults || undefined,
children: query.children ?? undefined,
})
const data = res?.data || {}
const rows = data.records || []
@@ -930,6 +975,8 @@ const goDetail = (item: any) => {
checkIn: query.checkIn,
checkOut: query.checkOut,
rooms: String(query.rooms),
adults: String(query.adults),
children: String(query.children),
})
}
@@ -942,6 +989,7 @@ onLoad((opt: any) => {
query.checkOut = opt?.checkOut || query.checkOut
query.rooms = Number(opt?.rooms || 1) || 1
query.adults = Number(opt?.adults || 1) || 1
query.children = Math.max(0, Number(opt?.children || 0) || 0)
clampListStayRange(false)
layout()
loadMeta()
@@ -1278,6 +1326,29 @@ onLoad((opt: any) => {
font-size: 26rpx;
color: #94a3b8;
}
.ht-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 32rpx 80rpx;
gap: 16rpx;
}
.ht-loading--more {
padding: 32rpx 0 48rpx;
flex-direction: row;
gap: 12rpx;
}
.ht-loading__title {
margin-top: 8rpx;
font-size: 30rpx;
font-weight: 600;
color: #334155;
}
.ht-loading__sub {
font-size: 24rpx;
color: #94a3b8;
}
.ht-hint {
width: 100%;
font-size: 22rpx;
+98 -1
View File
@@ -56,6 +56,14 @@
</tm-picker-date>
</view>
<view class="ht-guest-row" @tap="guestVisible = true">
<view class="ht-guest-row__main">
<text class="ht-guest-row__lab">间数人数</text>
<text class="ht-guest-row__val">{{ guestSummary }}</text>
</view>
<tm-icon name="arrow-right-s-line" :size="32" color="#94a3b8" />
</view>
<view class="ht-keyword-wrap">
<input
class="ht-keyword"
@@ -77,6 +85,14 @@
</view>
</view>
<hotel-guest-sheet
v-model="guestVisible"
:rooms="state.rooms"
:adults="state.adults"
:children="state.children"
@confirm="onGuestConfirm"
/>
<!-- 城市选择 -->
<view v-if="cityVisible" class="ht-city-mask" @tap="closeCityPicker">
<view class="ht-city-sheet" @tap.stop>
@@ -121,7 +137,9 @@ import { onShow } from "@dcloudio/uni-app"
import dayjs from "dayjs"
import { router } from "@@/index"
import { useTabbarStore } from "@@/stores/tabbar"
import hotelApi from "@@/api/hotel"
import TIndexAddress from "@@/components/address/t-index-address/t-index-address.vue"
import HotelGuestSheet from "@@/components/hotel/hotel-guest-sheet.vue"
const DATE_PICKER_MIN = dayjs().format("YYYY-MM-DD")
const DATE_PICKER_MAX = dayjs().add(365, "day").format("YYYY-MM-DD")
@@ -133,12 +151,17 @@ const hotCities = ["北京", "上海", "广州", "深圳", "杭州", "成都", "
const cityVisible = ref(false)
const cityKeyword = ref("")
const guestVisible = ref(false)
const prefLoaded = ref(false)
const state = reactive({
city: "",
keyword: "",
checkIn: dayjs().format("YYYY-MM-DD"),
checkOut: dayjs().add(1, "day").format("YYYY-MM-DD"),
rooms: 1,
adults: 1,
children: 0,
})
const nightCount = computed(() => {
@@ -146,6 +169,10 @@ const nightCount = computed(() => {
return n > 0 ? n : 1
})
const guestSummary = computed(
() => `${state.rooms}间 ${state.adults}成人 ${state.children}儿童`,
)
const checkOutPickerMin = computed(() => dayjs(state.checkIn).add(1, "day").format("YYYY-MM-DD"))
const checkOutPickerMax = computed(() => dayjs(state.checkIn).add(MAX_STAY_NIGHTS, "day").format("YYYY-MM-DD"))
@@ -213,13 +240,63 @@ const clearCity = () => {
state.city = ""
}
const onGuestConfirm = (v: { rooms: number; adults: number; children: number }) => {
state.rooms = v.rooms
state.adults = v.adults
state.children = v.children
}
const persistSearchPref = async () => {
try {
await hotelApi.saveSearchPref({
rooms: state.rooms,
adults: state.adults,
children: state.children,
nights: nightCount.value,
city: state.city,
keyword: state.keyword,
checkIn: state.checkIn,
checkOut: state.checkOut,
})
} catch {
/* 未登录或接口失败不阻断搜索 */
}
}
const loadSearchPref = async () => {
if (prefLoaded.value) return
try {
const res: any = await hotelApi.getSearchPref()
const d = res?.data
if (!d) return
state.rooms = Math.max(1, Number(d.rooms) || 1)
state.adults = Math.max(1, Number(d.adults) || 1)
state.children = Math.max(0, Number(d.children) || 0)
if (d.city) state.city = String(d.city)
// 晚数习惯:若库里有 nights,且当前仍是默认 1 晚,则按习惯回填离店
const nights = Math.max(1, Math.min(MAX_STAY_NIGHTS, Number(d.nights) || 1))
const today = dayjs().format("YYYY-MM-DD")
if (state.checkIn === today && nightCount.value === 1 && nights > 1) {
state.checkOut = dayjs(state.checkIn).add(nights, "day").format("YYYY-MM-DD")
}
prefLoaded.value = true
} catch {
prefLoaded.value = true
}
}
const goSearch = () => {
clampStayRange(true)
// 习惯落库异步,不阻塞跳转与列表请求
persistSearchPref()
router.jump("/pages/hotel/list", {
city: state.city,
keyword: state.keyword,
checkIn: state.checkIn,
checkOut: state.checkOut,
rooms: "1",
rooms: String(state.rooms),
adults: String(state.adults),
children: String(state.children),
})
}
@@ -230,6 +307,7 @@ const goOrders = () => {
onShow(() => {
// 从酒店列表等业务页返回时,确保原生底栏重新显示
useTabbarStore().showTabbar("/pages/tabbar/travel")
loadSearchPref()
})
</script>
@@ -347,6 +425,25 @@ onShow(() => {
color: #1570ef;
font-size: 22rpx;
}
.ht-guest-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 0;
border-bottom: 1rpx solid #f1f5f9;
}
.ht-guest-row__lab {
display: block;
font-size: 22rpx;
color: #94a3b8;
}
.ht-guest-row__val {
display: block;
margin-top: 6rpx;
font-size: 30rpx;
font-weight: 600;
color: #0f172a;
}
.ht-keyword-wrap {
position: relative;
margin-top: 20rpx;
+15
View File
@@ -33,6 +33,21 @@ export default {
params,
})
},
/** 当前用户酒店搜索习惯 */
getSearchPref: (): Promise<any> => {
return request({
url: "/supermarket/app/hotel/search-pref",
method: "GET",
})
},
/** 保存酒店搜索习惯 */
saveSearchPref: (data: any): Promise<any> => {
return request({
url: "/supermarket/app/hotel/search-pref",
method: "POST",
data,
})
},
/** 酒店详情 */
detail: (id: string | number): Promise<any> => {
return request({
@@ -0,0 +1,278 @@
<template>
<view v-if="visible" class="hg-mask" @tap="onClose">
<view class="hg-sheet" @tap.stop>
<view class="hg-sheet__head">
<text class="hg-sheet__title">请选择房间和住客人数</text>
<view class="hg-sheet__close" @tap="onClose">
<tm-icon name="close-line" :size="36" color="#64748b" />
</view>
</view>
<scroll-view class="hg-sheet__body" scroll-y :show-scrollbar="false">
<view class="hg-row">
<text class="hg-row__lab">间数</text>
<view class="hg-step">
<view class="hg-step__btn" :class="{ off: draft.rooms <= 1 }" @tap="change('rooms', -1)">−</view>
<text class="hg-step__val">{{ draft.rooms }}</text>
<view class="hg-step__btn" :class="{ off: draft.rooms >= 10 }" @tap="change('rooms', 1)">+</view>
</view>
</view>
<view class="hg-row">
<text class="hg-row__lab">成人数</text>
<view class="hg-step">
<view class="hg-step__btn" :class="{ off: draft.adults <= 1 }" @tap="change('adults', -1)">−</view>
<text class="hg-step__val">{{ draft.adults }}</text>
<view class="hg-step__btn" :class="{ off: draft.adults >= 20 }" @tap="change('adults', 1)">+</view>
</view>
</view>
<view class="hg-row">
<view class="hg-row__lab-wrap">
<text class="hg-row__lab">儿童数</text>
<text class="hg-row__sub">0-17岁</text>
</view>
<view class="hg-step">
<view class="hg-step__btn" :class="{ off: draft.children <= 0 }" @tap="change('children', -1)">−</view>
<text class="hg-step__val">{{ draft.children }}</text>
<view class="hg-step__btn" :class="{ off: draft.children >= 20 }" @tap="change('children', 1)">+</view>
</view>
</view>
<view class="hg-tip">
<text class="hg-tip__title">重要提示</text>
<text class="hg-tip__txt">1、儿童年龄请按照入住酒店当天实际年龄计算。</text>
<text class="hg-tip__txt">2、当前按总人数筛选可住房型;成人/儿童标签后续再细化。</text>
</view>
</scroll-view>
<view class="hg-sheet__foot">
<view class="hg-ok" @tap="onConfirm">确定</view>
</view>
</view>
</view>
</template>
<script setup lang="ts" name="HotelGuestSheet">
import { computed, onBeforeUnmount, reactive, watch } from "vue"
import { useTabbarStore } from "@@/stores/tabbar"
const props = defineProps<{
modelValue: boolean
rooms?: number
adults?: number
children?: number
}>()
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void
(e: "confirm", v: { rooms: number; adults: number; children: number }): void
}>()
const tabbarStore = useTabbarStore()
/** 本弹层是否已主动藏过底栏(关闭时再恢复,避免误伤其它逻辑) */
let hidTabbarBySheet = false
const currentPagePath = () => {
const pages = getCurrentPages()
const cur = pages[pages.length - 1] as { route?: string } | undefined
const route = String(cur?.route || "").replace(/^\//, "")
return route ? `/${route}` : tabbarStore.currentPath || ""
}
const syncTabbarWithSheet = (open: boolean) => {
// 原生 TabBar 盖在页面 fixed 之上,只能 hideTabBar,靠 z-index 盖不住
const path = currentPagePath()
if (open) {
if (!path || !tabbarStore.isTabbarPage(path)) return
tabbarStore.hideTabbar(path)
hidTabbarBySheet = true
return
}
if (!hidTabbarBySheet) return
hidTabbarBySheet = false
if (path && tabbarStore.isTabbarPage(path)) {
tabbarStore.showTabbar(path)
}
}
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
})
const draft = reactive({
rooms: 1,
adults: 1,
children: 0,
})
watch(
() => props.modelValue,
(open) => {
syncTabbarWithSheet(!!open)
if (!open) return
draft.rooms = Math.max(1, Math.min(10, Number(props.rooms) || 1))
draft.adults = Math.max(1, Math.min(20, Number(props.adults) || 1))
draft.children = Math.max(0, Math.min(20, Number(props.children) || 0))
},
)
onBeforeUnmount(() => {
syncTabbarWithSheet(false)
})
const change = (key: "rooms" | "adults" | "children", delta: number) => {
const next = draft[key] + delta
if (key === "rooms") draft.rooms = Math.max(1, Math.min(10, next))
else if (key === "adults") draft.adults = Math.max(1, Math.min(20, next))
else draft.children = Math.max(0, Math.min(20, next))
}
const onClose = () => {
visible.value = false
}
const onConfirm = () => {
emit("confirm", {
rooms: draft.rooms,
adults: draft.adults,
children: draft.children,
})
visible.value = false
}
</script>
<style lang="scss" scoped>
.hg-mask {
position: fixed;
inset: 0;
z-index: 1200;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: flex-end;
/* 盖住底部 TabBar / 手势条,避免确定按钮被挡 */
padding-bottom: 0;
}
.hg-sheet {
width: 100%;
max-height: 85vh;
background: #fff;
border-radius: 28rpx 28rpx 0 0;
padding: 28rpx 28rpx 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.hg-sheet__head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding-bottom: 20rpx;
}
.hg-sheet__body {
flex: 1;
min-height: 0;
max-height: 52vh;
overflow: hidden;
}
.hg-sheet__foot {
flex-shrink: 0;
padding: 24rpx 0 calc(24rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
}
.hg-sheet__title {
font-size: 32rpx;
font-weight: 600;
color: #0f172a;
}
.hg-sheet__close {
position: absolute;
right: 0;
top: 0;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.hg-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 0;
border-bottom: 1rpx solid #f1f5f9;
}
.hg-row__lab-wrap {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.hg-row__lab {
font-size: 30rpx;
color: #0f172a;
font-weight: 500;
}
.hg-row__sub {
font-size: 22rpx;
color: #94a3b8;
}
.hg-step {
display: flex;
align-items: center;
gap: 24rpx;
}
.hg-step__btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 2rpx solid #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #334155;
line-height: 1;
}
.hg-step__btn.off {
opacity: 0.35;
}
.hg-step__val {
min-width: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #0f172a;
}
.hg-tip {
margin-top: 24rpx;
padding: 20rpx;
background: #f8fafc;
border-radius: 16rpx;
}
.hg-tip__title {
display: block;
font-size: 26rpx;
font-weight: 600;
color: #334155;
margin-bottom: 8rpx;
}
.hg-tip__txt {
display: block;
font-size: 22rpx;
color: #64748b;
line-height: 1.6;
}
.hg-ok {
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 999rpx;
background: #1570ef;
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
</style>