Compare commits
9
Commits
cheagehome
...
1984f3ed97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1984f3ed97 | ||
|
|
ef4525fbcd | ||
|
|
858fc8f41f | ||
|
|
2a1117c1f9 | ||
|
|
e4f281bf14 | ||
|
|
182e7101c0 | ||
|
|
e2dc7d98bb | ||
|
|
0059b76de4 | ||
|
|
e84902f491 |
No files matched your search
@@ -0,0 +1,68 @@
|
|||||||
|
# TicketHome 首页接口接入设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在 `src/pages-service/tickets/index.vue` 中调用 `scenicProductHome`,使用固定参数:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ scenicId: 20260811 }
|
||||||
|
```
|
||||||
|
|
||||||
|
以接口返回的数据替换票务首页现有的 `SCENIC_SPOT`、`GROUPS` 和 `ticketStore.products` 静态数据。本次只接入首页接口,不修改商品详情、预订、订单等后续页面的数据来源。
|
||||||
|
|
||||||
|
## 数据流与组件边界
|
||||||
|
|
||||||
|
- `index.vue` 负责请求、保存首页数据以及加载和失败状态。
|
||||||
|
- 请求成功且 `code === 0`、`data` 为对象时,将 `data` 原样保存,不创建字段映射或首页 DTO 适配器。
|
||||||
|
- `TicketHome.vue` 直接使用接口字段,不再依赖静态数据字段名:
|
||||||
|
- 景区:`scenicId`、`scenicName`、`subtitle`、`levelText`、`address`、`phone`、`longitude`、`latitude`、`coverImage`、`mediaList`
|
||||||
|
- 分类:`categories` 中的 `categoryId`、`categoryName`、`title`、`subtitle`、`defaultFlag`
|
||||||
|
- 商品:`products.records` 中的 `commodityId`、`categoryId`、`saleTargetKey`、`title`、`image`、`tags`、`displayPrice`、`salesText`、`detailRouteType`、`bookingFeatureFlags`
|
||||||
|
- 默认分类:`selectedCategoryId`
|
||||||
|
- `index.vue` 通过单个 `homeData` prop 将接口 `data` 传给 `TicketHome.vue`,避免将接口结果拆分或转换成旧的 `scenicSpot/groups/products` props。
|
||||||
|
- `TicketHome.vue` 按商品的 `categoryId` 过滤每个分类下的首屏商品,并以 `commodityId` 作为商品点击事件参数。
|
||||||
|
|
||||||
|
## 展示规则
|
||||||
|
|
||||||
|
- 景区头图使用 `coverImage`,景区名称使用 `scenicName`。
|
||||||
|
- 等级和副标题使用 `levelText` 与 `subtitle`;任一字段为空时不渲染多余分隔符。
|
||||||
|
- 分类导航和分组标题使用 `categoryName`,分类说明使用分类 `subtitle`;分类 `title` 作为辅助标签,仅在非空时展示。
|
||||||
|
- 商品卡展示 `image`、`title`、`tags`、`displayPrice` 和 `salesText`。
|
||||||
|
- 接口中的字段为空时使用空态或隐藏对应可选内容,不回填旧静态文案或图片。
|
||||||
|
- 电话和导航继续使用现有交互;经纬度为空或非法时沿用“暂无导航坐标”提示。
|
||||||
|
- 左右分类滚动联动保持不变。初始高亮优先使用 `selectedCategoryId`,无有效匹配时使用第一个分类。
|
||||||
|
|
||||||
|
## 加载、失败与空数据
|
||||||
|
|
||||||
|
- 页面首次加载时请求一次 `scenicProductHome`。
|
||||||
|
- 加载期间显示明确的加载状态,不渲染静态首页。
|
||||||
|
- 请求失败、业务 `code !== 0` 或 `data` 无效时显示错误状态和重试入口,并使用接口 `msg` 或通用错误文案说明失败原因。
|
||||||
|
- 请求成功但分类或商品为空时,仍渲染景区信息,由 `TicketHome.vue` 展示对应空状态。
|
||||||
|
- 重试只重新请求首页接口,不重复导航或重置其他票务 store 状态。
|
||||||
|
|
||||||
|
## 测试与验证
|
||||||
|
|
||||||
|
- 先扩展 `view-contract.test.mjs`,验证:
|
||||||
|
- `index.vue` 调用 `scenicProductHome({ scenicId: 20260811 })`。
|
||||||
|
- 首页不再导入或传入 `SCENIC_SPOT`、`GROUPS`、`ticketStore.products`。
|
||||||
|
- `TicketHome.vue` 使用接口原始字段及 `products.records`。
|
||||||
|
- 分类商品通过 `categoryId` 关联,商品事件使用 `commodityId`。
|
||||||
|
- 页面包含加载、失败和重试状态。
|
||||||
|
- 确认新增测试先因功能未实现而失败,再实施最小代码使其通过。
|
||||||
|
- 实现后运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test src/pages-service/tickets/view-contract.test.mjs
|
||||||
|
node --test src/pages-service/tickets/model.test.mjs
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
本次变更不涉及依赖、配置或微信小程序编译能力,因此不默认运行生产构建。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不调用 `scenicCategoryProductsList` 或 `scenicProductCardContext`。
|
||||||
|
- 不把首页卡片数据写入现有 Pinia 票务 store。
|
||||||
|
- 不把首页简略商品数据转换为详情页所需的旧商品模型。
|
||||||
|
- 不调整商品详情、预订、订单、退款和凭证页的数据结构。
|
||||||
|
- 不修改接口文件或当前客户配置。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# TicketHome 视频封面设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让 `TicketHome.vue` 的 `homeData.coverImage` 同时支持图片和 MP4 视频,保持景区头图现有尺寸、渐变遮罩、导航与文字信息不变。
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
- 在 `TicketHome.vue` 内新增 `isMp4Video(url)`,与现有 `ExploreCards`、`PanoramicHome` 规则一致:字符串去除首尾空格及查询参数、hash 后,不区分大小写判断是否以 `.mp4` 结尾。
|
||||||
|
- `coverImage` 是 MP4 时渲染铺满头图区的 `<video>`;视频启用 `autoplay`、`loop`、`muted`,关闭 controls、播放按钮、全屏按钮、静音按钮和进度手势,使用 `object-fit="cover"`。
|
||||||
|
- `coverImage` 非 MP4 时继续渲染 `<image mode="aspectFill">`。
|
||||||
|
- `coverImage` 为空时不渲染媒体元素,保留头图区背景色。
|
||||||
|
- 不抽取公共媒体工具,不修改其他页面或接口字段。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 先在 `view-contract.test.mjs` 添加失败测试,覆盖视频分支、图片回退、播放器属性和带查询参数的 MP4 判断。
|
||||||
|
- 测试转绿后运行完整票务视图契约、票务模型测试、`git diff --check` 和 `yarn dev:mp-weixin`。
|
||||||
@@ -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` 和微信小程序开发编译。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import request from "@/request/base/request";
|
||||||
|
|
||||||
|
/// 景区商品首页
|
||||||
|
const scenicProductHome = (args) => {
|
||||||
|
return request.post("/hotelBiz/commodity/scenicProductHome", args);
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 景区分类商品分页列表
|
||||||
|
const scenicCategoryProductsList = (args) => {
|
||||||
|
return request.post("/hotelBiz/commodity/scenicCategoryProducts", args);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { scenicProductHome, scenicCategoryProductsList };
|
||||||
|
|
||||||
@@ -1,7 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="h-screen overflow-hidden bg-[#06111f] flex flex-col">
|
<view class="h-screen overflow-hidden bg-[#06111f] flex flex-col">
|
||||||
<view class="relative z-0 h-[43vh] shrink-0 overflow-hidden bg-[#173a34]">
|
<view class="relative z-0 h-[43vh] shrink-0 overflow-hidden bg-[#173a34]">
|
||||||
<image class="absolute inset-0 h-full w-full" :src="scenicSpot.heroImage" mode="aspectFill" />
|
<video
|
||||||
|
v-if="homeData.coverImage && isMp4Video(homeData.coverImage)"
|
||||||
|
class="absolute inset-0 h-full w-full"
|
||||||
|
:src="homeData.coverImage"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
:controls="false"
|
||||||
|
:show-center-play-btn="false"
|
||||||
|
:show-play-btn="false"
|
||||||
|
:show-fullscreen-btn="false"
|
||||||
|
:show-mute-btn="false"
|
||||||
|
:enable-progress-gesture="false"
|
||||||
|
object-fit="cover"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-else-if="homeData.coverImage"
|
||||||
|
class="absolute inset-0 h-full w-full"
|
||||||
|
:src="homeData.coverImage"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
<view class="absolute inset-0 bg-[linear-gradient(180deg,rgba(6,17,31,0.15)_18%,rgba(6,17,31,0.1)_45%,rgba(6,17,31,0.92)_100%)]" />
|
<view class="absolute inset-0 bg-[linear-gradient(180deg,rgba(6,17,31,0.15)_18%,rgba(6,17,31,0.1)_45%,rgba(6,17,31,0.92)_100%)]" />
|
||||||
|
|
||||||
<view class="absolute inset-x-0 top-0 z-20">
|
<view class="absolute inset-x-0 top-0 z-20">
|
||||||
@@ -23,16 +43,16 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="absolute bottom-[42px] left-[20px] right-[20px]">
|
<view class="absolute bottom-[42px] left-[20px] right-[20px]">
|
||||||
<text class="block text-[28px] font-bold leading-[1.2] text-white">{{ scenicSpot.name }}</text>
|
<text class="block text-[28px] font-bold leading-[1.2] text-white">{{ homeData.scenicName }}</text>
|
||||||
<text class="mt-[7px] block text-[13px] text-white/85">{{ scenicSpot.level }}|{{ scenicSpot.heritage }}</text>
|
<text v-if="scenicMetaText" class="mt-[7px] block text-[13px] text-white/85">{{ scenicMetaText }}</text>
|
||||||
<view class="mt-[10px] flex items-center">
|
<view v-if="homeData.address" class="mt-[10px] flex items-center">
|
||||||
<uni-icons type="location" size="15" color="#FFFFFF" />
|
<uni-icons type="location" size="15" color="#FFFFFF" />
|
||||||
<text class="ml-[4px] flex-1 text-[12px] leading-[17px] text-white/85">{{ scenicSpot.address }}</text>
|
<text class="ml-[4px] flex-1 text-[12px] leading-[17px] text-white/85">{{ homeData.address }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="mt-[10px] flex items-center gap-[18px]">
|
<view class="mt-[10px] flex items-center gap-[18px]">
|
||||||
<view class="flex items-center" @click="makePhoneCall">
|
<view v-if="homeData.phone" class="flex items-center" @click="makePhoneCall">
|
||||||
<uni-icons type="phone" size="15" color="#FFFFFF" />
|
<uni-icons type="phone" size="15" color="#FFFFFF" />
|
||||||
<text class="ml-[4px] text-[12px] text-white">{{ scenicSpot.phone }}</text>
|
<text class="ml-[4px] text-[12px] text-white">{{ homeData.phone }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex items-center" @click="openLocation">
|
<view class="flex items-center" @click="openLocation">
|
||||||
<uni-icons type="navigate" size="15" color="#FFFFFF" />
|
<uni-icons type="navigate" size="15" color="#FFFFFF" />
|
||||||
@@ -52,14 +72,14 @@
|
|||||||
>
|
>
|
||||||
<view
|
<view
|
||||||
v-for="group in groups"
|
v-for="group in groups"
|
||||||
:id="`ticket-nav-${group.id}`"
|
:id="`ticket-nav-${group.categoryId}`"
|
||||||
:key="group.id"
|
:key="group.categoryId"
|
||||||
class="relative flex min-h-[68px] items-center justify-center px-[10px] py-[12px] text-center"
|
class="relative flex min-h-[68px] items-center justify-center px-[10px] py-[12px] text-center"
|
||||||
:class="group.id === activeGroupId ? 'bg-[#ecf8f2] text-[#11795c]' : 'bg-white text-[#5f6875]'"
|
:class="isActiveGroup(group.categoryId) ? 'bg-[#ecf8f2] text-[#11795c]' : 'bg-white text-[#5f6875]'"
|
||||||
@click="selectGroup(group.id)"
|
@click="selectGroup(group.categoryId)"
|
||||||
>
|
>
|
||||||
<view v-if="group.id === activeGroupId" class="absolute left-0 h-[28px] w-[3px] rounded-r-full bg-[#149474]" />
|
<view v-if="isActiveGroup(group.categoryId)" class="absolute left-0 h-[28px] w-[3px] rounded-r-full bg-[#149474]" />
|
||||||
<text class="text-[13px] leading-[18px]" :class="group.id === activeGroupId ? 'font-semibold' : 'font-normal'">{{ group.name }}</text>
|
<text class="text-[13px] leading-[18px]" :class="isActiveGroup(group.categoryId) ? 'font-semibold' : 'font-normal'">{{ group.categoryName }}</text>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
@@ -70,35 +90,38 @@
|
|||||||
:scroll-into-view="scrollIntoViewId"
|
:scroll-into-view="scrollIntoViewId"
|
||||||
scroll-with-animation
|
scroll-with-animation
|
||||||
@scroll="handleProductScroll"
|
@scroll="handleProductScroll"
|
||||||
|
@touchstart="enableProductScrollSync"
|
||||||
>
|
>
|
||||||
<view class="px-[12px] pb-[24px]">
|
<view class="px-[12px] pb-[24px]">
|
||||||
<view v-for="group in groups" :id="`ticket-group-${group.id}`" :key="group.id" class="pt-[14px]">
|
<view v-if="!groups.length" class="pt-[80px] text-center">
|
||||||
|
<text class="text-[13px] text-[#9aa2ad]">暂无商品分类</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="group in groups" :id="`ticket-group-${group.categoryId}`" :key="group.categoryId" class="pt-[14px]">
|
||||||
<view class="flex items-center">
|
<view class="flex items-center">
|
||||||
<text class="text-[18px] font-bold text-[#202733]">{{ group.name }}</text>
|
<text class="text-[18px] font-bold text-[#202733]">{{ group.categoryName }}</text>
|
||||||
<text v-if="group.tag" class="ml-[7px] rounded-full bg-[#e5f6ee] px-[7px] py-[2px] text-[10px] text-[#158061]">{{ group.tag }}</text>
|
<text v-if="group.title" class="ml-[7px] rounded-full bg-[#e5f6ee] px-[7px] py-[2px] text-[10px] text-[#158061]">{{ group.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="group.description" class="mt-[4px] block text-[12px] leading-[17px] text-[#808997]">{{ group.description }}</text>
|
<text v-if="group.subtitle" class="mt-[4px] block text-[12px] leading-[17px] text-[#808997]">{{ group.subtitle }}</text>
|
||||||
|
|
||||||
<view v-if="productsForGroup(group).length" class="mt-[10px] flex flex-col gap-[10px]">
|
<view v-if="productsForGroup(group).length" class="mt-[10px] flex flex-col gap-[10px]">
|
||||||
<view v-for="product in productsForGroup(group)" :key="product.id" class="flex rounded-[14px] bg-white p-[10px]">
|
<view v-for="product in productsForGroup(group)" :key="product.commodityId" class="flex rounded-[14px] bg-white p-[10px]">
|
||||||
<image class="h-[100px] w-[100px] shrink-0 rounded-[10px] bg-[#e8edf0]" :src="product.images && product.images[0]" mode="aspectFill" @click="emit('open-detail', product.id)" />
|
<image class="h-[100px] w-[100px] shrink-0 rounded-[10px] bg-[#e8edf0]" :src="product.image" mode="aspectFill" @click="emit('open-detail', product.commodityId)" />
|
||||||
<view class="ml-[10px] flex min-w-0 flex-1 flex-col">
|
<view class="ml-[10px] flex min-w-0 flex-1 flex-col">
|
||||||
<view class="flex min-w-0 items-start" @click="emit('open-detail', product.id)">
|
<view class="flex min-w-0 items-start" @click="emit('open-detail', product.commodityId)">
|
||||||
<text class="line-clamp-2 min-w-0 text-[14px] font-semibold leading-[20px] text-[#242b36]">{{ product.shortTitle || product.name }}</text>
|
<text class="line-clamp-2 min-w-0 text-[14px] font-semibold leading-[20px] text-[#242b36]">{{ product.title }}</text>
|
||||||
<view class="flex h-[20px] shrink-0 items-center">
|
<view class="flex h-[20px] shrink-0 items-center">
|
||||||
<uni-icons type="right" size="14" color="#242b36" />
|
<uni-icons type="right" size="14" color="#242b36" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="mt-[5px] flex flex-wrap gap-[4px]">
|
<view v-if="product.tags?.length" class="mt-[5px] flex flex-wrap gap-[4px]">
|
||||||
<text v-if="product.ageRule" class="rounded-[4px] bg-[#f2ebff] px-[5px] py-[2px] text-[10px] text-[#7a52b3]">{{ ageRuleText(product.ageRule) }}</text>
|
<text v-for="tag in product.tags" :key="tag" class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]">{{ tag }}</text>
|
||||||
<text v-for="highlight in product.highlights || []" :key="highlight" class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]">{{ highlight }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="mt-auto flex items-end justify-between pt-[6px]">
|
<view class="mt-auto flex items-end justify-between pt-[6px]">
|
||||||
<view>
|
<view>
|
||||||
<text class="block text-[10px] text-[#9aa2ad]">已售 {{ product.sold || 0 }}</text>
|
<text v-if="product.salesText" class="block text-[10px] text-[#9aa2ad]">{{ product.salesText }}</text>
|
||||||
<text class="text-[12px] font-medium text-[#f26b32]">¥<text class="text-[20px] font-bold">{{ product.salePrice }}</text></text>
|
<text class="text-[12px] font-medium text-[#f26b32]">¥<text class="text-[20px] font-bold">{{ product.displayPrice }}</text></text>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex h-[28px] items-center justify-center rounded-full bg-[#f26b32] px-[13px]" @click="emit('buy', product.id)">
|
<view class="flex h-[28px] items-center justify-center rounded-full bg-[#f26b32] px-[13px]" @click="emit('buy', product.commodityId)">
|
||||||
<text class="text-[12px] font-medium leading-none text-white">选购</text>
|
<text class="text-[12px] font-medium leading-none text-white">选购</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -120,33 +143,67 @@
|
|||||||
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import TopNavBar from "@/components/TopNavBar/index.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({
|
const props = defineProps({
|
||||||
scenicSpot: { type: Object, required: true },
|
homeData: { type: Object, required: true },
|
||||||
groups: { type: Array, default: () => [] },
|
|
||||||
products: { type: Array, default: () => [] },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
|
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
|
||||||
const activeGroupId = ref(props.groups[0]?.id || '')
|
const groups = computed(() =>
|
||||||
|
Array.isArray(props.homeData.categoryProductGroups)
|
||||||
|
? props.homeData.categoryProductGroups
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const preferredCategoryId = String(props.homeData.selectedCategoryId || '')
|
||||||
|
const activeGroupId = ref(
|
||||||
|
groups.value.some(
|
||||||
|
(group) => String(group.categoryId) === preferredCategoryId
|
||||||
|
)
|
||||||
|
? props.homeData.selectedCategoryId
|
||||||
|
: groups.value[0]?.categoryId || ''
|
||||||
|
)
|
||||||
const scrollIntoViewId = ref('')
|
const scrollIntoViewId = ref('')
|
||||||
const leftScrollIntoViewId = computed(() => `ticket-nav-${activeGroupId.value}`)
|
const leftScrollIntoViewId = computed(() => `ticket-nav-${activeGroupId.value}`)
|
||||||
const groupOffsets = ref([])
|
const groupOffsets = ref([])
|
||||||
const rightScrollTop = ref(0)
|
const rightScrollTop = ref(0)
|
||||||
|
const categoryScrollSelection = createCategoryScrollSelection()
|
||||||
const instance = getCurrentInstance()
|
const instance = getCurrentInstance()
|
||||||
const productsById = computed(() => new Map(props.products.map((product) => [product.id, product])))
|
const scenicMetaText = computed(() =>
|
||||||
|
[props.homeData.levelText, props.homeData.subtitle].filter(Boolean).join('|')
|
||||||
|
)
|
||||||
|
const isMp4Video = (url) => {
|
||||||
|
if (typeof url !== 'string') return false
|
||||||
|
const resourcePath = url.trim().split(/[?#]/)[0]
|
||||||
|
return resourcePath.toLowerCase().endsWith('.mp4')
|
||||||
|
}
|
||||||
|
const isActiveGroup = (groupId) =>
|
||||||
|
String(groupId) === String(activeGroupId.value)
|
||||||
|
const productsForGroup = (group) =>
|
||||||
|
Array.isArray(group.products) ? group.products : []
|
||||||
|
|
||||||
const productsForGroup = (group) => (group.productIds || [])
|
const enableProductScrollSync = () => {
|
||||||
.map((productId) => productsById.value.get(productId))
|
categoryScrollSelection.resumeScrollSync()
|
||||||
.filter((product) => product?.status === 'on_sale' && product.clientVisible !== false)
|
scrollIntoViewId.value = ''
|
||||||
|
|
||||||
const ageRuleText = (rule) => {
|
|
||||||
if (rule.min > 0 && rule.max >= 999) return `${rule.min}周岁及以上`
|
|
||||||
if (rule.min <= 0 && rule.max < 999) return `${rule.max}周岁及以下`
|
|
||||||
return `${rule.min}—${rule.max}周岁`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectGroup = (groupId) => {
|
const selectGroup = (groupId) => {
|
||||||
activeGroupId.value = groupId
|
activeGroupId.value = categoryScrollSelection.select(groupId)
|
||||||
scrollIntoViewId.value = ''
|
scrollIntoViewId.value = ''
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
scrollIntoViewId.value = `ticket-group-${groupId}`
|
scrollIntoViewId.value = `ticket-group-${groupId}`
|
||||||
@@ -154,20 +211,20 @@ const selectGroup = (groupId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const measureGroupOffsets = () => {
|
const measureGroupOffsets = () => {
|
||||||
if (!props.groups.length) return
|
if (!groups.value.length) return
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const query = uni.createSelectorQuery().in(instance?.proxy)
|
const query = uni.createSelectorQuery().in(instance?.proxy)
|
||||||
query.select('#ticket-product-scroll').boundingClientRect()
|
query.select('#ticket-product-scroll').boundingClientRect()
|
||||||
props.groups.forEach((group) => {
|
groups.value.forEach((group) => {
|
||||||
query.select(`#ticket-group-${group.id}`).boundingClientRect()
|
query.select(`#ticket-group-${group.categoryId}`).boundingClientRect()
|
||||||
})
|
})
|
||||||
query.exec((rects = []) => {
|
query.exec((rects = []) => {
|
||||||
const containerRect = rects[0]
|
const containerRect = rects[0]
|
||||||
if (!containerRect) return
|
if (!containerRect) return
|
||||||
groupOffsets.value = props.groups
|
groupOffsets.value = groups.value
|
||||||
.map((group, index) => ({
|
.map((group, index) => ({
|
||||||
id: group.id,
|
id: group.categoryId,
|
||||||
top: Math.max(
|
top: Math.max(
|
||||||
0,
|
0,
|
||||||
Number(rects[index + 1]?.top || 0) -
|
Number(rects[index + 1]?.top || 0) -
|
||||||
@@ -192,21 +249,52 @@ const handleProductScroll = (event) => {
|
|||||||
(current, group) => (group.top <= activationLine ? group : current),
|
(current, group) => (group.top <= activationLine ? group : current),
|
||||||
groupOffsets.value[0]
|
groupOffsets.value[0]
|
||||||
)
|
)
|
||||||
if (activeGroup?.id) activeGroupId.value = activeGroup.id
|
if (activeGroup?.id) {
|
||||||
|
activeGroupId.value = categoryScrollSelection.resolve(
|
||||||
|
activeGroupId.value,
|
||||||
|
activeGroup.id
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(measureGroupOffsets)
|
onMounted(measureGroupOffsets)
|
||||||
watch(() => props.groups.map((group) => group.id).join(','), measureGroupOffsets)
|
watch(
|
||||||
|
() => groups.value.map((group) => group.categoryId).join(','),
|
||||||
|
() => {
|
||||||
|
if (!groups.value.some((group) => isActiveGroup(group.categoryId))) {
|
||||||
|
const selectedCategoryId = String(props.homeData.selectedCategoryId || '')
|
||||||
|
activeGroupId.value = groups.value.some(
|
||||||
|
(group) => String(group.categoryId) === selectedCategoryId
|
||||||
|
)
|
||||||
|
? props.homeData.selectedCategoryId
|
||||||
|
: groups.value[0]?.categoryId || ''
|
||||||
|
}
|
||||||
|
measureGroupOffsets()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const makePhoneCall = () => {
|
const makePhoneCall = () => {
|
||||||
if (!props.scenicSpot.phone) return
|
if (!props.homeData.phone) return
|
||||||
uni.makePhoneCall({ phoneNumber: props.scenicSpot.phone })
|
uni.makePhoneCall({ phoneNumber: props.homeData.phone })
|
||||||
}
|
}
|
||||||
|
|
||||||
const openLocation = () => {
|
const openLocation = () => {
|
||||||
const latitude = Number(props.scenicSpot.latitude)
|
const latitudeValue = props.homeData.latitude
|
||||||
const longitude = Number(props.scenicSpot.longitude)
|
const longitudeValue = props.homeData.longitude
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
const coordinatesMissing =
|
||||||
|
latitudeValue === '' ||
|
||||||
|
latitudeValue === null ||
|
||||||
|
latitudeValue === undefined ||
|
||||||
|
longitudeValue === '' ||
|
||||||
|
longitudeValue === null ||
|
||||||
|
longitudeValue === undefined
|
||||||
|
const latitude = Number(latitudeValue)
|
||||||
|
const longitude = Number(longitudeValue)
|
||||||
|
if (
|
||||||
|
coordinatesMissing ||
|
||||||
|
!Number.isFinite(latitude) ||
|
||||||
|
!Number.isFinite(longitude)
|
||||||
|
) {
|
||||||
uni.showToast({ title: '暂无导航坐标', icon: 'none' })
|
uni.showToast({ title: '暂无导航坐标', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -214,8 +302,8 @@ const openLocation = () => {
|
|||||||
uni.openLocation({
|
uni.openLocation({
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
name: props.scenicSpot.name || '',
|
name: props.homeData.scenicName || '',
|
||||||
address: props.scenicSpot.address || '',
|
address: props.homeData.address || '',
|
||||||
fail: () => uni.showToast({ title: '打开导航失败', icon: 'none' }),
|
fail: () => uni.showToast({ title: '打开导航失败', icon: 'none' }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="h-screen min-w-0 overflow-hidden bg-[#f5f7fa] text-[#172033]">
|
<view class="h-screen min-w-0 overflow-hidden bg-[#f5f7fa] text-[#172033]">
|
||||||
<TicketHome
|
<TicketHome
|
||||||
:scenic-spot="SCENIC_SPOT"
|
v-if="homeData"
|
||||||
:groups="visibleGroups"
|
:home-data="homeData"
|
||||||
:products="ticketStore.products"
|
|
||||||
@back="goBack"
|
@back="goBack"
|
||||||
@open-travelers="openTravelers"
|
@open-travelers="openTravelers"
|
||||||
@open-orders="openOrders"
|
@open-orders="openOrders"
|
||||||
@open-detail="openProductDetail"
|
@open-detail="openProductDetail"
|
||||||
@buy="startBooking"
|
@buy="startBooking"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<view v-else class="flex h-full flex-col">
|
||||||
|
<TopNavBar title="" @back="goBack" />
|
||||||
|
<view
|
||||||
|
v-if="loading"
|
||||||
|
class="flex min-h-0 flex-1 items-center justify-center"
|
||||||
|
>
|
||||||
|
<text class="text-[14px] text-[#808997]">正在加载景区商品...</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-else-if="errorMessage"
|
||||||
|
class="flex min-h-0 flex-1 flex-col items-center justify-center px-[32px] text-center"
|
||||||
|
>
|
||||||
|
<text class="text-[14px] leading-[21px] text-[#808997]">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</text>
|
||||||
|
<view
|
||||||
|
class="mt-[18px] flex h-[38px] items-center justify-center rounded-full bg-[#149474] px-[22px]"
|
||||||
|
@click="loadTicketHome"
|
||||||
|
>
|
||||||
|
<text class="text-[13px] font-medium text-white">重新加载</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
|
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||||
import TicketHome from "./components/TicketHome.vue";
|
import TicketHome from "./components/TicketHome.vue";
|
||||||
import { GROUPS, SCENIC_SPOT } from "./data.js";
|
import { scenicProductHome } from "./api/scenic.js";
|
||||||
import { useTicketStore } from "./store.js";
|
|
||||||
|
|
||||||
const ticketStore = useTicketStore();
|
const homeData = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
|
||||||
|
const loadTicketHome = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await scenicProductHome({ scenicId: 20260811 });
|
||||||
|
const validData =
|
||||||
|
response?.data &&
|
||||||
|
typeof response.data === "object" &&
|
||||||
|
!Array.isArray(response.data);
|
||||||
|
|
||||||
|
if (response?.code !== 0 || !validData) {
|
||||||
|
throw new Error(response?.msg || "景区商品加载失败,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
homeData.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
homeData.value = null;
|
||||||
|
errorMessage.value = error?.message || "景区商品加载失败,请稍后重试";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadTicketHome);
|
||||||
|
|
||||||
const visibleGroups = computed(() =>
|
|
||||||
[...GROUPS]
|
|
||||||
.filter((group) => group.enabled)
|
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
|
||||||
);
|
|
||||||
const goBack = () => uni.navigateBack();
|
const goBack = () => uni.navigateBack();
|
||||||
const openTravelers = () =>
|
const openTravelers = () =>
|
||||||
uni.navigateTo({ url: "/pages-service/tickets/travelers" });
|
uni.navigateTo({ url: "/pages-service/tickets/travelers" });
|
||||||
|
|||||||
@@ -63,6 +63,119 @@ test("storefront synchronizes the left category from right-column scrolling", as
|
|||||||
assert.match(source, /TopNavBar/);
|
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");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/import \{ scenicProductHome \} from "\.\/api\/scenic\.js"/
|
||||||
|
);
|
||||||
|
assert.match(source, /scenicProductHome\(\{ scenicId: 20260811 \}\)/);
|
||||||
|
assert.match(source, /const homeData = ref\(null\)/);
|
||||||
|
assert.match(source, /:home-data="homeData"/);
|
||||||
|
assert.match(source, /v-if="loading"/);
|
||||||
|
assert.match(source, /v-else-if="errorMessage"/);
|
||||||
|
assert.match(source, /@click="loadTicketHome"/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/SCENIC_SPOT|GROUPS|ticketStore\.products|visibleGroups/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TicketHome consumes scenic home response fields directly", async () => {
|
||||||
|
const source = await readTicketFile("components/TicketHome.vue");
|
||||||
|
|
||||||
|
for (const field of [
|
||||||
|
"homeData.coverImage",
|
||||||
|
"homeData.scenicName",
|
||||||
|
"homeData.levelText",
|
||||||
|
"homeData.subtitle",
|
||||||
|
"group.categoryId",
|
||||||
|
"group.categoryName",
|
||||||
|
"group.title",
|
||||||
|
"group.subtitle",
|
||||||
|
"product.commodityId",
|
||||||
|
"product.image",
|
||||||
|
"product.title",
|
||||||
|
"product.tags",
|
||||||
|
"product.displayPrice",
|
||||||
|
"product.salesText",
|
||||||
|
]) {
|
||||||
|
assert.ok(source.includes(field), `${field} is not rendered`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(source, /props\.homeData\.categoryProductGroups/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/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\)/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/scenicSpot|productIds|ageRuleText|product\.shortTitle|product\.salePrice/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TicketHome renders MP4 cover images as background videos", async () => {
|
||||||
|
const source = await readTicketFile("components/TicketHome.vue");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/<video\s+v-if="homeData\.coverImage && isMp4Video\(homeData\.coverImage\)"/
|
||||||
|
);
|
||||||
|
assert.match(source, /autoplay\s+loop\s+muted/);
|
||||||
|
assert.match(source, /:controls="false"/);
|
||||||
|
assert.match(source, /:show-center-play-btn="false"/);
|
||||||
|
assert.match(source, /:show-play-btn="false"/);
|
||||||
|
assert.match(source, /:show-fullscreen-btn="false"/);
|
||||||
|
assert.match(source, /:show-mute-btn="false"/);
|
||||||
|
assert.match(source, /:enable-progress-gesture="false"/);
|
||||||
|
assert.match(source, /object-fit="cover"/);
|
||||||
|
assert.match(source, /<image\s+v-else-if="homeData\.coverImage"/);
|
||||||
|
assert.match(source, /const isMp4Video = \(url\) =>/);
|
||||||
|
assert.match(source, /url\.trim\(\)\.split\(\/\[\?\#\]\/\)\[0\]/);
|
||||||
|
assert.match(source, /toLowerCase\(\)\.endsWith\('\.mp4'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
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");
|
||||||
|
|||||||
Reference in new issue
Block a user