Files
rent/apps/miniapp/src/pages/index/index.vue
T
2026-05-11 00:23:48 +08:00

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>