Files
YGChatCS/src/pages-service/tickets/components/ProductDetail.vue
T

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>