feat: 创建商品详情的交互

This commit is contained in:
2026-08-17 11:05:02 +08:00
parent 951bdf523f
commit b36f2864bb
3 changed files with 367 additions and 5 deletions

View File

@@ -1,6 +1,26 @@
<template> <template>
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100"> <view class="relative 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="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"> <view v-if="productImages.length" class="relative h-56 overflow-hidden bg-white">
<TicketMediaSwiper <TicketMediaSwiper
:media-list="productImages" :media-list="productImages"
@@ -29,7 +49,21 @@
</view> </view>
<view <view
v-for="section in detailSections" 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" :key="section.sectionCode"
class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]" class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]"
:class="section === detailSections[detailSections.length - 1] ? 'mb-[20px]' : ''" :class="section === detailSections[detailSections.length - 1] ? 'mb-[20px]' : ''"
@@ -42,6 +76,7 @@
:show-img-menu="false" :show-img-menu="false"
:set-title="false" :set-title="false"
:tag-style="detailContentTagStyle" :tag-style="detailContentTagStyle"
@ready="measureDetailSectionOffsets"
/> />
</view> </view>
</view> </view>
@@ -62,10 +97,37 @@
</template> </template>
<script setup> <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 MpHtml from "@/uni_modules/zero-markdown-view/components/mp-html/mp-html.vue";
import TicketDetailTabs from "./TicketDetailTabs.vue";
import TicketMediaSwiper from "./TicketMediaSwiper.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({ const props = defineProps({
product: { type: Object, required: true }, product: { type: Object, required: true },
}); });
@@ -96,6 +158,181 @@ const detailSections = computed(() =>
(left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0) (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(() => const currencySymbol = computed(() =>
props.product.currency === "CNY" ? "¥" : props.product.currency || "" props.product.currency === "CNY" ? "¥" : props.product.currency || ""
); );

View File

@@ -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>

View File

@@ -192,6 +192,19 @@ test("TicketHome prefers mediaList and falls back to coverImage", async () => {
assert.doesNotMatch(source, /<image\s+v-else-if="homeData\.coverImage"/); 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 () => { test("product detail is an independent prototype-aligned page", async () => {
const indexPage = await readTicketFile("index.vue"); const indexPage = await readTicketFile("index.vue");
const detailPage = await readTicketFile("product-detail.vue"); const detailPage = await readTicketFile("product-detail.vue");
@@ -214,7 +227,27 @@ test("product detail is an independent prototype-aligned page", async () => {
assert.match(detailPage, /<ProductDetail\s+v-else-if="product"/); assert.match(detailPage, /<ProductDetail\s+v-else-if="product"/);
assert.match(detailPage, /\/pages-service\/tickets\/booking\?productId=/); assert.match(detailPage, /\/pages-service\/tickets\/booking\?productId=/);
assert.match(component, /import TicketMediaSwiper from "\.\/TicketMediaSwiper\.vue"/); 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.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, /relative h-56 overflow-hidden bg-white/);
assert.doesNotMatch(component, /1 \/ \{\{ productImages\.length \}\}/); assert.doesNotMatch(component, /1 \/ \{\{ productImages\.length \}\}/);
assert.doesNotMatch(component, /productImages\.slice/); assert.doesNotMatch(component, /productImages\.slice/);
@@ -235,7 +268,7 @@ test("product detail is an independent prototype-aligned page", async () => {
]) { ]) {
assert.ok(component.includes(field), `${field} is not rendered`); assert.ok(component.includes(field), `${field} is not rendered`);
} }
assert.match(component, /v-for="section in detailSections"/); assert.match(component, /v-for="\(section, index\) in detailSections"/);
assert.match(component, /section\.sectionCode/); assert.match(component, /section\.sectionCode/);
assert.match(component, /section\.title/); assert.match(component, /section\.title/);
assert.match(component, /section\.content/); assert.match(component, /section\.content/);
@@ -268,6 +301,44 @@ test("product detail is an independent prototype-aligned page", async () => {
); );
}); });
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 () => { test("booking flow matches the visitor prototype while preserving requested controls", async () => {
const component = await readTicketFile("components/BookingFlow.vue"); const component = await readTicketFile("components/BookingFlow.vue");
const page = await readTicketFile("booking.vue"); const page = await readTicketFile("booking.vue");