597 lines
15 KiB
Vue
597 lines
15 KiB
Vue
<template>
|
|
<view class="page-index">
|
|
<!-- 搜索面板 -->
|
|
<view class="search-panel">
|
|
<base-card class="search-card" variant="elevated" :shadow="false">
|
|
<!-- 城市选择 -->
|
|
<form-field
|
|
type="select"
|
|
:display-value="searchParams.city"
|
|
placeholder="选择目的地城市"
|
|
right-icon="map"
|
|
:show-select-arrow="false"
|
|
@click="openCityPicker"
|
|
/>
|
|
|
|
<!-- 入住离店日期 -->
|
|
<form-field type="custom" @click="openDatePicker">
|
|
<view class="date-picker-content">
|
|
<view class="date-section">
|
|
<text class="date-label">{{ checkInDesc }}入住</text>
|
|
<text class="date-value">{{ checkInLabel }}</text>
|
|
</view>
|
|
<view class="nights-badge">{{ nightCount }}晚</view>
|
|
<view class="date-section">
|
|
<text class="date-label">{{ checkOutDesc }}离店</text>
|
|
<text class="date-value">{{ checkOutLabel }}</text>
|
|
</view>
|
|
</view>
|
|
</form-field>
|
|
|
|
<!-- 搜索关键词 -->
|
|
<form-field
|
|
type="select"
|
|
:display-value="searchParams.keyword"
|
|
placeholder="搜索酒店、民宿、位置"
|
|
right-icon="search"
|
|
@click="goLocationSearch"
|
|
/>
|
|
|
|
<!-- 价格区间 -->
|
|
<form-field
|
|
type="select"
|
|
:display-value="priceRangeLabel"
|
|
placeholder="价格区间"
|
|
@click="openPricePicker"
|
|
/>
|
|
|
|
<!-- 搜索按钮 -->
|
|
<view class="search-button-wrapper">
|
|
<base-button
|
|
type="primary"
|
|
size="large"
|
|
text="搜索酒店"
|
|
block
|
|
@click="handleSearch"
|
|
/>
|
|
</view>
|
|
</base-card>
|
|
</view>
|
|
|
|
<!-- 推荐列表 -->
|
|
<view class="recommend-section">
|
|
<view class="section-header">
|
|
<text class="section-title">为你推荐</text>
|
|
<view class="section-action" @tap="handleSearch">
|
|
<text class="action-text">查看全部</text>
|
|
<u-icon name="arrow-right" :size="16" color="#999" />
|
|
</view>
|
|
</view>
|
|
|
|
<scroll-view
|
|
scroll-y
|
|
class="merchant-list"
|
|
@scrolltolower="handleLoadMore"
|
|
refresher-enabled
|
|
:refresher-triggered="isRefreshing"
|
|
@refresherrefresh="handleRefresh"
|
|
>
|
|
<!-- 骨架屏加载 -->
|
|
<loading-state v-if="isLoading && merchants.length === 0" type="skeleton">
|
|
<template #skeleton>
|
|
<view v-for="i in 3" :key="i" class="skeleton-card">
|
|
<view class="skeleton-image" />
|
|
<view class="skeleton-body">
|
|
<view class="skeleton-title" />
|
|
<view class="skeleton-text" />
|
|
<view class="skeleton-text short" />
|
|
</view>
|
|
</view>
|
|
</template>
|
|
</loading-state>
|
|
|
|
<!-- 商家卡片列表 -->
|
|
<view v-else class="merchant-list-content">
|
|
<merchant-card
|
|
v-for="merchant in displayMerchants"
|
|
:key="merchant.id"
|
|
:merchant="merchant"
|
|
@click="handleMerchantClick(merchant.id)"
|
|
/>
|
|
</view>
|
|
|
|
<!-- 加载更多指示器 -->
|
|
<loading-state v-if="isLoading && merchants.length > 0" size="small" />
|
|
|
|
<!-- 加载完成提示 -->
|
|
<view v-if="!hasMoreData && merchants.length > 0" class="load-complete">
|
|
<text class="complete-text">已加载全部内容</text>
|
|
</view>
|
|
|
|
<!-- 空状态 -->
|
|
<empty-state
|
|
v-if="merchants.length === 0 && !isLoading"
|
|
type="search"
|
|
@action="handleRefresh"
|
|
/>
|
|
</scroll-view>
|
|
</view>
|
|
|
|
<!-- 弹窗组件 -->
|
|
<CityPicker
|
|
ref="cityPickerRef"
|
|
:city="searchParams.city"
|
|
@change="handleCityChange"
|
|
/>
|
|
<DatePicker
|
|
ref="datePickerRef"
|
|
:check-in="searchParams.checkIn"
|
|
:check-out="searchParams.checkOut"
|
|
@change="handleDateChange"
|
|
/>
|
|
<RoomGuestPicker
|
|
ref="roomGuestPickerRef"
|
|
:room-count="searchParams.roomCount"
|
|
:adult-count="searchParams.adultCount"
|
|
:child-count="searchParams.childCount"
|
|
@change="handleRoomGuestChange"
|
|
/>
|
|
<PricePicker
|
|
ref="pricePickerRef"
|
|
:min="searchParams.minPrice"
|
|
:max="searchParams.maxPrice"
|
|
@change="handlePriceChange"
|
|
/>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onActivated, reactive } from 'vue';
|
|
import { getMerchantList } from '@/api/user/merchant';
|
|
import { getDefaultDates, formatDateShort, getDateDescription, calculateNights } from '@/utils/date';
|
|
import type { Merchant, MerchantCardData, SearchParams } from '@/types/merchant';
|
|
import CityPicker from '@/components/CityPicker.vue';
|
|
import DatePicker from '@/components/DatePicker.vue';
|
|
import RoomGuestPicker from '@/components/RoomGuestPicker.vue';
|
|
import PricePicker from '@/components/PricePicker.vue';
|
|
import BaseCard from '@/components/base/BaseCard.vue';
|
|
import BaseButton from '@/components/base/BaseButton.vue';
|
|
import FormField from '@/components/business/FormField.vue';
|
|
import MerchantCard from '@/components/business/MerchantCard.vue';
|
|
import LoadingState from '@/components/base/LoadingState.vue';
|
|
import EmptyState from '@/components/base/EmptyState.vue';
|
|
|
|
// 常量定义
|
|
const DEFAULT_CITY = '上海';
|
|
const PAGE_SIZE = 10;
|
|
const DEFAULT_COVER = '/static/default-avatar.png';
|
|
|
|
// 获取默认日期
|
|
const { today, tomorrow } = getDefaultDates();
|
|
|
|
// 搜索参数
|
|
const searchParams = reactive<SearchParams>({
|
|
city: DEFAULT_CITY,
|
|
keyword: '',
|
|
checkIn: today,
|
|
checkOut: tomorrow,
|
|
roomCount: 1,
|
|
adultCount: 1,
|
|
childCount: 0,
|
|
minPrice: undefined,
|
|
maxPrice: undefined,
|
|
});
|
|
|
|
// 商家列表状态
|
|
const merchants = ref<Merchant[]>([]);
|
|
const currentPage = ref(1);
|
|
const isLoading = ref(false);
|
|
const isRefreshing = ref(false);
|
|
const hasMoreData = ref(true);
|
|
|
|
// 弹窗引用
|
|
const cityPickerRef = ref();
|
|
const datePickerRef = ref();
|
|
const roomGuestPickerRef = ref();
|
|
const pricePickerRef = ref();
|
|
|
|
// 计算属性
|
|
const nightCount = computed(() =>
|
|
calculateNights(searchParams.checkIn, searchParams.checkOut)
|
|
);
|
|
|
|
const checkInLabel = computed(() =>
|
|
formatDateShort(searchParams.checkIn)
|
|
);
|
|
|
|
const checkOutLabel = computed(() =>
|
|
formatDateShort(searchParams.checkOut)
|
|
);
|
|
|
|
const checkInDesc = computed(() =>
|
|
getDateDescription(searchParams.checkIn)
|
|
);
|
|
|
|
const checkOutDesc = computed(() =>
|
|
getDateDescription(searchParams.checkOut)
|
|
);
|
|
|
|
const priceRangeLabel = computed(() => {
|
|
const { minPrice, maxPrice } = searchParams;
|
|
if (minPrice === undefined && maxPrice === undefined) return '';
|
|
if (minPrice !== undefined && maxPrice !== undefined) return `¥${minPrice}-${maxPrice}`;
|
|
if (minPrice !== undefined) return `¥${minPrice}+`;
|
|
return `¥${maxPrice}以下`;
|
|
});
|
|
|
|
const displayMerchants = computed<MerchantCardData[]>(() =>
|
|
merchants.value.map(transformMerchantData)
|
|
);
|
|
|
|
// 数据转换
|
|
function transformMerchantData(merchant: Merchant): MerchantCardData {
|
|
return {
|
|
id: merchant.id,
|
|
name: merchant.shopName,
|
|
coverImage: merchant.logo || DEFAULT_COVER,
|
|
cityName: merchant.city,
|
|
rating: merchant.rating,
|
|
reviewCount: merchant.reviewCount,
|
|
description: merchant.description || '暂无简介',
|
|
facilities: merchant.facilities || [],
|
|
minPrice: merchant.minPrice || 0,
|
|
roomCount: merchant.roomCount,
|
|
distance: merchant.distance,
|
|
isRecommend: merchant.isRecommend,
|
|
isHot: merchant.isHot,
|
|
isNew: merchant.isNew,
|
|
};
|
|
}
|
|
|
|
// 获取商家列表
|
|
async function fetchMerchantList(shouldReset = false) {
|
|
if (isLoading.value) return;
|
|
if (!shouldReset && !hasMoreData.value) return;
|
|
|
|
isLoading.value = true;
|
|
|
|
if (shouldReset) {
|
|
currentPage.value = 1;
|
|
hasMoreData.value = true;
|
|
}
|
|
|
|
try {
|
|
const response = await getMerchantList({
|
|
page: currentPage.value,
|
|
pageSize: PAGE_SIZE,
|
|
});
|
|
|
|
const list = response.data?.list || [];
|
|
merchants.value = shouldReset ? list : [...merchants.value, ...list];
|
|
hasMoreData.value = list.length >= PAGE_SIZE;
|
|
currentPage.value++;
|
|
} catch (error) {
|
|
console.error('获取商家列表失败:', error);
|
|
uni.showToast({ title: '加载失败,请重试', icon: 'none' });
|
|
} finally {
|
|
isLoading.value = false;
|
|
isRefreshing.value = false;
|
|
}
|
|
}
|
|
|
|
// 弹窗操作
|
|
function openCityPicker() {
|
|
cityPickerRef.value?.open();
|
|
}
|
|
|
|
function openDatePicker() {
|
|
datePickerRef.value?.open();
|
|
}
|
|
|
|
function openPricePicker() {
|
|
pricePickerRef.value?.open();
|
|
}
|
|
|
|
// 弹窗回调
|
|
function handleCityChange(city: string) {
|
|
searchParams.city = city;
|
|
}
|
|
|
|
function handleDateChange(checkIn: string, checkOut: string) {
|
|
searchParams.checkIn = checkIn;
|
|
searchParams.checkOut = checkOut;
|
|
}
|
|
|
|
function handleRoomGuestChange(rooms: number, adults: number, children: number) {
|
|
searchParams.roomCount = rooms;
|
|
searchParams.adultCount = adults;
|
|
searchParams.childCount = children;
|
|
}
|
|
|
|
function handlePriceChange(min: number | undefined, max: number | undefined) {
|
|
searchParams.minPrice = min;
|
|
searchParams.maxPrice = max;
|
|
}
|
|
|
|
// 导航操作
|
|
function goLocationSearch() {
|
|
uni.navigateTo({ url: '/pages/location-search/index' });
|
|
}
|
|
|
|
function handleSearch() {
|
|
const queryParams = buildSearchQuery();
|
|
uni.navigateTo({ url: `/pages/search/index?${queryParams}` });
|
|
}
|
|
|
|
function handleMerchantClick(merchantId: number) {
|
|
const queryParams = buildMerchantQuery(merchantId);
|
|
uni.navigateTo({ url: `/pages/merchant-detail/index?${queryParams}` });
|
|
}
|
|
|
|
// 构建查询参数
|
|
function buildSearchQuery(): string {
|
|
const params = [
|
|
`city=${encodeURIComponent(searchParams.city)}`,
|
|
searchParams.keyword ? `keyword=${encodeURIComponent(searchParams.keyword)}` : '',
|
|
`checkIn=${searchParams.checkIn}`,
|
|
`checkOut=${searchParams.checkOut}`,
|
|
`roomCount=${searchParams.roomCount}`,
|
|
`adultCount=${searchParams.adultCount}`,
|
|
`childCount=${searchParams.childCount}`,
|
|
searchParams.minPrice !== undefined ? `minPrice=${searchParams.minPrice}` : '',
|
|
searchParams.maxPrice !== undefined ? `maxPrice=${searchParams.maxPrice}` : '',
|
|
];
|
|
return params.filter(Boolean).join('&');
|
|
}
|
|
|
|
function buildMerchantQuery(merchantId: number): string {
|
|
return [
|
|
`id=${merchantId}`,
|
|
`checkIn=${searchParams.checkIn}`,
|
|
`checkOut=${searchParams.checkOut}`,
|
|
`roomCount=${searchParams.roomCount}`,
|
|
`adultCount=${searchParams.adultCount}`,
|
|
`childCount=${searchParams.childCount}`,
|
|
].join('&');
|
|
}
|
|
|
|
// 列表操作
|
|
function handleRefresh() {
|
|
isRefreshing.value = true;
|
|
fetchMerchantList(true);
|
|
}
|
|
|
|
function handleLoadMore() {
|
|
fetchMerchantList(false);
|
|
}
|
|
|
|
// 处理位置搜索回传的关键词
|
|
function checkLocationSearchKeyword() {
|
|
const globalUni = uni as any;
|
|
const keyword = globalUni.__locationSearchKeyword;
|
|
if (keyword) {
|
|
searchParams.keyword = keyword;
|
|
globalUni.__locationSearchKeyword = '';
|
|
}
|
|
}
|
|
|
|
// 生命周期
|
|
onMounted(() => {
|
|
checkLocationSearchKeyword();
|
|
fetchMerchantList(true);
|
|
});
|
|
|
|
onActivated(() => {
|
|
checkLocationSearchKeyword();
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
@import '@/static/styles/design-tokens.scss';
|
|
@import '@/static/styles/mixins.scss';
|
|
|
|
.page-index {
|
|
min-height: 100vh;
|
|
background: $bg-page;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* ========== 搜索面板 ========== */
|
|
.search-panel {
|
|
padding: $spacing-xl;
|
|
background: linear-gradient(180deg, #f8f9fa 0%, $bg-page 100%);
|
|
}
|
|
|
|
.search-card {
|
|
overflow: visible;
|
|
border-radius: $radius-lg;
|
|
}
|
|
|
|
/* 日期选择器 */
|
|
.date-picker-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
padding: $spacing-sm 0;
|
|
gap: $spacing-md;
|
|
}
|
|
|
|
.date-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6rpx;
|
|
flex: 1;
|
|
|
|
&:last-child {
|
|
align-items: flex-end;
|
|
}
|
|
}
|
|
|
|
.date-label {
|
|
font-size: $font-xs;
|
|
color: $text-tertiary;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.date-value {
|
|
font-size: $font-lg;
|
|
font-weight: $font-semibold;
|
|
color: $text-primary;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.nights-badge {
|
|
flex-shrink: 0;
|
|
font-size: $font-xs;
|
|
color: $primary-color;
|
|
background: linear-gradient(135deg, $primary-50 0%, rgba($primary-color, 0.08) 100%);
|
|
padding: 8rpx $spacing-md;
|
|
border-radius: $radius-round;
|
|
font-weight: $font-semibold;
|
|
border: 1rpx solid rgba($primary-color, 0.15);
|
|
}
|
|
|
|
.search-button-wrapper {
|
|
margin-top: $spacing-lg;
|
|
}
|
|
|
|
/* ========== 推荐区域 ========== */
|
|
.recommend-section {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0 $spacing-xl;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: $spacing-xl 0 $spacing-md;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: $font-xl;
|
|
font-weight: $font-bold;
|
|
color: $text-primary;
|
|
letter-spacing: 0.5rpx;
|
|
}
|
|
|
|
.section-action {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6rpx;
|
|
padding: $spacing-xs $spacing-sm;
|
|
border-radius: $radius-sm;
|
|
transition: $transition-all;
|
|
|
|
&:active {
|
|
background: rgba($primary-color, 0.08);
|
|
transform: scale(0.98);
|
|
}
|
|
}
|
|
|
|
.action-text {
|
|
font-size: $font-sm;
|
|
color: $text-secondary;
|
|
font-weight: $font-medium;
|
|
}
|
|
|
|
/* ========== 商家列表 ========== */
|
|
.merchant-list {
|
|
flex: 1;
|
|
height: 0;
|
|
}
|
|
|
|
.merchant-list-content {
|
|
padding-bottom: $spacing-2xl;
|
|
}
|
|
|
|
/* 骨架屏 */
|
|
.skeleton-card {
|
|
display: flex;
|
|
gap: $spacing-md;
|
|
padding: $spacing-lg;
|
|
background: $bg-card;
|
|
border-radius: $radius-lg;
|
|
margin-bottom: $spacing-md;
|
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
.skeleton-image {
|
|
width: 200rpx;
|
|
height: 200rpx;
|
|
flex-shrink: 0;
|
|
border-radius: $radius-md;
|
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
background-size: 200% 100%;
|
|
animation: skeleton-loading 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.skeleton-body {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $spacing-md;
|
|
justify-content: center;
|
|
}
|
|
|
|
.skeleton-title,
|
|
.skeleton-text {
|
|
height: 32rpx;
|
|
border-radius: $radius-sm;
|
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
background-size: 200% 100%;
|
|
animation: skeleton-loading 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.skeleton-title {
|
|
width: 70%;
|
|
height: 36rpx;
|
|
}
|
|
|
|
.skeleton-text {
|
|
width: 100%;
|
|
|
|
&.short {
|
|
width: 60%;
|
|
}
|
|
}
|
|
|
|
@keyframes skeleton-loading {
|
|
0% {
|
|
background-position: 200% 0;
|
|
}
|
|
100% {
|
|
background-position: -200% 0;
|
|
}
|
|
}
|
|
|
|
/* 加载完成提示 */
|
|
.load-complete {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: $spacing-2xl 0;
|
|
position: relative;
|
|
|
|
&::before,
|
|
&::after {
|
|
content: '';
|
|
flex: 1;
|
|
height: 1rpx;
|
|
background: linear-gradient(to right, transparent, $border-light, transparent);
|
|
}
|
|
}
|
|
|
|
.complete-text {
|
|
padding: 0 $spacing-xl;
|
|
font-size: $font-xs;
|
|
color: $text-placeholder;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|