Files
CosScene/clients/pages/index/index.vue
T
shiran 3f3acf834d feat(map): 添加地图定位功能的条件渲染控制
为了解决H5平台地图定位显示的问题,在index页面和pick-location页面中添加了
mapShowLocation响应式变量,并通过条件编译确保仅在非H5平台启用地图定位功能,
避免H5环境下出现定位相关的兼容性问题。
2026-05-09 18:49:42 +08:00

1101 lines
26 KiB
Vue

<script setup>
import { ref, computed, onMounted, nextTick } from "vue";
import { getSpots } from "@/api/spot";
import { getTags } from "@/api/tag";
import { getPromotions, recordClick } from "@/api/promotion";
import { get, extractList } from "@/utils/request";
import { resolveImageUrl } from "@/utils/image";
import { formatSpotPrice } from "@/utils/spot";
import CityPicker from "@/components/city-picker/city-picker.vue";
import TagBar from "@/components/tag-bar/tag-bar.vue";
const banners = ref([]);
const sysInfo = uni.getSystemInfoSync();
const winH = sysInfo.windowHeight;
const PEEK_H = Math.round(winH * 0.25);
const EXPAND_H = Math.round(winH * 0.82);
const panelTopH = ref(100);
const measurePanelTop = () => {
nextTick(() => {
uni.createSelectorQuery().select(".panel-top").boundingClientRect((rect) => {
if (rect && rect.height > 0) panelTopH.value = rect.height;
}).exec();
});
};
const scrollH = computed(() => Math.max(0, panelH.value - panelTopH.value));
const city = ref("全部城市");
const spots = ref([]);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
const loading = ref(false);
const showCityPicker = ref(false);
const tags = ref([]);
const activeTagId = ref(null);
const sortOptions = [
{ label: "最新", value: "created_at" },
{ label: "评分", value: "avg_rating" },
{ label: "热门", value: "favorite_count" },
];
const sortBy = ref("created_at");
const showSortPicker = ref(false);
const selectedSpot = ref(null);
const locating = ref(false);
const mapCtx = ref(null);
const userLocation = ref(null);
const mapShowLocation = ref(false);
// #ifndef H5
mapShowLocation.value = true;
// #endif
const truncate = (str, len = 8) =>
str && str.length > len ? str.slice(0, len) + "…" : str;
const markers = computed(() =>
spots.value
.filter((s) => s.latitude != null && s.longitude != null)
.map((s) => ({
id: s.id,
latitude: s.latitude,
longitude: s.longitude,
title: s.title,
iconPath: "/static/marker.svg",
width: 28,
height: 36,
callout: {
content: truncate(s.title),
display: "ALWAYS",
fontSize: 12,
borderRadius: 6,
padding: 6,
bgColor: "#6366f1",
color: "#ffffff",
textAlign: "center",
anchorY: 0,
},
}))
);
const leftCol = computed(() => spots.value.filter((_, i) => i % 2 === 0));
const rightCol = computed(() => spots.value.filter((_, i) => i % 2 === 1));
const mapCenter = ref({ latitude: 39.9, longitude: 116.4 });
const mapId = "spot-map";
const panelH = ref(PEEK_H);
const panelExpanded = ref(false);
const dragging = ref(false);
let touchStartY = 0;
let startH = 0;
const scrollTop = ref(0);
const getY = (e) => {
if (e.touches && e.touches.length > 0) return e.touches[0].clientY;
if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientY;
if (typeof e.clientY === "number") return e.clientY;
return 0;
};
const onDragStart = (e) => {
touchStartY = getY(e);
startH = panelH.value;
dragging.value = true;
};
const onDragMove = (e) => {
if (!dragging.value) return;
const y = getY(e);
const dy = touchStartY - y;
let h = startH + dy;
if (h < PEEK_H) h = PEEK_H;
if (h > EXPAND_H) h = EXPAND_H;
panelH.value = h;
};
const onDragEnd = () => {
if (!dragging.value) return;
dragging.value = false;
if (panelH.value > startH) {
panelH.value = EXPAND_H;
panelExpanded.value = true;
} else if (panelH.value < startH) {
panelH.value = PEEK_H;
panelExpanded.value = false;
}
measurePanelTop();
};
let contentTouchY = 0;
let contentDragging = false;
const onContentTouchStart = (e) => {
contentTouchY = getY(e);
contentDragging = false;
};
const onContentTouchMove = (e) => {
if (contentDragging) return;
const y = getY(e);
const dy = contentTouchY - y;
if (Math.abs(dy) < 10) return;
if (!panelExpanded.value && dy > 0) {
contentDragging = true;
panelH.value = EXPAND_H;
panelExpanded.value = true;
measurePanelTop();
} else if (panelExpanded.value && dy < 0 && scrollTop.value <= 0) {
contentDragging = true;
panelH.value = PEEK_H;
panelExpanded.value = false;
measurePanelTop();
}
};
const onScrollUpdate = (e) => {
scrollTop.value = e.detail.scrollTop || 0;
};
const panelStyle = computed(() => ({
height: `${panelH.value}px`,
transition: dragging.value
? "none"
: "height 0.3s cubic-bezier(0.25,0.8,0.25,1)",
}));
const selectSpot = (spot) => {
selectedSpot.value = spot;
panelH.value = PEEK_H;
panelExpanded.value = false;
if (spot.latitude != null && spot.longitude != null) {
mapCenter.value = { latitude: spot.latitude, longitude: spot.longitude };
mapCtx.value?.moveToLocation({
latitude: spot.latitude,
longitude: spot.longitude,
});
}
};
const closePopup = () => {
selectedSpot.value = null;
};
const goDetail = (id) => {
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
};
const onMarkerTap = (e) => {
const markerId = e.detail?.markerId || e.markerId;
if (markerId) {
const spot = spots.value.find((s) => s.id === markerId);
if (spot) {
selectSpot(spot);
} else {
goDetail(markerId);
}
}
};
const onMapTap = () => {
if (selectedSpot.value) closePopup();
};
const fetchSpots = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const params = { page: page.value, page_size: pageSize, sort_by: sortBy.value };
if (city.value !== "全部城市") params.city = city.value;
if (activeTagId.value !== null) params.tag_id = activeTagId.value;
const res = await getSpots(params);
const list = extractList(res);
if (reset) {
spots.value = list;
} else {
spots.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
if (reset && list.length > 0) {
const first = list.find(
(s) => s.latitude != null && s.longitude != null
);
if (first) {
mapCenter.value = {
latitude: first.latitude,
longitude: first.longitude,
};
}
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const handleCitySelect = (selected) => {
city.value = selected;
showCityPicker.value = false;
fetchSpots(true);
};
const handleTagSelect = (tagId) => {
activeTagId.value = tagId;
fetchSpots(true);
};
const goSearch = () => {
uni.navigateTo({ url: "/pages/search/index" });
};
const fetchTags = async () => {
try {
const res = await getTags();
tags.value = extractList(res);
measurePanelTop();
} catch (e) {
console.error(e);
}
};
const loadMore = () => {
if (!loading.value && hasMore.value) fetchSpots();
};
const refreshSpots = () => {
if (loading.value) return;
fetchSpots(true);
};
const reverseGeocode = async (lat, lng) => {
const data = await get("/map/geocoder/reverse", {
location: `${lat},${lng}`,
});
return data?.status === 0 ? data.result : null;
};
const extractCity = (result) => {
return (
result?.address_component?.city ||
result?.ad_info?.city ||
result?.address_reference?.town?.title ||
""
);
};
const locateToUser = async ({ syncCity = true } = {}) => {
if (locating.value) return;
locating.value = true;
try {
const location = await new Promise((resolve, reject) => {
uni.getLocation({
type: "gcj02",
success: resolve,
fail: reject,
});
});
userLocation.value = {
latitude: location.latitude,
longitude: location.longitude,
};
mapCenter.value = userLocation.value;
selectedSpot.value = null;
mapCtx.value?.moveToLocation({
latitude: location.latitude,
longitude: location.longitude,
});
if (syncCity) {
const result = await reverseGeocode(location.latitude, location.longitude);
const currentCity = extractCity(result);
if (currentCity) {
city.value = currentCity;
}
}
await fetchSpots(true);
} catch (e) {
console.error(e);
if (!spots.value.length) {
await fetchSpots(true);
}
} finally {
locating.value = false;
}
};
const initPage = async () => {
fetchTags();
await locateToUser({ syncCity: true });
};
async function loadBanners() {
try {
const res = await getPromotions("home_banner");
banners.value = Array.isArray(res) ? res : [];
} catch (e) { /* ignore */ }
}
function onBannerClick(item) {
recordClick(item.id).catch(() => {});
if (item.link_type === "spot" && item.link_id) {
uni.navigateTo({ url: `/pages/spot/detail?id=${item.link_id}` });
} else if (item.link_type === "event" && item.link_id) {
uni.navigateTo({ url: `/pages/event/detail?id=${item.link_id}` });
} else if (item.link_type === "shooting" && item.link_id) {
uni.navigateTo({ url: `/pages/shooting/detail?id=${item.link_id}` });
} else if (item.link_type === "url" && item.link_url) {
// #ifdef H5
window.open(item.link_url, "_blank");
// #endif
// #ifndef H5
uni.navigateTo({ url: `/pages/webview/index?url=${encodeURIComponent(item.link_url)}` });
// #endif
}
}
onMounted(() => {
mapCtx.value = uni.createMapContext(mapId);
measurePanelTop();
initPage();
loadBanners();
});
</script>
<template>
<page-meta :page-style="'overflow:hidden;height:100%;'" />
<view class="discover-page">
<map
:id="mapId"
class="full-map"
:latitude="mapCenter.latitude"
:longitude="mapCenter.longitude"
:markers="markers"
:scale="12"
:show-location="mapShowLocation"
:enable-zoom="true"
:enable-scroll="true"
@markertap="onMarkerTap"
@tap="onMapTap"
/>
<!-- 搜索栏 -->
<view class="top-overlay">
<view class="top-bar">
<view class="search-entry" @tap="goSearch">
<uni-icons type="search" size="16" color="#94a3b8" />
<text class="search-entry-text">搜索取景地</text>
</view>
<view class="action-btn refresh-btn" @tap="refreshSpots">
<uni-icons type="refresh" size="16" color="#6366f1" />
</view>
<view class="city-selector" @tap="showCityPicker = true">
<uni-icons type="location" size="16" color="#6366f1" />
<text class="city-name">{{ city }}</text>
<uni-icons type="arrowdown" size="10" color="#94a3b8" />
</view>
</view>
</view>
<view class="locate-btn" @tap="locateToUser({ syncCity: true })">
<uni-icons
type="location-filled"
size="18"
color="#6366f1"
/>
<text class="locate-btn-text">{{ locating ? "定位中" : "定位" }}</text>
</view>
<!-- 地图上的详情浮窗 -->
<view v-if="selectedSpot" class="map-popup" @tap.stop>
<view class="popup-close" @tap="closePopup">
<uni-icons type="closeempty" size="18" color="#94a3b8" />
</view>
<view class="popup-body" @tap="goDetail(selectedSpot.id)">
<image
v-if="selectedSpot.cover_image_url"
class="popup-cover"
:src="resolveImageUrl(selectedSpot.cover_image_url)"
mode="aspectFill"
/>
<view v-else class="popup-cover popup-cover-empty">
<uni-icons type="camera" size="28" color="#94a3b8" />
</view>
<view class="popup-info">
<text class="popup-title">{{ selectedSpot.title }}</text>
<text class="popup-city">{{ selectedSpot.city }}</text>
<text
class="popup-price"
:class="{ free: formatSpotPrice(selectedSpot).isFree, paid: !formatSpotPrice(selectedSpot).isFree }"
>
{{ formatSpotPrice(selectedSpot).label }}
</text>
<view v-if="selectedSpot.avg_rating" class="popup-rating">
<uni-icons type="star-filled" size="14" color="#f59e0b" />
<text class="popup-rating-num">{{
selectedSpot.avg_rating.toFixed(1)
}}</text>
</view>
</view>
<view class="popup-arrow">
<uni-icons type="right" size="18" color="#6366f1" />
</view>
</view>
</view>
<!-- 底部面板 -->
<view class="bottom-panel" :style="panelStyle">
<view class="panel-top">
<view
class="panel-drag-zone"
@touchstart.stop="onDragStart"
@touchmove.stop.prevent="onDragMove"
@touchend.stop="onDragEnd"
@mousedown.stop="onDragStart"
@mousemove.stop.prevent="onDragMove"
@mouseup.stop="onDragEnd"
@mouseleave="onDragEnd"
>
<view class="panel-handle" />
</view>
<TagBar
:tags="tags"
:active-id="activeTagId"
@select="handleTagSelect"
/>
<swiper
v-if="banners.length > 0"
class="banner-swiper"
:indicator-dots="banners.length > 1"
:autoplay="true"
:interval="4000"
:circular="true"
>
<swiper-item v-for="b in banners" :key="b.id" @tap="onBannerClick(b)">
<image class="banner-img" :src="resolveImageUrl(b.image_url)" mode="aspectFill" />
<view class="banner-title-bar">
<text class="banner-title">{{ b.title }}</text>
</view>
</swiper-item>
</swiper>
<view class="panel-header">
<text class="panel-title">推荐取景地</text>
<view class="panel-header-right">
<view class="sort-trigger" @tap="showSortPicker = !showSortPicker">
<uni-icons type="bars" size="14" color="#6366f1" />
<text class="sort-label">{{ sortOptions.find(o => o.value === sortBy)?.label }}</text>
</view>
<text class="panel-count" v-if="spots.length">{{ spots.length }}个</text>
</view>
</view>
<view v-if="showSortPicker" class="sort-dropdown">
<view
v-for="opt in sortOptions"
:key="opt.value"
class="sort-option"
:class="{ active: sortBy === opt.value }"
@tap="sortBy = opt.value; showSortPicker = false; fetchSpots(true)"
>
<text class="sort-option-text">{{ opt.label }}</text>
<uni-icons v-if="sortBy === opt.value" type="checkmarkempty" size="14" color="#6366f1" />
</view>
</view>
</view>
<scroll-view
scroll-y
:show-scrollbar="false"
class="panel-scroll"
:style="{ height: scrollH + 'px' }"
@scrolltolower="loadMore"
@scroll="onScrollUpdate"
@touchstart="onContentTouchStart"
@touchmove="onContentTouchMove"
>
<view class="waterfall">
<view class="waterfall-col">
<view
v-for="item in leftCol"
:key="item.id"
class="wf-card"
@tap="selectSpot(item)"
>
<image
v-if="item.cover_image_url"
class="wf-cover"
:src="resolveImageUrl(item.cover_image_url)"
mode="widthFix"
/>
<view v-else class="wf-cover wf-cover-empty">
<uni-icons type="camera" size="32" color="#94a3b8" />
</view>
<view class="wf-info">
<text class="wf-title">{{ item.title }}</text>
<view class="wf-meta">
<text class="wf-city">{{ item.city }}</text>
<view v-if="item.avg_rating" class="wf-rating">
<uni-icons type="star-filled" size="12" color="#f59e0b" />
<text class="wf-rating-num">{{ item.avg_rating.toFixed(1) }}</text>
</view>
</view>
<view class="wf-bottom">
<text
class="wf-price"
:class="{ free: formatSpotPrice(item).isFree, paid: !formatSpotPrice(item).isFree }"
>{{ formatSpotPrice(item).label }}</text>
<view v-if="item.favorite_count" class="wf-fav">
<uni-icons type="heart-filled" size="12" color="#94a3b8" />
<text class="wf-fav-num">{{ item.favorite_count }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="waterfall-col">
<view
v-for="item in rightCol"
:key="item.id"
class="wf-card"
@tap="selectSpot(item)"
>
<image
v-if="item.cover_image_url"
class="wf-cover"
:src="resolveImageUrl(item.cover_image_url)"
mode="widthFix"
/>
<view v-else class="wf-cover wf-cover-empty">
<uni-icons type="camera" size="32" color="#94a3b8" />
</view>
<view class="wf-info">
<text class="wf-title">{{ item.title }}</text>
<view class="wf-meta">
<text class="wf-city">{{ item.city }}</text>
<view v-if="item.avg_rating" class="wf-rating">
<uni-icons type="star-filled" size="12" color="#f59e0b" />
<text class="wf-rating-num">{{ item.avg_rating.toFixed(1) }}</text>
</view>
</view>
<view class="wf-bottom">
<text
class="wf-price"
:class="{ free: formatSpotPrice(item).isFree, paid: !formatSpotPrice(item).isFree }"
>{{ formatSpotPrice(item).label }}</text>
<view v-if="item.favorite_count" class="wf-fav">
<uni-icons type="heart-filled" size="12" color="#94a3b8" />
<text class="wf-fav-num">{{ item.favorite_count }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view v-if="loading" class="status-tip">
<text>加载中...</text>
</view>
<view v-else-if="!hasMore && spots.length > 0" class="status-tip">
<text>没有更多了</text>
</view>
<view
v-else-if="!loading && spots.length === 0"
class="status-tip empty"
>
<uni-icons type="search" size="40" color="#94a3b8" />
<text class="empty-text">暂无取景地数据</text>
</view>
</scroll-view>
</view><!-- bottom-panel -->
<CityPicker
:visible="showCityPicker"
:current-city="city"
@close="showCityPicker = false"
@select="handleCitySelect"
/>
</view>
</template>
<style scoped>
.discover-page {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #f5f6fa;
}
.full-map {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.top-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
padding-top: var(--status-bar-height, 0px);
}
.top-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
gap: 16rpx;
}
.locate-btn {
position: absolute;
right: 24rpx;
top: calc(112rpx + env(safe-area-inset-top));
z-index: 11;
height: 72rpx;
padding: 0 22rpx;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 6rpx 18rpx rgba(15, 23, 42, 0.1);
display: flex;
align-items: center;
gap: 8rpx;
}
.locate-btn-text {
font-size: 24rpx;
color: #475569;
font-weight: 600;
}
.search-entry {
flex: 1;
display: flex;
align-items: center;
height: 72rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 36rpx;
padding: 0 24rpx;
gap: 10rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
}
.search-entry-text {
font-size: 28rpx;
color: #94a3b8;
}
.city-selector {
display: flex;
align-items: center;
height: 72rpx;
padding: 0 20rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 36rpx;
gap: 6rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.city-name {
font-size: 26rpx;
color: #1e293b;
font-weight: 500;
}
/* 地图浮窗 */
.map-popup {
position: absolute;
left: 24rpx;
right: 24rpx;
bottom: 30%;
z-index: 25;
background: #ffffff;
border-radius: 20rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.popup-close {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
background: rgba(255, 255, 255, 0.8);
border-radius: 24rpx;
}
.popup-body {
display: flex;
align-items: center;
padding: 20rpx;
gap: 20rpx;
}
.popup-cover {
width: 140rpx;
height: 140rpx;
border-radius: 12rpx;
flex-shrink: 0;
}
.popup-cover-empty {
background: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
}
.popup-info {
flex: 1;
min-width: 0;
}
.popup-title {
font-size: 30rpx;
font-weight: 600;
color: #1e293b;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8rpx;
}
.popup-city {
font-size: 24rpx;
color: #64748b;
display: block;
margin-bottom: 8rpx;
}
.popup-price {
font-size: 24rpx;
display: block;
margin-bottom: 8rpx;
font-weight: 600;
}
.popup-price.free {
color: #16a34a;
}
.popup-price.paid {
color: #d97706;
}
.popup-rating {
display: flex;
align-items: center;
gap: 4rpx;
}
.popup-rating-num {
font-size: 24rpx;
color: #f59e0b;
font-weight: 600;
}
.popup-arrow {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(99, 102, 241, 0.1);
border-radius: 24rpx;
}
/* 底部面板 */
.bottom-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #f5f6fa;
border-radius: 32rpx 32rpx 0 0;
z-index: 20;
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-top {
flex-shrink: 0;
}
.panel-drag-zone {
display: flex;
align-items: center;
justify-content: center;
padding: 16rpx 0 8rpx;
flex-shrink: 0;
cursor: grab;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.panel-handle {
width: 80rpx;
height: 10rpx;
background: #cbd5e1;
border-radius: 5rpx;
pointer-events: none;
}
.banner-swiper {
width: calc(100% - 40rpx);
margin: 12rpx 20rpx 0;
height: 240rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
}
.banner-img {
width: 100%;
height: 240rpx;
border-radius: 16rpx;
}
.banner-title-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.5));
padding: 16rpx 20rpx 12rpx;
border-radius: 0 0 16rpx 16rpx;
}
.banner-title {
font-size: 26rpx;
color: #fff;
font-weight: 600;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10rpx 32rpx 8rpx;
flex-shrink: 0;
}
.panel-title {
font-size: 30rpx;
font-weight: 700;
color: #1e293b;
}
.panel-header-right {
display: flex;
align-items: center;
gap: 16rpx;
}
.sort-trigger {
display: flex;
align-items: center;
gap: 6rpx;
padding: 6rpx 16rpx;
background: rgba(99, 102, 241, 0.08);
border-radius: 20rpx;
}
.sort-label {
font-size: 22rpx;
color: #6366f1;
font-weight: 500;
}
.sort-dropdown {
background: #fff;
border-radius: 12rpx;
margin: 0 32rpx 8rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.sort-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 28rpx;
border-bottom: 1rpx solid #f1f5f9;
}
.sort-option:last-child { border-bottom: none; }
.sort-option.active { background: rgba(99, 102, 241, 0.04); }
.sort-option-text {
font-size: 26rpx;
color: #334155;
}
.sort-option.active .sort-option-text {
color: #6366f1;
font-weight: 600;
}
.panel-count {
font-size: 22rpx;
color: #94a3b8;
}
.panel-scroll {
flex: 1;
min-height: 0;
}
.panel-scroll ::-webkit-scrollbar {
display: none;
}
/* 瀑布流 */
.waterfall {
display: flex;
gap: 16rpx;
padding: 8rpx 24rpx 120rpx;
}
.waterfall-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.wf-card {
background: #ffffff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.wf-cover {
width: 100%;
}
.wf-cover-empty {
height: 200rpx;
background: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
}
.wf-info {
padding: 16rpx 18rpx 18rpx;
}
.wf-title {
font-size: 26rpx;
font-weight: 600;
color: #1e293b;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 6rpx;
}
.wf-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4rpx;
}
.wf-city {
font-size: 22rpx;
color: #6366f1;
}
.wf-rating {
display: flex;
align-items: center;
gap: 2rpx;
}
.wf-rating-num {
font-size: 20rpx;
color: #f59e0b;
font-weight: 600;
}
.wf-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4rpx;
}
.wf-price {
font-size: 22rpx;
font-weight: 600;
}
.wf-price.free { color: #16a34a; }
.wf-price.paid { color: #d97706; }
.wf-fav {
display: flex;
align-items: center;
gap: 2rpx;
}
.wf-fav-num {
font-size: 20rpx;
color: #94a3b8;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.status-tip.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
}
.empty-text {
margin-top: 12rpx;
font-size: 26rpx;
color: #94a3b8;
}
</style>