3 Commits
Author SHA1 Message Date
zoujing 1984f3ed97 feat: 修复页面滚动问题 2026-08-12 10:32:33 +08:00
zoujing ef4525fbcd fix: read ticket category product groups 2026-08-12 09:56:45 +08:00
zoujing 858fc8f41f docs: design ticket category product groups 2026-08-12 09:53:43 +08:00
3 changed files with 96 additions and 12 deletions

No files matched your search

@@ -0,0 +1,22 @@
# TicketHome 分类商品组数据结构调整设计
## 目标
适配 `scenicProductHome` 最新响应结构。`TicketHome.vue` 不再使用 `categories` 和旧的 `products.records`,分类导航、分组信息与首屏商品统一来自 `homeData.categoryProductGroups`。
## 数据与交互
- `groups` 只读取有效数组 `homeData.categoryProductGroups`,无效或缺失时使用空数组。
- 每个分组直接使用 `categoryId`、`categoryName`、`title`、`subtitle`、`defaultFlag` 和 `products`。
- `productsForGroup(group)` 只返回 `group.products`;分组没有有效商品数组时返回空数组,不再扁平化或按 `categoryId` 二次过滤。
- `selectedCategoryId` 继续作为初始选中分类;若不存在于商品组中,则选择第一个商品组。
- 左侧分类点击滚动到对应右侧分组;右侧滚动继续同步左侧高亮。
- 空分组继续展示“暂无可售商品”,完全没有分组时展示“暂无商品分类”。
- 商品卡字段和点击行为不变,详情与选购仍传递 `commodityId`。
- 本次不使用 `total`、`hasMore`、`pageNo`、`pageSize`,也不调用分类分页接口。
## 验证
- 先更新契约测试,使其要求读取 `categoryProductGroups` 和 `group.products`,并禁止出现 `homeData.categories`、`homeData.products.records` 与按 `categoryId` 二次过滤。
- 确认测试在旧实现上失败后修改组件。
- 完成后运行完整票务视图契约、票务模型测试、`git diff --check` 和微信小程序开发编译。
@@ -90,6 +90,7 @@
:scroll-into-view="scrollIntoViewId"
scroll-with-animation
@scroll="handleProductScroll"
@touchstart="enableProductScrollSync"
>
<view class="px-[12px] pb-[24px]">
<view v-if="!groups.length" class="pt-[80px] text-center">
@@ -142,17 +143,31 @@
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
import TopNavBar from "@/components/TopNavBar/index.vue"
const createCategoryScrollSelection = () => {
let shouldSyncFromScroll = true
return {
select(groupId) {
shouldSyncFromScroll = false
return groupId
},
resumeScrollSync() {
shouldSyncFromScroll = true
},
resolve(currentGroupId, scrollGroupId) {
return shouldSyncFromScroll ? scrollGroupId : currentGroupId
},
}
}
const props = defineProps({
homeData: { type: Object, required: true },
})
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
const groups = computed(() =>
Array.isArray(props.homeData.categories) ? props.homeData.categories : []
)
const products = computed(() =>
Array.isArray(props.homeData.products?.records)
? props.homeData.products.records
Array.isArray(props.homeData.categoryProductGroups)
? props.homeData.categoryProductGroups
: []
)
const preferredCategoryId = String(props.homeData.selectedCategoryId || '')
@@ -167,6 +182,7 @@ const scrollIntoViewId = ref('')
const leftScrollIntoViewId = computed(() => `ticket-nav-${activeGroupId.value}`)
const groupOffsets = ref([])
const rightScrollTop = ref(0)
const categoryScrollSelection = createCategoryScrollSelection()
const instance = getCurrentInstance()
const scenicMetaText = computed(() =>
[props.homeData.levelText, props.homeData.subtitle].filter(Boolean).join('|')
@@ -179,12 +195,15 @@ const isMp4Video = (url) => {
const isActiveGroup = (groupId) =>
String(groupId) === String(activeGroupId.value)
const productsForGroup = (group) =>
products.value.filter(
(product) => String(product.categoryId) === String(group.categoryId)
)
Array.isArray(group.products) ? group.products : []
const enableProductScrollSync = () => {
categoryScrollSelection.resumeScrollSync()
scrollIntoViewId.value = ''
}
const selectGroup = (groupId) => {
activeGroupId.value = groupId
activeGroupId.value = categoryScrollSelection.select(groupId)
scrollIntoViewId.value = ''
nextTick(() => {
scrollIntoViewId.value = `ticket-group-${groupId}`
@@ -230,7 +249,12 @@ const handleProductScroll = (event) => {
(current, group) => (group.top <= activationLine ? group : current),
groupOffsets.value[0]
)
if (activeGroup?.id) activeGroupId.value = activeGroup.id
if (activeGroup?.id) {
activeGroupId.value = categoryScrollSelection.resolve(
activeGroupId.value,
activeGroup.id
)
}
}
onMounted(measureGroupOffsets)
@@ -63,6 +63,40 @@ test("storefront synchronizes the left category from right-column scrolling", as
assert.match(source, /TopNavBar/);
});
test("left category selection wins over programmatic product scrolling", async () => {
const source = await readTicketFile("components/TicketHome.vue");
const factorySource = source.match(
/const createCategoryScrollSelection = \(\) => \{[\s\S]*?^\}\n/m
)?.[0];
assert.match(source, /@touchstart="enableProductScrollSync"/);
assert.doesNotMatch(source, /category-scroll-selection\.mjs/);
assert.ok(factorySource, "inline category scroll selection factory missing");
assert.match(source, /const categoryScrollSelection =\s*createCategoryScrollSelection\(\)/);
assert.match(
source,
/const selectGroup = \(groupId\) => \{\s*activeGroupId\.value = categoryScrollSelection\.select\(groupId\)/
);
assert.match(
source,
/const enableProductScrollSync = \(\) => \{\s*categoryScrollSelection\.resumeScrollSync\(\)\s*scrollIntoViewId\.value = ''\s*\}/
);
assert.match(
source,
/const handleProductScroll = \(event\) => \{[\s\S]*rightScrollTop\.value = Number[\s\S]*measureGroupOffsets\(\)[\s\S]*activeGroupId\.value = categoryScrollSelection\.resolve\(\s*activeGroupId\.value,\s*activeGroup\.id\s*\)/
);
const createCategoryScrollSelection = Function(
`${factorySource}\nreturn createCategoryScrollSelection`
)();
const selection = createCategoryScrollSelection();
const clickedGroupId = selection.select("second");
assert.equal(selection.resolve(clickedGroupId, "first"), "second");
selection.resumeScrollSync();
assert.equal(selection.resolve(clickedGroupId, "third"), "third");
});
test("ticket storefront loads API home data without a static adapter", async () => {
const source = await readTicketFile("index.vue");
@@ -104,10 +138,14 @@ test("TicketHome consumes scenic home response fields directly", async () => {
assert.ok(source.includes(field), `${field} is not rendered`);
}
assert.match(source, /props\.homeData\.products\?\.records/);
assert.match(source, /props\.homeData\.categoryProductGroups/);
assert.match(
source,
/product\.categoryId\) === String\(group\.categoryId/
/Array\.isArray\(group\.products\) \? group\.products : \[\]/
);
assert.doesNotMatch(
source,
/homeData\.categories|homeData\.products|products\.value\.filter/
);
assert.match(source, /emit\('open-detail', product\.commodityId\)/);
assert.match(source, /emit\('buy', product\.commodityId\)/);