feat: 景区首页及商品详情头图展示优化调整,支持轮播

This commit is contained in:
2026-08-12 11:49:50 +08:00
parent b066de2c8c
commit d5a92e1dae
4 changed files with 120 additions and 51 deletions

View File

@@ -2,24 +2,15 @@
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100"> <view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100">
<scroll-view class="min-h-0 flex-1" scroll-y> <scroll-view class="min-h-0 flex-1" scroll-y>
<view v-if="productImages.length" class="relative h-56 overflow-hidden bg-white"> <view v-if="productImages.length" class="relative h-56 overflow-hidden bg-white">
<image class="h-full w-full bg-slate-200" :src="productImages[0]" mode="aspectFill" /> <TicketMediaSwiper
<view class="absolute bottom-3 right-3 rounded-full bg-slate-950/65 px-2 py-1 text-[10px] text-white">1 / {{ productImages.length }}</view> :media-list="productImages"
class="h-full w-full bg-slate-200"
/>
</view> </view>
<view v-else class="flex h-56 items-center justify-center bg-slate-200"> <view v-else class="flex h-56 items-center justify-center bg-slate-200">
<text class="text-[12px] text-slate-400">暂无商品图片</text> <text class="text-[12px] text-slate-400">暂无商品图片</text>
</view> </view>
<view v-if="productImages.length > 1" class="flex gap-2 overflow-hidden bg-white px-4 pb-3 pt-2">
<image
v-for="(image, index) in productImages.slice(0, 4)"
:key="`${image}-${index}`"
class="h-11 w-16 rounded-lg object-cover"
:class="index === 0 ? 'ring-2 ring-blue-500' : 'opacity-70'"
:src="image"
mode="aspectFill"
/>
</view>
<view class="bg-white px-4 pb-4 pt-4"> <view class="bg-white px-4 pb-4 pt-4">
<view class="flex flex-wrap gap-1.5"> <view class="flex flex-wrap gap-1.5">
<text v-if="product.fulfillmentLabel" class="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600">{{ product.fulfillmentLabel }}</text> <text v-if="product.fulfillmentLabel" class="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600">{{ product.fulfillmentLabel }}</text>
@@ -32,7 +23,7 @@
<template v-else> <template v-else>
<text class="pb-0.5 text-xs">{{ currencySymbol }}</text> <text class="pb-0.5 text-xs">{{ currencySymbol }}</text>
<text class="text-3xl font-bold leading-none">{{ product.startingPrice }}</text> <text class="text-3xl font-bold leading-none">{{ product.startingPrice }}</text>
<text class="pb-0.5 text-[10px] text-slate-400"></text> <text class="pb-0.5 text-[10px] text-slate-400"></text>
</template> </template>
</view> </view>
</view> </view>
@@ -52,7 +43,7 @@
<view class="flex items-center justify-between"> <view class="flex items-center justify-between">
<view> <view>
<text class="text-[12px] text-slate-500">优惠价</text> <text class="text-[12px] text-slate-500">优惠价</text>
<text class="ml-[4px] text-[14px] font-medium text-[#f26b32]">{{ currencySymbol }}<text class="text-[25px] font-bold">{{ product.startingPrice }}</text></text> <text class="ml-[4px] text-[14px] font-medium text-[#f26b32]">{{ currencySymbol }}<text class="text-[25px] font-bold">{{ product.startingPrice }}</text></text>
</view> </view>
<view class="rounded-full bg-[#f26b32] px-[28px] py-[11px]" @click="emit('buy', product.commodityId)"> <view class="rounded-full bg-[#f26b32] px-[28px] py-[11px]" @click="emit('buy', product.commodityId)">
<text class="text-[15px] font-semibold text-white">{{ product.bookingButtonText || '去预订' }}</text> <text class="text-[15px] font-semibold text-white">{{ product.bookingButtonText || '去预订' }}</text>
@@ -64,15 +55,23 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import TicketMediaSwiper from "./TicketMediaSwiper.vue";
const props = defineProps({ const props = defineProps({
product: { type: Object, required: true }, product: { type: Object, required: true },
}); });
const emit = defineEmits(["buy"]); const emit = defineEmits(["buy"]);
const productImages = computed(() => const productImages = computed(() => [
[props.product.coverImage, ...(props.product.imageList || [])].filter(Boolean) ...new Set(
); [
props.product.coverImage,
...(Array.isArray(props.product.imageList) ? props.product.imageList : []),
]
.filter((image) => typeof image === "string" && image.trim())
.map((image) => image.trim())
),
]);
const detailSections = computed(() => const detailSections = computed(() =>
[...(props.product.detailSections || [])].sort( [...(props.product.detailSections || [])].sort(
(left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0) (left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)

View File

@@ -1,26 +1,9 @@
<template> <template>
<view class="h-screen overflow-hidden bg-[#06111f] flex flex-col"> <view class="h-screen overflow-hidden bg-[#06111f] flex flex-col">
<view class="relative z-0 h-[43vh] shrink-0 overflow-hidden bg-[#173a34]"> <view class="relative z-0 h-[43vh] shrink-0 overflow-hidden bg-[#173a34]">
<video <TicketMediaSwiper
v-if="homeData.coverImage && isMp4Video(homeData.coverImage)" :media-list="homeMediaList"
class="absolute inset-0 h-full w-full" class="absolute inset-0 h-full w-full"
:src="homeData.coverImage"
autoplay
loop
muted
:controls="false"
:show-center-play-btn="false"
:show-play-btn="false"
:show-fullscreen-btn="false"
:show-mute-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
/>
<image
v-else-if="homeData.coverImage"
class="absolute inset-0 h-full w-full"
:src="homeData.coverImage"
mode="aspectFill"
/> />
<view class="absolute inset-0 bg-[linear-gradient(180deg,rgba(6,17,31,0.15)_18%,rgba(6,17,31,0.1)_45%,rgba(6,17,31,0.92)_100%)]" /> <view class="absolute inset-0 bg-[linear-gradient(180deg,rgba(6,17,31,0.15)_18%,rgba(6,17,31,0.1)_45%,rgba(6,17,31,0.92)_100%)]" />
@@ -142,6 +125,7 @@
<script setup> <script setup>
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue' import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
import TopNavBar from "@/components/TopNavBar/index.vue" import TopNavBar from "@/components/TopNavBar/index.vue"
import TicketMediaSwiper from "./TicketMediaSwiper.vue"
const createCategoryScrollSelection = () => { const createCategoryScrollSelection = () => {
let shouldSyncFromScroll = true let shouldSyncFromScroll = true
@@ -187,11 +171,19 @@ const instance = getCurrentInstance()
const scenicMetaText = computed(() => const scenicMetaText = computed(() =>
[props.homeData.levelText, props.homeData.subtitle].filter(Boolean).join('') [props.homeData.levelText, props.homeData.subtitle].filter(Boolean).join('')
) )
const isMp4Video = (url) => { const homeMediaList = computed(() => {
if (typeof url !== 'string') return false const mediaList = Array.isArray(props.homeData.mediaList)
const resourcePath = url.trim().split(/[?#]/)[0] ? props.homeData.mediaList.filter(
return resourcePath.toLowerCase().endsWith('.mp4') (media) => typeof media === 'string' && media.trim()
} )
: []
if (mediaList.length) return mediaList
return typeof props.homeData.coverImage === 'string' &&
props.homeData.coverImage.trim()
? [props.homeData.coverImage]
: []
})
const isActiveGroup = (groupId) => const isActiveGroup = (groupId) =>
String(groupId) === String(activeGroupId.value) String(groupId) === String(activeGroupId.value)
const productsForGroup = (group) => const productsForGroup = (group) =>

View File

@@ -0,0 +1,58 @@
<template>
<swiper
class="h-full w-full"
:indicator-dots="false"
:autoplay="hasMultipleMedia"
:circular="hasMultipleMedia"
:interval="5000"
:duration="500"
>
<swiper-item
v-for="(media, index) in normalizedMediaList"
:key="`${media}-${index}`"
class="h-full w-full"
>
<video
v-if="isMp4Video(media)"
class="h-full w-full"
:src="media"
autoplay
loop
muted
:controls="false"
:show-center-play-btn="false"
:show-play-btn="false"
:show-fullscreen-btn="false"
:show-mute-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
/>
<image
v-else
class="h-full w-full"
:src="media"
mode="aspectFill"
/>
</swiper-item>
</swiper>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
mediaList: { type: Array, default: () => [] },
});
const normalizedMediaList = computed(() =>
props.mediaList
.filter((media) => typeof media === "string" && media.trim())
.map((media) => media.trim())
);
const hasMultipleMedia = computed(() => normalizedMediaList.value.length > 1);
const isMp4Video = (url) => {
if (typeof url !== "string") return false;
const resourcePath = url.trim().split(/[?#]/)[0];
return resourcePath.toLowerCase().endsWith(".mp4");
};
</script>

View File

@@ -155,13 +155,16 @@ test("TicketHome consumes scenic home response fields directly", async () => {
); );
}); });
test("TicketHome renders MP4 cover images as background videos", async () => { test("tickets use a local indicator-free media swiper", async () => {
const source = await readTicketFile("components/TicketHome.vue"); const source = await readTicketFile("components/TicketMediaSwiper.vue");
assert.match( assert.match(source, /<swiper/);
source, assert.match(source, /:indicator-dots="false"/);
/<video\s+v-if="homeData\.coverImage && isMp4Video\(homeData\.coverImage\)"/ assert.match(source, /:autoplay="hasMultipleMedia"/);
); assert.match(source, /:circular="hasMultipleMedia"/);
assert.match(source, /:interval="5000"/);
assert.match(source, /v-for="\(media, index\) in normalizedMediaList"/);
assert.match(source, /<video\s+v-if="isMp4Video\(media\)"/);
assert.match(source, /autoplay\s+loop\s+muted/); assert.match(source, /autoplay\s+loop\s+muted/);
assert.match(source, /:controls="false"/); assert.match(source, /:controls="false"/);
assert.match(source, /:show-center-play-btn="false"/); assert.match(source, /:show-center-play-btn="false"/);
@@ -170,10 +173,23 @@ test("TicketHome renders MP4 cover images as background videos", async () => {
assert.match(source, /:show-mute-btn="false"/); assert.match(source, /:show-mute-btn="false"/);
assert.match(source, /:enable-progress-gesture="false"/); assert.match(source, /:enable-progress-gesture="false"/);
assert.match(source, /object-fit="cover"/); assert.match(source, /object-fit="cover"/);
assert.match(source, /<image\s+v-else-if="homeData\.coverImage"/); assert.match(source, /<image\s+v-else/);
assert.match(source, /const isMp4Video = \(url\) =>/); assert.match(source, /const isMp4Video = \(url\) =>/);
assert.match(source, /url\.trim\(\)\.split\(\/\[\?\#\]\/\)\[0\]/); assert.match(source, /url\.trim\(\)\.split\(\/\[\?\#\]\/\)\[0\]/);
assert.match(source, /toLowerCase\(\)\.endsWith\('\.mp4'\)/); assert.match(source, /toLowerCase\(\)\.endsWith\("\.mp4"\)/);
});
test("TicketHome prefers mediaList and falls back to coverImage", async () => {
const source = await readTicketFile("components/TicketHome.vue");
assert.match(source, /import TicketMediaSwiper from "\.\/TicketMediaSwiper\.vue"/);
assert.match(source, /<TicketMediaSwiper\s+:media-list="homeMediaList"/);
assert.match(source, /props\.homeData\.mediaList/);
assert.match(source, /const homeMediaList = computed\(\(\) =>/);
assert.match(source, /if \(mediaList\.length\) return mediaList/);
assert.match(source, /props\.homeData\.coverImage/);
assert.doesNotMatch(source, /<video/);
assert.doesNotMatch(source, /<image\s+v-else-if="homeData\.coverImage"/);
}); });
test("product detail is an independent prototype-aligned page", async () => { test("product detail is an independent prototype-aligned page", async () => {
@@ -197,8 +213,12 @@ test("product detail is an independent prototype-aligned page", async () => {
assert.doesNotMatch(detailPage, /useTicketStore|ticketStore\.products/); assert.doesNotMatch(detailPage, /useTicketStore|ticketStore\.products/);
assert.match(detailPage, /<ProductDetail\s+v-else-if="product"/); assert.match(detailPage, /<ProductDetail\s+v-else-if="product"/);
assert.match(detailPage, /\/pages-service\/tickets\/booking\?productId=/); assert.match(detailPage, /\/pages-service\/tickets\/booking\?productId=/);
assert.match(component, /import TicketMediaSwiper from "\.\/TicketMediaSwiper\.vue"/);
assert.match(component, /<TicketMediaSwiper\s+:media-list="productImages"/);
assert.match(component, /relative h-56 overflow-hidden bg-white/); assert.match(component, /relative h-56 overflow-hidden bg-white/);
assert.match(component, /1 \/ \{\{ productImages\.length \}\}/); assert.doesNotMatch(component, /1 \/ \{\{ productImages\.length \}\}/);
assert.doesNotMatch(component, /productImages\.slice/);
assert.match(component, /new Set/);
assert.match(component, /rounded bg-blue-50 px-1\.5 py-0\.5 text-\[10px\] font-semibold text-blue-600/); assert.match(component, /rounded bg-blue-50 px-1\.5 py-0\.5 text-\[10px\] font-semibold text-blue-600/);
for (const field of [ for (const field of [
"product.coverImage", "product.coverImage",