feat: 景区首页及商品详情头图展示优化调整,支持轮播
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
58
src/pages-service/tickets/components/TicketMediaSwiper.vue
Normal file
58
src/pages-service/tickets/components/TicketMediaSwiper.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user