3f3acf834d
为了解决H5平台地图定位显示的问题,在index页面和pick-location页面中添加了 mapShowLocation响应式变量,并通过条件编译确保仅在非H5平台启用地图定位功能, 避免H5环境下出现定位相关的兼容性问题。
1101 lines
26 KiB
Vue
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>
|