Compare commits
3
Commits
2a1117c1f9
...
1984f3ed97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1984f3ed97 | ||
|
|
ef4525fbcd | ||
|
|
858fc8f41f |
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\)/);
|
||||
|
||||
Reference in new issue
Block a user