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">
<scroll-view class="min-h-0 flex-1" scroll-y>
<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" />
<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>
<TicketMediaSwiper
:media-list="productImages"
class="h-full w-full bg-slate-200"
/>
</view>
<view v-else class="flex h-56 items-center justify-center bg-slate-200">
<text class="text-[12px] text-slate-400">暂无商品图片</text>
</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="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>
@@ -32,7 +23,7 @@
<template v-else>
<text class="pb-0.5 text-xs">{{ currencySymbol }}</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>
</view>
</view>
@@ -52,7 +43,7 @@
<view class="flex items-center justify-between">
<view>
<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 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>
@@ -64,15 +55,23 @@
<script setup>
import { computed } from "vue";
import TicketMediaSwiper from "./TicketMediaSwiper.vue";
const props = defineProps({
product: { type: Object, required: true },
});
const emit = defineEmits(["buy"]);
const productImages = computed(() =>
[props.product.coverImage, ...(props.product.imageList || [])].filter(Boolean)
);
const productImages = computed(() => [
...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(() =>
[...(props.product.detailSections || [])].sort(
(left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)

View File

@@ -1,26 +1,9 @@
<template>
<view class="h-screen overflow-hidden bg-[#06111f] flex flex-col">
<view class="relative z-0 h-[43vh] shrink-0 overflow-hidden bg-[#173a34]">
<video
v-if="homeData.coverImage && isMp4Video(homeData.coverImage)"
<TicketMediaSwiper
:media-list="homeMediaList"
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%)]" />
@@ -142,6 +125,7 @@
<script setup>
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
import TopNavBar from "@/components/TopNavBar/index.vue"
import TicketMediaSwiper from "./TicketMediaSwiper.vue"
const createCategoryScrollSelection = () => {
let shouldSyncFromScroll = true
@@ -187,11 +171,19 @@ const instance = getCurrentInstance()
const scenicMetaText = computed(() =>
[props.homeData.levelText, props.homeData.subtitle].filter(Boolean).join('')
)
const isMp4Video = (url) => {
if (typeof url !== 'string') return false
const resourcePath = url.trim().split(/[?#]/)[0]
return resourcePath.toLowerCase().endsWith('.mp4')
}
const homeMediaList = computed(() => {
const mediaList = Array.isArray(props.homeData.mediaList)
? props.homeData.mediaList.filter(
(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) =>
String(groupId) === String(activeGroupId.value)
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 () => {
const source = await readTicketFile("components/TicketHome.vue");
test("tickets use a local indicator-free media swiper", async () => {
const source = await readTicketFile("components/TicketMediaSwiper.vue");
assert.match(
source,
/<video\s+v-if="homeData\.coverImage && isMp4Video\(homeData\.coverImage\)"/
);
assert.match(source, /<swiper/);
assert.match(source, /:indicator-dots="false"/);
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, /:controls="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, /:enable-progress-gesture="false"/);
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, /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 () => {
@@ -197,8 +213,12 @@ test("product detail is an independent prototype-aligned page", async () => {
assert.doesNotMatch(detailPage, /useTicketStore|ticketStore\.products/);
assert.match(detailPage, /<ProductDetail\s+v-else-if="product"/);
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, /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/);
for (const field of [
"product.coverImage",