Compare commits
5
Commits
1984f3ed97
...
home3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b36f2864bb | ||
|
|
951bdf523f | ||
|
|
d5a92e1dae | ||
|
|
b066de2c8c | ||
|
|
6c0384f60f |
No files matched your search
@@ -5,10 +5,10 @@ const scenicProductHome = (args) => {
|
||||
return request.post("/hotelBiz/commodity/scenicProductHome", args);
|
||||
};
|
||||
|
||||
/// 景区分类商品分页列表
|
||||
const scenicCategoryProductsList = (args) => {
|
||||
return request.post("/hotelBiz/commodity/scenicCategoryProducts", args);
|
||||
/// 景区分类商品详情
|
||||
const scenicProductDetail = (args) => {
|
||||
return request.post("/hotelBiz/commodity/productDetailV2", args);
|
||||
};
|
||||
|
||||
export { scenicProductHome, scenicCategoryProductsList };
|
||||
export { scenicProductHome, scenicProductDetail };
|
||||
|
||||
@@ -1,73 +1,84 @@
|
||||
<template>
|
||||
<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 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>
|
||||
</view>
|
||||
<view class="relative flex h-full min-w-0 flex-col overflow-hidden bg-slate-100">
|
||||
<view
|
||||
v-if="isTabsPinned && detailSections.length"
|
||||
class="absolute left-0 right-0 top-0 z-30 border-b border-slate-100 bg-white shadow-[0_4px_12px_rgba(15,23,42,0.08)]"
|
||||
>
|
||||
<TicketDetailTabs
|
||||
:sections="detailSections"
|
||||
:active-section-code="activeSectionCode"
|
||||
instance-key="pinned"
|
||||
@select="selectDetailSection"
|
||||
/>
|
||||
</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"
|
||||
<scroll-view
|
||||
id="ticket-detail-scroll"
|
||||
class="min-h-0 flex-1"
|
||||
scroll-y
|
||||
:scroll-top="requestedScrollTop"
|
||||
scroll-with-animation
|
||||
@scroll="handleDetailScroll"
|
||||
@touchstart="enableDetailScrollSync"
|
||||
>
|
||||
<view v-if="productImages.length" class="relative h-56 overflow-hidden bg-white">
|
||||
<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 class="bg-white px-4 pb-4 pt-4">
|
||||
<view class="flex flex-wrap gap-1.5">
|
||||
<text v-for="highlight in product.highlights || []" :key="highlight" class="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600">{{ highlight }}</text>
|
||||
<text v-if="!(product.highlights || []).length" class="text-[10px] text-slate-400">尚未配置销售标签</text>
|
||||
</view>
|
||||
<text class="mt-2 block text-lg font-bold leading-7 text-slate-950">{{ product.name }}</text>
|
||||
<text class="mt-1 block text-xs leading-5 text-slate-500">{{ product.description || product.shortTitle || '暂无商品简介' }}</text>
|
||||
<view v-if="product.ageRule" class="mt-3 rounded-xl border border-violet-100 bg-violet-50 p-3 text-[11px] leading-5 text-violet-700">
|
||||
<text class="font-bold">年龄限制:{{ ageRuleText }}</text>
|
||||
<text class="block">下单时仅对门票游客按使用日和身份证校验。</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>
|
||||
<text v-for="tag in product.tags || []" :key="tag" class="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600">{{ tag }}</text>
|
||||
</view>
|
||||
<text class="mt-2 block text-lg font-bold leading-7 text-slate-950">{{ product.commodityName }}</text>
|
||||
<text v-if="product.subtitle" class="mt-1 block text-xs leading-5 text-slate-500">{{ product.subtitle }}</text>
|
||||
<view class="mt-3 flex items-end gap-1 text-orange-600">
|
||||
<text v-if="Number(product.salePrice) === 0" class="text-2xl font-bold leading-none">免费预约</text>
|
||||
<text v-if="Number(product.startingPrice) === 0" class="text-2xl font-bold leading-none">免费预约</text>
|
||||
<template v-else>
|
||||
<text class="pb-0.5 text-xs">¥</text>
|
||||
<text class="text-3xl font-bold leading-none">{{ product.salePrice }}</text>
|
||||
<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>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
||||
<text class="block text-[16px] font-semibold text-slate-800">费用包含</text>
|
||||
<text v-for="item in product.included || []" :key="item" class="mt-[10px] block text-[14px] leading-[20px] text-slate-600">· {{ item }}</text>
|
||||
<text v-if="!(product.included || []).length" class="mt-[10px] block text-[14px] text-slate-400">以商品说明为准</text>
|
||||
<view
|
||||
v-if="detailSections.length"
|
||||
id="ticket-detail-tabs-anchor"
|
||||
class="mt-[12px] border-y border-slate-100 bg-white"
|
||||
>
|
||||
<TicketDetailTabs
|
||||
:sections="detailSections"
|
||||
:active-section-code="activeSectionCode"
|
||||
instance-key="inline"
|
||||
@select="selectDetailSection"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
||||
<text class="block text-[16px] font-semibold text-slate-800">费用不含</text>
|
||||
<text v-for="item in product.excluded || []" :key="item" class="mt-[10px] block text-[14px] leading-[20px] text-slate-600">· {{ item }}</text>
|
||||
<text v-if="!(product.excluded || []).length" class="mt-[10px] block text-[14px] text-slate-400">无</text>
|
||||
</view>
|
||||
|
||||
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
||||
<text class="block text-[16px] font-semibold text-slate-800">商品详情</text>
|
||||
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.detail || product.description || '暂无商品详情' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
||||
<text class="block text-[16px] font-semibold text-slate-800">购买须知</text>
|
||||
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.purchaseNotice || '请以实际预约规则为准。' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
||||
<text class="block text-[16px] font-semibold text-slate-800">使用说明</text>
|
||||
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.useRule || '请按预约日期和场次使用,具体以出票凭证及现场指引为准。' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mx-[12px] mb-[20px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
||||
<text class="block text-[16px] font-semibold text-slate-800">退款说明</text>
|
||||
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.refundRule || '未使用且符合商品退改规则时可申请退款,最终结果以订单审核为准。' }}</text>
|
||||
<view
|
||||
v-for="(section, index) in detailSections"
|
||||
:id="`ticket-detail-section-${index}`"
|
||||
:key="section.sectionCode"
|
||||
class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]"
|
||||
:class="section === detailSections[detailSections.length - 1] ? 'mb-[20px]' : ''"
|
||||
>
|
||||
<text class="block text-[16px] font-semibold text-slate-800">{{ section.title }}</text>
|
||||
<view class="mt-[10px] text-[14px] leading-[22px] text-slate-600">
|
||||
<MpHtml
|
||||
:content="section.content || ''"
|
||||
:preview-img="false"
|
||||
:show-img-menu="false"
|
||||
:set-title="false"
|
||||
:tag-style="detailContentTagStyle"
|
||||
@ready="measureDetailSectionOffsets"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
@@ -75,10 +86,10 @@
|
||||
<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]">¥<text class="text-[25px] font-bold">{{ product.salePrice }}</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.id)">
|
||||
<text class="text-[15px] font-semibold text-white">去预订</text>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -86,19 +97,243 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import MpHtml from "@/uni_modules/zero-markdown-view/components/mp-html/mp-html.vue";
|
||||
import TicketDetailTabs from "./TicketDetailTabs.vue";
|
||||
import TicketMediaSwiper from "./TicketMediaSwiper.vue";
|
||||
|
||||
const createDetailSectionScrollSelection = () => {
|
||||
let shouldSyncFromScroll = true;
|
||||
|
||||
return {
|
||||
select(sectionCode) {
|
||||
shouldSyncFromScroll = false;
|
||||
return String(sectionCode ?? "");
|
||||
},
|
||||
resumeScrollSync() {
|
||||
shouldSyncFromScroll = true;
|
||||
},
|
||||
resolve(currentSectionCode, scrollSectionCode) {
|
||||
return shouldSyncFromScroll
|
||||
? String(scrollSectionCode ?? "")
|
||||
: String(currentSectionCode ?? "");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
product: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["buy"]);
|
||||
const productImages = computed(() =>
|
||||
(props.product.images || []).filter(Boolean)
|
||||
const detailContentTagStyle = {
|
||||
p: "margin: 8px 0; line-height: 1.7; color: #475569; word-break: break-word;",
|
||||
img: "max-width: 100%; height: auto; display: block; margin: 10px auto;",
|
||||
ul: "margin: 8px 0; padding-left: 20px; color: #475569;",
|
||||
ol: "margin: 8px 0; padding-left: 20px; color: #475569;",
|
||||
li: "margin: 4px 0; line-height: 1.7; color: #475569;",
|
||||
blockquote:
|
||||
"margin: 10px 0; padding: 8px 12px; border-left: 3px solid #cbd5e1; background-color: #f8fafc; color: #64748b;",
|
||||
strong: "font-weight: 700; color: #334155;",
|
||||
};
|
||||
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)
|
||||
)
|
||||
);
|
||||
const activeSectionCode = ref(
|
||||
String(detailSections.value[0]?.sectionCode ?? "")
|
||||
);
|
||||
const requestedScrollTop = ref(0);
|
||||
const currentDetailScrollTop = ref(0);
|
||||
const detailScrollHeight = ref(0);
|
||||
const detailScrollViewportHeight = ref(0);
|
||||
const tabsOffsetTop = ref(Number.POSITIVE_INFINITY);
|
||||
const tabsHeight = ref(48);
|
||||
const detailSectionOffsets = ref([]);
|
||||
const isTabsPinned = ref(false);
|
||||
const pendingSectionCode = ref("");
|
||||
const detailSectionScrollSelection =
|
||||
createDetailSectionScrollSelection();
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const scrollToMeasuredDetailSection = (sectionCode) => {
|
||||
const normalizedSectionCode = String(sectionCode ?? "");
|
||||
const sectionOffset = detailSectionOffsets.value.find(
|
||||
(section) => section.code === normalizedSectionCode
|
||||
);
|
||||
if (!sectionOffset) return false;
|
||||
|
||||
const targetScrollTop = Math.max(
|
||||
0,
|
||||
sectionOffset.top - tabsHeight.value
|
||||
);
|
||||
requestedScrollTop.value = currentDetailScrollTop.value;
|
||||
nextTick(() => {
|
||||
requestedScrollTop.value = targetScrollTop;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const selectDetailSection = (sectionCode) => {
|
||||
activeSectionCode.value = detailSectionScrollSelection.select(sectionCode);
|
||||
if (scrollToMeasuredDetailSection(activeSectionCode.value)) return;
|
||||
|
||||
pendingSectionCode.value = activeSectionCode.value;
|
||||
measureDetailSectionOffsets();
|
||||
};
|
||||
|
||||
const enableDetailScrollSync = () => {
|
||||
detailSectionScrollSelection.resumeScrollSync();
|
||||
};
|
||||
|
||||
const resolveScrollSectionCode = (scrollTop, scrollHeight) => {
|
||||
if (!detailSectionOffsets.value.length) return "";
|
||||
|
||||
const isAtBottom =
|
||||
scrollHeight > 0 &&
|
||||
detailScrollViewportHeight.value > 0 &&
|
||||
scrollTop + detailScrollViewportHeight.value >=
|
||||
scrollHeight - 4;
|
||||
if (isAtBottom) {
|
||||
return detailSectionOffsets.value[detailSectionOffsets.value.length - 1]
|
||||
.code;
|
||||
}
|
||||
|
||||
const activationLine =
|
||||
scrollTop + (isTabsPinned.value ? tabsHeight.value + 8 : 8);
|
||||
return detailSectionOffsets.value.reduce(
|
||||
(current, section) =>
|
||||
section.top <= activationLine ? section : current,
|
||||
detailSectionOffsets.value[0]
|
||||
).code;
|
||||
};
|
||||
|
||||
const syncActiveDetailSection = (scrollTop, scrollHeight) => {
|
||||
const scrollSectionCode = resolveScrollSectionCode(
|
||||
scrollTop,
|
||||
scrollHeight
|
||||
);
|
||||
if (!scrollSectionCode) return;
|
||||
|
||||
activeSectionCode.value = detailSectionScrollSelection.resolve(
|
||||
activeSectionCode.value,
|
||||
scrollSectionCode
|
||||
);
|
||||
};
|
||||
|
||||
const measureDetailSectionOffsets = () => {
|
||||
if (!detailSections.value.length) {
|
||||
tabsOffsetTop.value = Number.POSITIVE_INFINITY;
|
||||
detailSectionOffsets.value = [];
|
||||
isTabsPinned.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const query = uni.createSelectorQuery().in(instance?.proxy);
|
||||
query.select("#ticket-detail-scroll").boundingClientRect();
|
||||
query.select("#ticket-detail-tabs-anchor").boundingClientRect();
|
||||
detailSections.value.forEach((section, index) => {
|
||||
query
|
||||
.select(`#ticket-detail-section-${index}`)
|
||||
.boundingClientRect();
|
||||
});
|
||||
query.exec((rects = []) => {
|
||||
const scrollRect = rects[0];
|
||||
const tabsRect = rects[1];
|
||||
if (!scrollRect || !tabsRect) return;
|
||||
|
||||
const scrollTop = currentDetailScrollTop.value;
|
||||
detailScrollViewportHeight.value = Number(scrollRect.height || 0);
|
||||
tabsOffsetTop.value = Math.max(
|
||||
0,
|
||||
Number(tabsRect.top || 0) - Number(scrollRect.top || 0) + scrollTop
|
||||
);
|
||||
tabsHeight.value = Number(tabsRect.height || 48);
|
||||
detailSectionOffsets.value = detailSections.value
|
||||
.map((section, index) => {
|
||||
const sectionRect = rects[index + 2];
|
||||
if (!sectionRect) return null;
|
||||
return {
|
||||
code: String(section.sectionCode ?? ""),
|
||||
top: Math.max(
|
||||
0,
|
||||
Number(sectionRect.top || 0) -
|
||||
Number(scrollRect.top || 0) +
|
||||
scrollTop
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
isTabsPinned.value = scrollTop >= tabsOffsetTop.value;
|
||||
syncActiveDetailSection(scrollTop, detailScrollHeight.value);
|
||||
|
||||
if (pendingSectionCode.value) {
|
||||
const sectionCode = pendingSectionCode.value;
|
||||
pendingSectionCode.value = "";
|
||||
scrollToMeasuredDetailSection(sectionCode);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDetailScroll = (event) => {
|
||||
const scrollTop = Number(event.detail?.scrollTop || 0);
|
||||
const scrollHeight = Number(event.detail?.scrollHeight || 0);
|
||||
currentDetailScrollTop.value = scrollTop;
|
||||
detailScrollHeight.value = scrollHeight;
|
||||
isTabsPinned.value = scrollTop >= tabsOffsetTop.value;
|
||||
|
||||
if (!detailSectionOffsets.value.length) {
|
||||
measureDetailSectionOffsets();
|
||||
return;
|
||||
}
|
||||
syncActiveDetailSection(scrollTop, scrollHeight);
|
||||
};
|
||||
|
||||
onMounted(measureDetailSectionOffsets);
|
||||
watch(
|
||||
() =>
|
||||
detailSections.value
|
||||
.map((section) => String(section.sectionCode ?? ""))
|
||||
.join(","),
|
||||
() => {
|
||||
const activeExists = detailSections.value.some(
|
||||
(section) =>
|
||||
String(section.sectionCode ?? "") === activeSectionCode.value
|
||||
);
|
||||
if (!activeExists) {
|
||||
activeSectionCode.value = String(
|
||||
detailSections.value[0]?.sectionCode ?? ""
|
||||
);
|
||||
}
|
||||
detailSectionScrollSelection.resumeScrollSync();
|
||||
detailSectionOffsets.value = [];
|
||||
tabsOffsetTop.value = Number.POSITIVE_INFINITY;
|
||||
isTabsPinned.value = false;
|
||||
measureDetailSectionOffsets();
|
||||
}
|
||||
);
|
||||
const currencySymbol = computed(() =>
|
||||
props.product.currency === "CNY" ? "¥" : props.product.currency || ""
|
||||
);
|
||||
const ageRuleText = computed(() => {
|
||||
const { min, max } = props.product.ageRule || {};
|
||||
if (Number(max) >= 999) return `${min}周岁及以上`;
|
||||
return `${min}—${max}周岁`;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<scroll-view
|
||||
class="h-[48px] w-full whitespace-nowrap bg-white"
|
||||
scroll-x
|
||||
:scroll-into-view="activeTabId"
|
||||
scroll-with-animation
|
||||
>
|
||||
<view class="inline-flex h-[48px] min-w-full items-center px-[8px]">
|
||||
<view
|
||||
v-for="(section, index) in sections"
|
||||
:id="`ticket-detail-tab-${instanceKey}-${index}`"
|
||||
:key="`${section.sectionCode}-${index}`"
|
||||
class="relative inline-flex h-[48px] flex-none items-center justify-center px-[14px]"
|
||||
@click="emit('select', section.sectionCode)"
|
||||
>
|
||||
<text
|
||||
class="text-[14px] leading-[20px]"
|
||||
:class="isActiveSection(section.sectionCode) ? 'font-semibold text-[#f26b32]' : 'font-normal text-slate-500'"
|
||||
>
|
||||
{{ section.title }}
|
||||
</text>
|
||||
<view
|
||||
v-if="isActiveSection(section.sectionCode)"
|
||||
class="absolute bottom-0 left-1/2 h-[3px] w-[20px] -translate-x-1/2 rounded-full bg-[#f26b32]"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
sections: { type: Array, default: () => [] },
|
||||
activeSectionCode: { type: [String, Number], default: "" },
|
||||
instanceKey: { type: String, default: "default" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
const activeIndex = computed(() =>
|
||||
props.sections.findIndex(
|
||||
(section) =>
|
||||
String(section.sectionCode) === String(props.activeSectionCode)
|
||||
)
|
||||
);
|
||||
const activeTabId = computed(() =>
|
||||
activeIndex.value >= 0
|
||||
? `ticket-detail-tab-${props.instanceKey}-${activeIndex.value}`
|
||||
: ""
|
||||
);
|
||||
const isActiveSection = (sectionCode) =>
|
||||
String(sectionCode) === String(props.activeSectionCode);
|
||||
</script>
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
@@ -77,9 +77,9 @@ const openTravelers = () =>
|
||||
uni.navigateTo({ url: "/pages-service/tickets/travelers" });
|
||||
const openOrders = () =>
|
||||
uni.navigateTo({ url: "/pages-service/tickets/orders" });
|
||||
const openProductDetail = (productId) =>
|
||||
const openProductDetail = (commodityId) =>
|
||||
uni.navigateTo({
|
||||
url: `/pages-service/tickets/product-detail?productId=${productId}`,
|
||||
url: `/pages-service/tickets/product-detail?commodityId=${commodityId}`,
|
||||
});
|
||||
const startBooking = (productId) =>
|
||||
uni.navigateTo({
|
||||
|
||||
@@ -6,31 +6,83 @@
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
/>
|
||||
<view
|
||||
v-if="loading"
|
||||
class="flex min-h-0 flex-1 items-center justify-center"
|
||||
>
|
||||
<text class="text-[14px] text-slate-400">正在加载商品详情...</text>
|
||||
</view>
|
||||
<view
|
||||
v-else-if="errorMessage"
|
||||
class="flex min-h-0 flex-1 flex-col items-center justify-center px-[32px] text-center"
|
||||
>
|
||||
<text class="text-[14px] leading-[21px] text-slate-400">
|
||||
{{ errorMessage }}
|
||||
</text>
|
||||
<view
|
||||
class="mt-[18px] flex h-[38px] items-center justify-center rounded-full bg-[#f26b32] px-[22px]"
|
||||
@click="loadProductDetail"
|
||||
>
|
||||
<text class="text-[13px] font-medium text-white">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
<ProductDetail
|
||||
v-if="product"
|
||||
v-else-if="product"
|
||||
class="min-h-0 flex-1"
|
||||
:product="product"
|
||||
@buy="startBooking"
|
||||
/>
|
||||
<view v-else class="flex min-h-0 flex-1 items-center justify-center text-sm text-slate-400">商品不存在或已下架</view>
|
||||
<view v-else class="flex min-h-0 flex-1 items-center justify-center">
|
||||
<text class="text-[14px] text-slate-400">商品不存在或已下架</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import ProductDetail from "./components/ProductDetail.vue";
|
||||
import { useTicketStore } from "./store.js";
|
||||
import { scenicProductDetail } from "./api/scenic.js";
|
||||
|
||||
const ticketStore = useTicketStore();
|
||||
const productId = ref("");
|
||||
const product = computed(() =>
|
||||
ticketStore.products.find((item) => item.id === productId.value)
|
||||
);
|
||||
const commodityId = ref("");
|
||||
const product = ref(null);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
onLoad((options) => {
|
||||
productId.value = options?.productId || "";
|
||||
const loadProductDetail = async () => {
|
||||
if (!commodityId.value) {
|
||||
product.value = null;
|
||||
errorMessage.value = "缺少商品参数";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await scenicProductDetail({ commodityId: commodityId.value });
|
||||
const validData =
|
||||
response?.data &&
|
||||
typeof response.data === "object" &&
|
||||
!Array.isArray(response.data);
|
||||
|
||||
if (response?.code !== 0 || !validData) {
|
||||
throw new Error(response?.msg || "商品详情加载失败,请稍后重试");
|
||||
}
|
||||
|
||||
product.value = response.data;
|
||||
} catch (error) {
|
||||
product.value = null;
|
||||
errorMessage.value = error?.message || "商品详情加载失败,请稍后重试";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(async (options) => {
|
||||
commodityId.value = options?.commodityId || "";
|
||||
await loadProductDetail();
|
||||
});
|
||||
|
||||
const startBooking = (selectedProductId) =>
|
||||
|
||||
@@ -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,35 +173,170 @@ 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("ticket detail section tabs scroll horizontally and keep the active tab visible", async () => {
|
||||
const tabs = await readTicketFile("components/TicketDetailTabs.vue");
|
||||
|
||||
assert.match(tabs, /<scroll-view/);
|
||||
assert.match(tabs, /scroll-x/);
|
||||
assert.match(tabs, /:scroll-into-view="activeTabId"/);
|
||||
assert.match(tabs, /scroll-with-animation/);
|
||||
assert.match(tabs, /v-for="\(section, index\) in sections"/);
|
||||
assert.match(tabs, /section\.title/);
|
||||
assert.match(tabs, /emit\('select', section\.sectionCode\)/);
|
||||
assert.match(tabs, /isActiveSection\(section\.sectionCode\)/);
|
||||
});
|
||||
|
||||
test("product detail is an independent prototype-aligned page", async () => {
|
||||
const indexPage = await readTicketFile("index.vue");
|
||||
const detailPage = await readTicketFile("product-detail.vue");
|
||||
const component = await readTicketFile("components/ProductDetail.vue");
|
||||
const data = await readTicketFile("data.js");
|
||||
|
||||
assert.match(indexPage, /uni\.navigateTo\(\{\s*url: `\/pages-service\/tickets\/product-detail\?productId=\$\{productId\}`/);
|
||||
assert.match(indexPage, /uni\.navigateTo\(\{\s*url: `\/pages-service\/tickets\/product-detail\?commodityId=\$\{commodityId\}`/);
|
||||
assert.doesNotMatch(indexPage, /detailProduct|<ProductDetail/);
|
||||
assert.match(detailPage, /onLoad\(\(options\)/);
|
||||
assert.match(detailPage, /options\?\.productId/);
|
||||
assert.match(detailPage, /<ProductDetail/);
|
||||
assert.match(detailPage, /onLoad\(async \(options\)/);
|
||||
assert.match(detailPage, /options\?\.commodityId/);
|
||||
assert.match(
|
||||
detailPage,
|
||||
/import \{ scenicProductDetail \} from "\.\/api\/scenic\.js"/
|
||||
);
|
||||
assert.match(detailPage, /scenicProductDetail\(\{ commodityId: commodityId\.value \}\)/);
|
||||
assert.match(detailPage, /product\.value = response\.data/);
|
||||
assert.match(detailPage, /v-if="loading"/);
|
||||
assert.match(detailPage, /v-else-if="errorMessage"/);
|
||||
assert.match(detailPage, /@click="loadProductDetail"/);
|
||||
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, /import TicketDetailTabs from "\.\/TicketDetailTabs\.vue"/);
|
||||
assert.match(component, /<TicketMediaSwiper\s+:media-list="productImages"/);
|
||||
assert.equal((component.match(/<TicketDetailTabs/g) || []).length, 2);
|
||||
assert.match(component, /instance-key="inline"/);
|
||||
assert.match(component, /instance-key="pinned"/);
|
||||
assert.match(component, /v-if="isTabsPinned && detailSections\.length"/);
|
||||
assert.match(component, /id="ticket-detail-tabs-anchor"/);
|
||||
assert.match(component, /id="ticket-detail-scroll"/);
|
||||
assert.match(component, /:scroll-top="requestedScrollTop"/);
|
||||
assert.match(component, /@scroll="handleDetailScroll"/);
|
||||
assert.match(component, /@touchstart="enableDetailScrollSync"/);
|
||||
assert.match(component, /:id="`ticket-detail-section-\$\{index\}`"/);
|
||||
assert.match(component, /@ready="measureDetailSectionOffsets"/);
|
||||
assert.match(
|
||||
component,
|
||||
/uni\.createSelectorQuery\(\)\.in\(instance\?\.proxy\)/
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/scrollTop \+ detailScrollViewportHeight\.value >=\s*scrollHeight - 4/
|
||||
);
|
||||
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.doesNotMatch(component, /bg-violet-50 px-\[7px\] py-\[3px\] text-\[11px\] text-violet-700/);
|
||||
assert.match(component, /product\.description/);
|
||||
assert.match(component, /使用说明/);
|
||||
assert.match(component, /product\.useRule/);
|
||||
assert.match(component, /退款说明/);
|
||||
assert.match(component, /product\.refundRule/);
|
||||
assert.match(data, /useRule/);
|
||||
assert.match(data, /refundRule/);
|
||||
for (const field of [
|
||||
"product.coverImage",
|
||||
"product.imageList",
|
||||
"product.tags",
|
||||
"product.fulfillmentLabel",
|
||||
"product.commodityName",
|
||||
"product.subtitle",
|
||||
"product.startingPrice",
|
||||
"product.currency",
|
||||
"product.detailSections",
|
||||
"product.bookingButtonText",
|
||||
"product.commodityId",
|
||||
]) {
|
||||
assert.ok(component.includes(field), `${field} is not rendered`);
|
||||
}
|
||||
assert.match(component, /v-for="\(section, index\) in detailSections"/);
|
||||
assert.match(component, /section\.sectionCode/);
|
||||
assert.match(component, /section\.title/);
|
||||
assert.match(component, /section\.content/);
|
||||
assert.match(
|
||||
component,
|
||||
/import MpHtml from "@\/uni_modules\/zero-markdown-view\/components\/mp-html\/mp-html\.vue"/
|
||||
);
|
||||
assert.match(component, /<MpHtml/);
|
||||
assert.match(component, /:content="section\.content \|\| ''"/);
|
||||
assert.match(component, /:preview-img="false"/);
|
||||
assert.match(component, /:show-img-menu="false"/);
|
||||
assert.match(component, /:set-title="false"/);
|
||||
assert.match(component, /:tag-style="detailContentTagStyle"/);
|
||||
assert.match(
|
||||
component,
|
||||
/img:\s*"[^"]*max-width:\s*100%[^"]*height:\s*auto/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
component,
|
||||
/<text[^>]*>\{\{\s*section\.content\s*\}\}<\/text>/
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/sort\(\s*\(left, right\) => Number\(left\.sortOrder/
|
||||
);
|
||||
assert.match(component, /emit\('buy', product\.commodityId\)/);
|
||||
assert.doesNotMatch(
|
||||
component,
|
||||
/product\.name|product\.salePrice|product\.included|product\.excluded|product\.purchaseNotice|product\.useRule|product\.refundRule|product\.id/
|
||||
);
|
||||
});
|
||||
|
||||
test("product detail tab clicks win until the visitor resumes manual scrolling", async () => {
|
||||
const component = await readTicketFile("components/ProductDetail.vue");
|
||||
const factorySource = component.match(
|
||||
/const createDetailSectionScrollSelection = \(\) => \{[\s\S]*?^\};?\n/m
|
||||
)?.[0];
|
||||
|
||||
assert.ok(factorySource, "inline detail section scroll selection factory missing");
|
||||
assert.match(component, /const detailSectionScrollSelection =\s*createDetailSectionScrollSelection\(\)/);
|
||||
assert.match(
|
||||
component,
|
||||
/const selectDetailSection = \(sectionCode\) => \{\s*activeSectionCode\.value = detailSectionScrollSelection\.select/
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/const enableDetailScrollSync = \(\) => \{\s*detailSectionScrollSelection\.resumeScrollSync\(\)/
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/activeSectionCode\.value = detailSectionScrollSelection\.resolve\(/
|
||||
);
|
||||
|
||||
const createDetailSectionScrollSelection = Function(
|
||||
`${factorySource}\nreturn createDetailSectionScrollSelection`
|
||||
)();
|
||||
const selection = createDetailSectionScrollSelection();
|
||||
const clickedSectionCode = selection.select("PURCHASE_NOTICE");
|
||||
|
||||
assert.equal(
|
||||
selection.resolve(clickedSectionCode, "PRODUCT_DETAIL"),
|
||||
"PURCHASE_NOTICE"
|
||||
);
|
||||
selection.resumeScrollSync();
|
||||
assert.equal(
|
||||
selection.resolve(clickedSectionCode, "FEE_INCLUDE"),
|
||||
"FEE_INCLUDE"
|
||||
);
|
||||
});
|
||||
|
||||
test("booking flow matches the visitor prototype while preserving requested controls", async () => {
|
||||
|
||||
Reference in new issue
Block a user