feat: 创建商品详情的交互
This commit is contained in:
@@ -1,6 +1,26 @@
|
||||
<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 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"
|
||||
@@ -29,7 +49,21 @@
|
||||
</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"
|
||||
class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]"
|
||||
:class="section === detailSections[detailSections.length - 1] ? 'mb-[20px]' : ''"
|
||||
@@ -42,6 +76,7 @@
|
||||
:show-img-menu="false"
|
||||
:set-title="false"
|
||||
:tag-style="detailContentTagStyle"
|
||||
@ready="measureDetailSectionOffsets"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -62,10 +97,37 @@
|
||||
</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 },
|
||||
});
|
||||
@@ -96,6 +158,181 @@ const detailSections = computed(() =>
|
||||
(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 || ""
|
||||
);
|
||||
|
||||
54
src/pages-service/tickets/components/TicketDetailTabs.vue
Normal file
54
src/pages-service/tickets/components/TicketDetailTabs.vue
Normal 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>
|
||||
@@ -192,6 +192,19 @@ test("TicketHome prefers mediaList and falls back to coverImage", async () => {
|
||||
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");
|
||||
@@ -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, /\/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.doesNotMatch(component, /1 \/ \{\{ productImages\.length \}\}/);
|
||||
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.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\.title/);
|
||||
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 () => {
|
||||
const component = await readTicketFile("components/BookingFlow.vue");
|
||||
const page = await readTicketFile("booking.vue");
|
||||
|
||||
Reference in New Issue
Block a user