340 lines
11 KiB
Vue
340 lines
11 KiB
Vue
<template>
|
|
<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>
|
|
|
|
<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-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.startingPrice) === 0" class="text-2xl font-bold leading-none">免费预约</text>
|
|
<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>
|
|
</template>
|
|
</view>
|
|
</view>
|
|
|
|
<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
|
|
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>
|
|
|
|
<view class="shrink-0 border-t border-slate-100 bg-white px-[16px] pb-[calc(env(safe-area-inset-bottom)+2px)] pt-[10px]">
|
|
<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>
|
|
</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>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
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 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 || ""
|
|
);
|
|
</script>
|