5 Commits

Author SHA1 Message Date
ebad453023 feat: 添加了商品详情页的骨架屏请求效果 2026-08-17 21:07:26 +08:00
d567d1a2c4 feat: 去掉默认数据 2026-08-17 20:53:42 +08:00
0461ba2ea9 feat: 修复页面的滚动 2026-08-17 20:53:28 +08:00
ec20e11c32 feat: 拉取接口填充了一下新版首页 2026-08-17 20:27:31 +08:00
21659f61e3 feat: 切换为小七 2026-08-17 19:48:37 +08:00
20 changed files with 444 additions and 1148 deletions

View File

@@ -1,68 +0,0 @@
# 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。
- 不把首页简略商品数据转换为详情页所需的旧商品模型。
- 不调整商品详情、预订、订单、退款和凭证页的数据结构。
- 不修改接口文件或当前客户配置。

View File

@@ -1,18 +0,0 @@
# 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`。

View File

@@ -1,22 +0,0 @@
# 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` 和微信小程序开发编译。

View File

@@ -1,5 +1,5 @@
{
"appid": "wx5e79df5996572539",
"appid": "wx8531ca7339685eca",
"compileType": "miniprogram",
"libVersion": "3.8.10",
"packOptions": {

View File

@@ -12,7 +12,7 @@ import rawConfigs from "../../client-configs.json" with { type: "json" };
export const CLIENT_CONFIGS = rawConfigs;
// 获取当前用户端配置
export const getCurrentConfig = () => CLIENT_CONFIGS.zhinian;
export const getCurrentConfig = () => CLIENT_CONFIGS.xiaoqi;
export const clientId = getCurrentConfig().clientId;
export const appId = getCurrentConfig().appId;

View File

@@ -61,7 +61,7 @@
},
"quickapp" : {},
"mp-weixin" : {
"appid" : "wx5e79df5996572539",
"appid" : "wx8531ca7339685eca",
"setting" : {
"urlCheck" : false,
"minified" : true

View File

@@ -1,14 +0,0 @@
import request from "@/request/base/request";
/// 景区商品首页
const scenicProductHome = (args) => {
return request.post("/hotelBiz/commodity/scenicProductHome", args);
};
/// 景区分类商品详情
const scenicProductDetail = (args) => {
return request.post("/hotelBiz/commodity/productDetailV2", args);
};
export { scenicProductHome, scenicProductDetail };

View File

@@ -1,84 +1,73 @@
<template>
<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"
class="h-full w-full bg-slate-200"
/>
<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 h-56 overflow-hidden bg-white">
<image class="h-full w-full bg-slate-200" :src="productImages[0]" mode="aspectFill" />
<view class="absolute bottom-3 right-3 rounded-full bg-slate-950/65 px-2 py-1 text-[10px] text-white">1 / {{ productImages.length }}</view>
</view>
<view v-else class="flex h-56 items-center justify-center bg-slate-200">
<text class="text-[12px] text-slate-400">暂无商品图片</text>
<view v-if="productImages.length > 1" class="flex gap-2 overflow-hidden bg-white px-4 pb-3 pt-2">
<image
v-for="(image, index) in productImages.slice(0, 4)"
:key="`${image}-${index}`"
class="h-11 w-16 rounded-lg object-cover"
:class="index === 0 ? 'ring-2 ring-blue-500' : 'opacity-70'"
:src="image"
mode="aspectFill"
/>
</view>
<view class="bg-white px-4 pb-4 pt-4">
<view class="flex flex-wrap gap-1.5">
<text v-if="product.fulfillmentLabel" class="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600">{{ product.fulfillmentLabel }}</text>
<text v-for="tag in product.tags || []" :key="tag" class="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600">{{ tag }}</text>
<text v-for="highlight in product.highlights || []" :key="highlight" class="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600">{{ highlight }}</text>
<text v-if="!(product.highlights || []).length" class="text-[10px] text-slate-400">尚未配置销售标签</text>
</view>
<text class="mt-2 block text-lg font-bold leading-7 text-slate-950">{{ product.name }}</text>
<text class="mt-1 block text-xs leading-5 text-slate-500">{{ product.description || product.shortTitle || '暂无商品简介' }}</text>
<view v-if="product.ageRule" class="mt-3 rounded-xl border border-violet-100 bg-violet-50 p-3 text-[11px] leading-5 text-violet-700">
<text class="font-bold">年龄限制:{{ ageRuleText }}</text>
<text class="block">下单时仅对门票游客按使用日和身份证校验。</text>
</view>
<text class="mt-2 block text-lg font-bold leading-7 text-slate-950">{{ product.commodityName }}</text>
<text v-if="product.subtitle" class="mt-1 block text-xs leading-5 text-slate-500">{{ product.subtitle }}</text>
<view class="mt-3 flex items-end gap-1 text-orange-600">
<text v-if="Number(product.startingPrice) === 0" class="text-2xl font-bold leading-none">免费预约</text>
<text v-if="Number(product.salePrice) === 0" class="text-2xl font-bold leading-none">免费预约</text>
<template v-else>
<text class="pb-0.5 text-xs">{{ currencySymbol }}</text>
<text class="text-3xl font-bold leading-none">{{ product.startingPrice }}</text>
<text class="pb-0.5 text-xs">¥</text>
<text class="text-3xl font-bold leading-none">{{ product.salePrice }}</text>
<text class="pb-0.5 text-[10px] text-slate-400"></text>
</template>
</view>
</view>
<view
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 class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="block text-[16px] font-semibold text-slate-800">费用包含</text>
<text v-for="item in product.included || []" :key="item" class="mt-[10px] block text-[14px] leading-[20px] text-slate-600">· {{ item }}</text>
<text v-if="!(product.included || []).length" class="mt-[10px] block text-[14px] text-slate-400">以商品说明为准</text>
</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]' : ''"
>
<text class="block text-[16px] font-semibold text-slate-800">{{ section.title }}</text>
<view class="mt-[10px] text-[14px] leading-[22px] text-slate-600">
<MpHtml
:content="section.content || ''"
:preview-img="false"
:show-img-menu="false"
:set-title="false"
:tag-style="detailContentTagStyle"
@ready="measureDetailSectionOffsets"
/>
</view>
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="block text-[16px] font-semibold text-slate-800">费用不含</text>
<text v-for="item in product.excluded || []" :key="item" class="mt-[10px] block text-[14px] leading-[20px] text-slate-600">· {{ item }}</text>
<text v-if="!(product.excluded || []).length" class="mt-[10px] block text-[14px] text-slate-400">无</text>
</view>
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="block text-[16px] font-semibold text-slate-800">商品详情</text>
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.detail || product.description || '暂无商品详情' }}</text>
</view>
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="block text-[16px] font-semibold text-slate-800">购买须知</text>
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.purchaseNotice || '请以实际预约规则为准。' }}</text>
</view>
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="block text-[16px] font-semibold text-slate-800">使用说明</text>
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.useRule || '请按预约日期和场次使用,具体以出票凭证及现场指引为准。' }}</text>
</view>
<view class="mx-[12px] mb-[20px] mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="block text-[16px] font-semibold text-slate-800">退款说明</text>
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.refundRule || '未使用且符合商品退改规则时可申请退款,最终结果以订单审核为准。' }}</text>
</view>
</scroll-view>
@@ -86,10 +75,10 @@
<view class="flex items-center justify-between">
<view>
<text class="text-[12px] text-slate-500">优惠价</text>
<text class="ml-[4px] text-[14px] font-medium text-[#f26b32]">{{ currencySymbol }}<text class="text-[25px] font-bold">{{ product.startingPrice }}</text></text>
<text class="ml-[4px] text-[14px] font-medium text-[#f26b32]">¥<text class="text-[25px] font-bold">{{ product.salePrice }}</text>起</text>
</view>
<view class="rounded-full bg-[#f26b32] px-[28px] py-[11px]" @click="emit('buy', product.commodityId)">
<text class="text-[15px] font-semibold text-white">{{ product.bookingButtonText || '去预订' }}</text>
<view class="rounded-full bg-[#f26b32] px-[28px] py-[11px]" @click="emit('buy', product.id)">
<text class="text-[15px] font-semibold text-white">去预订</text>
</view>
</view>
</view>
@@ -97,243 +86,19 @@
</template>
<script setup>
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 ?? "");
},
};
};
import { computed } from "vue";
const props = defineProps({
product: { type: Object, required: true },
});
const emit = defineEmits(["buy"]);
const detailContentTagStyle = {
p: "margin: 8px 0; line-height: 1.7; color: #475569; word-break: break-word;",
img: "max-width: 100%; height: auto; display: block; margin: 10px auto;",
ul: "margin: 8px 0; padding-left: 20px; color: #475569;",
ol: "margin: 8px 0; padding-left: 20px; color: #475569;",
li: "margin: 4px 0; line-height: 1.7; color: #475569;",
blockquote:
"margin: 10px 0; padding: 8px 12px; border-left: 3px solid #cbd5e1; background-color: #f8fafc; color: #64748b;",
strong: "font-weight: 700; color: #334155;",
};
const productImages = computed(() => [
...new Set(
[
props.product.coverImage,
...(Array.isArray(props.product.imageList) ? props.product.imageList : []),
]
.filter((image) => typeof image === "string" && image.trim())
.map((image) => image.trim())
),
]);
const detailSections = computed(() =>
[...(props.product.detailSections || [])].sort(
(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 || ""
const productImages = computed(() =>
(props.product.images || []).filter(Boolean)
);
const ageRuleText = computed(() => {
const { min, max } = props.product.ageRule || {};
if (Number(max) >= 999) return `${min}周岁及以上`;
return `${min}—${max}周岁`;
});
</script>

View File

@@ -1,54 +0,0 @@
<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

@@ -1,10 +1,7 @@
<template>
<view class="h-screen overflow-hidden bg-[#06111f] flex flex-col">
<view class="relative z-0 h-[43vh] shrink-0 overflow-hidden bg-[#173a34]">
<TicketMediaSwiper
:media-list="homeMediaList"
class="absolute inset-0 h-full w-full"
/>
<image class="absolute inset-0 h-full w-full" :src="scenicSpot.heroImage" 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-x-0 top-0 z-20">
@@ -26,16 +23,16 @@
</view>
<view class="absolute bottom-[42px] left-[20px] right-[20px]">
<text class="block text-[28px] font-bold leading-[1.2] text-white">{{ homeData.scenicName }}</text>
<text v-if="scenicMetaText" class="mt-[7px] block text-[13px] text-white/85">{{ scenicMetaText }}</text>
<view v-if="homeData.address" class="mt-[10px] flex items-center">
<text class="block text-[28px] font-bold leading-[1.2] text-white">{{ scenicSpot.name }}</text>
<text class="mt-[7px] block text-[13px] text-white/85">{{ scenicSpot.level }}|{{ scenicSpot.heritage }}</text>
<view class="mt-[10px] flex items-center">
<uni-icons type="location" size="15" color="#FFFFFF" />
<text class="ml-[4px] flex-1 text-[12px] leading-[17px] text-white/85">{{ homeData.address }}</text>
<text class="ml-[4px] flex-1 text-[12px] leading-[17px] text-white/85">{{ scenicSpot.address }}</text>
</view>
<view class="mt-[10px] flex items-center gap-[18px]">
<view v-if="homeData.phone" class="flex items-center" @click="makePhoneCall">
<view class="flex items-center" @click="makePhoneCall">
<uni-icons type="phone" size="15" color="#FFFFFF" />
<text class="ml-[4px] text-[12px] text-white">{{ homeData.phone }}</text>
<text class="ml-[4px] text-[12px] text-white">{{ scenicSpot.phone }}</text>
</view>
<view class="flex items-center" @click="openLocation">
<uni-icons type="navigate" size="15" color="#FFFFFF" />
@@ -55,14 +52,14 @@
>
<view
v-for="group in groups"
:id="`ticket-nav-${group.categoryId}`"
:key="group.categoryId"
:id="`ticket-nav-${group.id}`"
:key="group.id"
class="relative flex min-h-[68px] items-center justify-center px-[10px] py-[12px] text-center"
:class="isActiveGroup(group.categoryId) ? 'bg-[#ecf8f2] text-[#11795c]' : 'bg-white text-[#5f6875]'"
@click="selectGroup(group.categoryId)"
:class="group.id === activeGroupId ? 'bg-[#ecf8f2] text-[#11795c]' : 'bg-white text-[#5f6875]'"
@click="selectGroup(group.id)"
>
<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="isActiveGroup(group.categoryId) ? 'font-semibold' : 'font-normal'">{{ group.categoryName }}</text>
<view v-if="group.id === activeGroupId" 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>
</view>
</scroll-view>
@@ -73,38 +70,35 @@
: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">
<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 v-for="group in groups" :id="`ticket-group-${group.id}`" :key="group.id" class="pt-[14px]">
<view class="flex items-center">
<text class="text-[18px] font-bold text-[#202733]">{{ group.categoryName }}</text>
<text v-if="group.title" class="ml-[7px] rounded-full bg-[#e5f6ee] px-[7px] py-[2px] text-[10px] text-[#158061]">{{ group.title }}</text>
<text class="text-[18px] font-bold text-[#202733]">{{ group.name }}</text>
<text v-if="group.tag" class="ml-[7px] rounded-full bg-[#e5f6ee] px-[7px] py-[2px] text-[10px] text-[#158061]">{{ group.tag }}</text>
</view>
<text v-if="group.subtitle" class="mt-[4px] block text-[12px] leading-[17px] text-[#808997]">{{ group.subtitle }}</text>
<text v-if="group.description" class="mt-[4px] block text-[12px] leading-[17px] text-[#808997]">{{ group.description }}</text>
<view v-if="productsForGroup(group).length" class="mt-[10px] flex flex-col gap-[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.image" mode="aspectFill" @click="emit('open-detail', product.commodityId)" />
<view v-for="product in productsForGroup(group)" :key="product.id" 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)" />
<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.commodityId)">
<text class="line-clamp-2 min-w-0 text-[14px] font-semibold leading-[20px] text-[#242b36]">{{ product.title }}</text>
<view class="flex min-w-0 items-start" @click="emit('open-detail', product.id)">
<text class="line-clamp-2 min-w-0 text-[14px] font-semibold leading-[20px] text-[#242b36]">{{ product.shortTitle || product.name }}</text>
<view class="flex h-[20px] shrink-0 items-center">
<uni-icons type="right" size="14" color="#242b36" />
</view>
</view>
<view v-if="product.tags?.length" class="mt-[5px] flex flex-wrap gap-[4px]">
<text v-for="tag in product.tags" :key="tag" class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]">{{ tag }}</text>
<view 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="highlight in product.highlights || []" :key="highlight" class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]">{{ highlight }}</text>
</view>
<view class="mt-auto flex items-end justify-between pt-[6px]">
<view>
<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.displayPrice }}</text></text>
<text v-if="product.sold !== null && product.sold !== undefined" class="block text-[10px] text-[#9aa2ad]">已售 {{ product.sold }}</text>
<text class="text-[12px] font-medium text-[#f26b32]">¥<text class="text-[20px] font-bold">{{ product.salePrice }}</text></text>
</view>
<view class="flex h-[28px] items-center justify-center rounded-full bg-[#f26b32] px-[13px]" @click="emit('buy', product.commodityId)">
<view class="flex h-[28px] items-center justify-center rounded-full bg-[#f26b32] px-[13px]" @click="emit('buy', product.id)">
<text class="text-[12px] font-medium leading-none text-white">选购</text>
</view>
</view>
@@ -112,7 +106,7 @@
</view>
</view>
<view v-else class="mt-[10px] rounded-[14px] bg-white px-[16px] py-[28px] text-center">
<text class="text-[13px] text-[#9aa2ad]">暂无可售商品</text>
<text class="text-[13px] text-[#9aa2ad]">{{ loading ? '加载中...' : '暂无可售商品' }}</text>
</view>
</view>
</view>
@@ -125,77 +119,38 @@
<script setup>
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
import TopNavBar from "@/components/TopNavBar/index.vue"
import TicketMediaSwiper from "./TicketMediaSwiper.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 },
scenicSpot: { type: Object, required: true },
groups: { type: Array, default: () => [] },
products: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
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 activeGroupId = ref(props.groups[0]?.id || '')
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('|')
)
const homeMediaList = computed(() => {
const mediaList = Array.isArray(props.homeData.mediaList)
? props.homeData.mediaList.filter(
(media) => typeof media === 'string' && media.trim()
)
: []
if (mediaList.length) return mediaList
const productsById = computed(() => new Map(props.products.map((product) => [product.id, product])))
const groupLayoutSignature = computed(() => props.groups
.map((group) => `${group.id}:${(group.productIds || []).join(',')}`)
.join('|'))
return typeof props.homeData.coverImage === 'string' &&
props.homeData.coverImage.trim()
? [props.homeData.coverImage]
: []
})
const isActiveGroup = (groupId) =>
String(groupId) === String(activeGroupId.value)
const productsForGroup = (group) =>
Array.isArray(group.products) ? group.products : []
const productsForGroup = (group) => (group.productIds || [])
.map((productId) => productsById.value.get(productId))
.filter((product) => product?.status === 'on_sale' && product.clientVisible !== false)
const enableProductScrollSync = () => {
categoryScrollSelection.resumeScrollSync()
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) => {
activeGroupId.value = categoryScrollSelection.select(groupId)
activeGroupId.value = groupId
scrollIntoViewId.value = ''
nextTick(() => {
scrollIntoViewId.value = `ticket-group-${groupId}`
@@ -203,20 +158,20 @@ const selectGroup = (groupId) => {
}
const measureGroupOffsets = () => {
if (!groups.value.length) return
if (!props.groups.length) return
nextTick(() => {
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('#ticket-product-scroll').boundingClientRect()
groups.value.forEach((group) => {
query.select(`#ticket-group-${group.categoryId}`).boundingClientRect()
props.groups.forEach((group) => {
query.select(`#ticket-group-${group.id}`).boundingClientRect()
})
query.exec((rects = []) => {
const containerRect = rects[0]
if (!containerRect) return
groupOffsets.value = groups.value
groupOffsets.value = props.groups
.map((group, index) => ({
id: group.categoryId,
id: group.id,
top: Math.max(
0,
Number(rects[index + 1]?.top || 0) -
@@ -241,52 +196,24 @@ const handleProductScroll = (event) => {
(current, group) => (group.top <= activationLine ? group : current),
groupOffsets.value[0]
)
if (activeGroup?.id) {
activeGroupId.value = categoryScrollSelection.resolve(
activeGroupId.value,
activeGroup.id
)
}
if (activeGroup?.id) activeGroupId.value = activeGroup.id
}
onMounted(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()
}
)
watch(groupLayoutSignature, () => {
groupOffsets.value = []
measureGroupOffsets()
}, { flush: 'post' })
const makePhoneCall = () => {
if (!props.homeData.phone) return
uni.makePhoneCall({ phoneNumber: props.homeData.phone })
if (!props.scenicSpot.phone) return
uni.makePhoneCall({ phoneNumber: props.scenicSpot.phone })
}
const openLocation = () => {
const latitudeValue = props.homeData.latitude
const longitudeValue = props.homeData.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)
) {
const latitude = Number(props.scenicSpot.latitude)
const longitude = Number(props.scenicSpot.longitude)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
uni.showToast({ title: '暂无导航坐标', icon: 'none' })
return
}
@@ -294,8 +221,8 @@ const openLocation = () => {
uni.openLocation({
latitude,
longitude,
name: props.homeData.scenicName || '',
address: props.homeData.address || '',
name: props.scenicSpot.name || '',
address: props.scenicSpot.address || '',
fail: () => uni.showToast({ title: '打开导航失败', icon: 'none' }),
})
}

View File

@@ -1,58 +0,0 @@
<template>
<swiper
class="h-full w-full"
:indicator-dots="false"
:autoplay="hasMultipleMedia"
:circular="hasMultipleMedia"
:interval="5000"
:duration="500"
>
<swiper-item
v-for="(media, index) in normalizedMediaList"
:key="`${media}-${index}`"
class="h-full w-full"
>
<video
v-if="isMp4Video(media)"
class="h-full w-full"
:src="media"
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
class="h-full w-full"
:src="media"
mode="aspectFill"
/>
</swiper-item>
</swiper>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
mediaList: { type: Array, default: () => [] },
});
const normalizedMediaList = computed(() =>
props.mediaList
.filter((media) => typeof media === "string" && media.trim())
.map((media) => media.trim())
);
const hasMultipleMedia = computed(() => normalizedMediaList.value.length > 1);
const isMp4Video = (url) => {
if (typeof url !== "string") return false;
const resourcePath = url.trim().split(/[?#]/)[0];
return resourcePath.toLowerCase().endsWith(".mp4");
};
</script>

View File

@@ -49,12 +49,9 @@ export const SCENIC_SPOT = {
}
export const GROUPS = [
{ id: 'ticket', name: '景点门票', subtitle: '累计销量 5000+', description: '一票通玩,核销后门票7日内有效', tag: '官方', productIds: ['adult', 'elderly', 'minor', 'free'], sortOrder: 1, enabled: true },
{ id: 'vip', name: 'VIP速通', subtitle: '专属服务', description: '减少排队等待时间,按预约场次使用', tag: '推荐', productIds: ['vip-fast'], sortOrder: 2, enabled: true },
{ id: 'bus', name: '直通车', subtitle: '往返直达', description: '贵阳市区集合,直达小七孔景区', tag: '省心', productIds: ['direct-bus'], sortOrder: 3, enabled: true },
{ id: 'package', name: '门票+观光车', subtitle: '累计销量 1.5万+', description: '门票与观光车由第三方出票,园内项目使用自营权益码', tag: '热卖', productIds: ['classic'], sortOrder: 4, enabled: true },
{ id: 'tour', name: '一日游', subtitle: '精选线路', description: '轻松游览小七孔代表性景观', tag: '品质', productIds: ['day-tour'], sortOrder: 5, enabled: true },
{ id: 'play', name: '玩乐体验', subtitle: '园内自营项目', description: '工作人员扫描园内权益码后选择项目核销', tag: '自营', productIds: ['boat', 'guide'], sortOrder: 6, enabled: true },
{ id: 'ticket', name: '门票', commodityTypeCode: '1', productIds: [], sortOrder: 1, enabled: true },
{ id: 'maolan', name: '茂兰景区', commodityTypeCode: '67d07734e0af4caa918309f5a346a86e', productIds: [], sortOrder: 2, enabled: true },
{ id: 'heritage-tribe', name: '世遗部落', commodityTypeCode: 'd5766c63028e46f1bd9aaa25d1dc7482', productIds: [], sortOrder: 3, enabled: true },
]
const TIME_SLOTS = ['08:00—09:30', '09:30—11:30', '11:30—13:30', '13:30—15:00', '15:00—16:00']

View File

@@ -1,88 +1,111 @@
<template>
<view class="h-screen min-w-0 overflow-hidden bg-[#f5f7fa] text-[#172033]">
<TicketHome
v-if="homeData"
:home-data="homeData"
:scenic-spot="SCENIC_SPOT"
:groups="visibleGroups"
:products="products"
:loading="loading"
@back="goBack"
@open-travelers="openTravelers"
@open-orders="openOrders"
@open-detail="openProductDetail"
@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>
</template>
<script setup>
import { onMounted, ref } from "vue";
import TopNavBar from "@/components/TopNavBar/index.vue";
import { computed, onMounted, ref } from "vue";
import { quickBookingList } from "@/request/api/GoodsApi";
import TicketHome from "./components/TicketHome.vue";
import { scenicProductHome } from "./api/scenic.js";
import { GROUPS, PLACEHOLDER_IMAGE, SCENIC_SPOT } from "./data.js";
const homeData = ref(null);
const loading = ref(false);
const errorMessage = ref("");
const groups = ref(GROUPS.map((group) => ({ ...group, productIds: [] })));
const products = ref([]);
const loading = ref(true);
const loadTicketHome = async () => {
const visibleGroups = computed(() =>
[...groups.value]
.filter((group) => group.enabled)
.sort((left, right) => left.sortOrder - right.sortOrder)
);
const normalizeStringList = (value) =>
Array.isArray(value) ? value.filter(Boolean).map(String) : [];
const mapQuickBookingProduct = (item) => ({
id: String(item.commodityId),
commodityId: String(item.commodityId),
name: item.commodityName || "未命名商品",
shortTitle: item.commodityName || "未命名商品",
salePrice: item.specificationPrice ?? item.commodityPrice ?? 0,
sold: item.salesVolume ?? item.soldQuantity ?? item.saleCount ?? null,
images: [item.commodityPhoto || PLACEHOLDER_IMAGE],
highlights: normalizeStringList(item.commodityTradeRuleList),
status: "on_sale",
clientVisible: true,
});
const loadQuickBookingProducts = 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);
const results = await Promise.all(
GROUPS.map(async (group) => {
try {
const res = await quickBookingList({
commodityTypeCode: group.commodityTypeCode,
current: 1,
size: 10,
});
const records = Array.isArray(res?.data?.records)
? res.data.records.filter((item) => item?.commodityId)
: [];
if (response?.code !== 0 || !validData) {
throw new Error(response?.msg || "景区商品加载失败,请稍后重试");
}
if (!Array.isArray(res?.data?.records)) {
console.error(`查询${group.name}商品失败:`, res);
}
homeData.value = response.data;
} catch (error) {
homeData.value = null;
errorMessage.value = error?.message || "景区商品加载失败,请稍后重试";
} finally {
loading.value = false;
}
return { groupId: group.id, records };
} catch (error) {
console.error(`查询${group.name}商品失败:`, error);
return { groupId: group.id, records: [] };
}
})
);
const recordsByGroupId = new Map(
results.map(({ groupId, records }) => [groupId, records])
);
const productMap = new Map();
groups.value = GROUPS.map((group) => {
const groupProducts = (recordsByGroupId.get(group.id) || []).map(
mapQuickBookingProduct
);
groupProducts.forEach((product) => productMap.set(product.id, product));
return {
...group,
productIds: groupProducts.map((product) => product.id),
};
});
products.value = [...productMap.values()];
loading.value = false;
};
onMounted(loadTicketHome);
onMounted(loadQuickBookingProducts);
const goBack = () => uni.navigateBack();
const openTravelers = () =>
uni.navigateTo({ url: "/pages-service/tickets/travelers" });
const openOrders = () =>
uni.navigateTo({ url: "/pages-service/tickets/orders" });
const openProductDetail = (commodityId) =>
const openProductDetail = (productId) =>
uni.navigateTo({
url: `/pages-service/tickets/product-detail?commodityId=${commodityId}`,
url: `/pages/goods/index?commodityId=${encodeURIComponent(productId)}`,
});
const startBooking = (productId) =>
uni.navigateTo({
url: `/pages-service/tickets/booking?productId=${productId}`,
url: `/pages/goods/index?commodityId=${encodeURIComponent(productId)}`,
});
</script>

View File

@@ -23,18 +23,13 @@ test('provides coherent default visitor ticket data', async () => {
assert.deepEqual(SCENIC_SPOT, {
id: 'libo-xiaoqikong', name: '荔波小七孔景区', level: '国家5A级景区', heritage: '世界自然遗产', address: '贵州省黔南布依族苗族自治州荔波县', phone: '0854-3619810', latitude: 25.2534, longitude: 107.7255, heroImage: SCENIC_HERO_IMAGE,
})
assert.deepEqual(GROUPS.map(({ id, name, sortOrder, enabled, productIds }) => ({ id, name, sortOrder, enabled, productIds })), [
{ id: 'ticket', name: '景点门票', sortOrder: 1, enabled: true, productIds: ['adult', 'elderly', 'minor', 'free'] },
{ id: 'vip', name: 'VIP速通', sortOrder: 2, enabled: true, productIds: ['vip-fast'] },
{ id: 'bus', name: '直通车', sortOrder: 3, enabled: true, productIds: ['direct-bus'] },
{ id: 'package', name: '门票+观光车', sortOrder: 4, enabled: true, productIds: ['classic'] },
{ id: 'tour', name: '一日游', sortOrder: 5, enabled: true, productIds: ['day-tour'] },
{ id: 'play', name: '玩乐体验', sortOrder: 6, enabled: true, productIds: ['boat', 'guide'] },
assert.deepEqual(GROUPS.map(({ id, name, commodityTypeCode, sortOrder, enabled, productIds }) => ({ id, name, commodityTypeCode, sortOrder, enabled, productIds })), [
{ id: 'ticket', name: '门票', commodityTypeCode: '1', sortOrder: 1, enabled: true, productIds: [] },
{ id: 'maolan', name: '茂兰景区', commodityTypeCode: '67d07734e0af4caa918309f5a346a86e', sortOrder: 2, enabled: true, productIds: [] },
{ id: 'heritage-tribe', name: '世遗部落', commodityTypeCode: 'd5766c63028e46f1bd9aaa25d1dc7482', sortOrder: 3, enabled: true, productIds: [] },
])
assert.deepEqual(PRODUCTS.map(({ id }) => id), ['adult', 'elderly', 'minor', 'free', 'vip-fast', 'direct-bus', 'classic', 'day-tour', 'boat', 'guide'])
const productsById = new Map(PRODUCTS.map((product) => [product.id, product]))
assert.ok(GROUPS.every((group) => group.productIds.every((productId) => productsById.has(productId))))
assert.deepEqual(GROUPS.flatMap((group) => group.productIds).sort(), PRODUCTS.map(({ id }) => id).sort())
const productKeys = ['id', 'name', 'shortTitle', 'category', 'description', 'salePrice', 'marketPrice', 'stock', 'sold', 'status', 'clientVisible', 'highlights', 'ageRule', 'included', 'excluded', 'detail', 'purchaseNotice', 'images', 'reservation', 'entitlements']
const reservationKeys = ['requiresDate', 'advanceDays', 'requiresTimeSlot', 'timeSlots', 'realNameRequired', 'idTypes', 'maxQuantity', 'contactPhoneRequired']
const entitlementKeys = ['id', 'name', 'fulfillment', 'quantity', 'unit', 'refundAllocation']

View File

@@ -6,83 +6,31 @@
title-align="left"
background="#ffffff"
/>
<view
v-if="loading"
class="flex min-h-0 flex-1 items-center justify-center"
>
<text class="text-[14px] text-slate-400">正在加载商品详情...</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-slate-400">
{{ errorMessage }}
</text>
<view
class="mt-[18px] flex h-[38px] items-center justify-center rounded-full bg-[#f26b32] px-[22px]"
@click="loadProductDetail"
>
<text class="text-[13px] font-medium text-white">重新加载</text>
</view>
</view>
<ProductDetail
v-else-if="product"
v-if="product"
class="min-h-0 flex-1"
:product="product"
@buy="startBooking"
/>
<view v-else class="flex min-h-0 flex-1 items-center justify-center">
<text class="text-[14px] text-slate-400">商品不存在或已下架</text>
</view>
<view v-else class="flex min-h-0 flex-1 items-center justify-center text-sm text-slate-400">商品不存在或已下架</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import TopNavBar from "@/components/TopNavBar/index.vue";
import ProductDetail from "./components/ProductDetail.vue";
import { scenicProductDetail } from "./api/scenic.js";
import { useTicketStore } from "./store.js";
const commodityId = ref("");
const product = ref(null);
const loading = ref(false);
const errorMessage = ref("");
const ticketStore = useTicketStore();
const productId = ref("");
const product = computed(() =>
ticketStore.products.find((item) => item.id === productId.value)
);
const loadProductDetail = async () => {
if (!commodityId.value) {
product.value = null;
errorMessage.value = "缺少商品参数";
return;
}
loading.value = true;
errorMessage.value = "";
try {
const response = await scenicProductDetail({ commodityId: commodityId.value });
const validData =
response?.data &&
typeof response.data === "object" &&
!Array.isArray(response.data);
if (response?.code !== 0 || !validData) {
throw new Error(response?.msg || "商品详情加载失败,请稍后重试");
}
product.value = response.data;
} catch (error) {
product.value = null;
errorMessage.value = error?.message || "商品详情加载失败,请稍后重试";
} finally {
loading.value = false;
}
};
onLoad(async (options) => {
commodityId.value = options?.commodityId || "";
await loadProductDetail();
onLoad((options) => {
productId.value = options?.productId || "";
});
const startBooking = (selectedProductId) =>

View File

@@ -60,283 +60,39 @@ test("storefront synchronizes the left category from right-column scrolling", as
assert.match(source, /groupOffsets/);
assert.match(source, /scrollTop/);
assert.match(source, /leftScrollIntoViewId/);
assert.match(source, /groupLayoutSignature/);
assert.match(source, /group\.productIds/);
assert.match(source, /groupOffsets\.value = \[\]/);
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("tickets use a local indicator-free media swiper", async () => {
const source = await readTicketFile("components/TicketMediaSwiper.vue");
assert.match(source, /<swiper/);
assert.match(source, /:indicator-dots="false"/);
assert.match(source, /:autoplay="hasMultipleMedia"/);
assert.match(source, /:circular="hasMultipleMedia"/);
assert.match(source, /:interval="5000"/);
assert.match(source, /v-for="\(media, index\) in normalizedMediaList"/);
assert.match(source, /<video\s+v-if="isMp4Video\(media\)"/);
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/);
assert.match(source, /const isMp4Video = \(url\) =>/);
assert.match(source, /url\.trim\(\)\.split\(\/\[\?\#\]\/\)\[0\]/);
assert.match(source, /toLowerCase\(\)\.endsWith\("\.mp4"\)/);
});
test("TicketHome prefers mediaList and falls back to coverImage", async () => {
const source = await readTicketFile("components/TicketHome.vue");
assert.match(source, /import TicketMediaSwiper from "\.\/TicketMediaSwiper\.vue"/);
assert.match(source, /<TicketMediaSwiper\s+:media-list="homeMediaList"/);
assert.match(source, /props\.homeData\.mediaList/);
assert.match(source, /const homeMediaList = computed\(\(\) =>/);
assert.match(source, /if \(mediaList\.length\) return mediaList/);
assert.match(source, /props\.homeData\.coverImage/);
assert.doesNotMatch(source, /<video/);
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("storefront products open the API-backed goods detail page", async () => {
const indexPage = await readTicketFile("index.vue");
const detailPage = await readTicketFile("product-detail.vue");
const component = await readTicketFile("components/ProductDetail.vue");
const data = await readTicketFile("data.js");
assert.match(indexPage, /uni\.navigateTo\(\{\s*url: `\/pages-service\/tickets\/product-detail\?commodityId=\$\{commodityId\}`/);
assert.match(indexPage, /quickBookingList/);
assert.match(indexPage, /commodityTypeCode: group\.commodityTypeCode/);
assert.match(indexPage, /current: 1/);
assert.match(indexPage, /size: 10/);
assert.match(indexPage, /url: `\/pages\/goods\/index\?commodityId=\$\{encodeURIComponent\(productId\)\}`/);
assert.doesNotMatch(indexPage, /detailProduct|<ProductDetail/);
assert.match(detailPage, /onLoad\(async \(options\)/);
assert.match(detailPage, /options\?\.commodityId/);
assert.match(
detailPage,
/import \{ scenicProductDetail \} from "\.\/api\/scenic\.js"/
);
assert.match(detailPage, /scenicProductDetail\(\{ commodityId: commodityId\.value \}\)/);
assert.match(detailPage, /product\.value = response\.data/);
assert.match(detailPage, /v-if="loading"/);
assert.match(detailPage, /v-else-if="errorMessage"/);
assert.match(detailPage, /@click="loadProductDetail"/);
assert.doesNotMatch(detailPage, /useTicketStore|ticketStore\.products/);
assert.match(detailPage, /<ProductDetail\s+v-else-if="product"/);
assert.match(detailPage, /onLoad\(\(options\)/);
assert.match(detailPage, /options\?\.productId/);
assert.match(detailPage, /<ProductDetail/);
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/);
assert.match(component, /new Set/);
assert.match(component, /1 \/ \{\{ productImages\.length \}\}/);
assert.match(component, /rounded bg-blue-50 px-1\.5 py-0\.5 text-\[10px\] font-semibold text-blue-600/);
for (const field of [
"product.coverImage",
"product.imageList",
"product.tags",
"product.fulfillmentLabel",
"product.commodityName",
"product.subtitle",
"product.startingPrice",
"product.currency",
"product.detailSections",
"product.bookingButtonText",
"product.commodityId",
]) {
assert.ok(component.includes(field), `${field} is not rendered`);
}
assert.match(component, /v-for="\(section, index\) in detailSections"/);
assert.match(component, /section\.sectionCode/);
assert.match(component, /section\.title/);
assert.match(component, /section\.content/);
assert.match(
component,
/import MpHtml from "@\/uni_modules\/zero-markdown-view\/components\/mp-html\/mp-html\.vue"/
);
assert.match(component, /<MpHtml/);
assert.match(component, /:content="section\.content \|\| ''"/);
assert.match(component, /:preview-img="false"/);
assert.match(component, /:show-img-menu="false"/);
assert.match(component, /:set-title="false"/);
assert.match(component, /:tag-style="detailContentTagStyle"/);
assert.match(
component,
/img:\s*"[^"]*max-width:\s*100%[^"]*height:\s*auto/
);
assert.doesNotMatch(
component,
/<text[^>]*>\{\{\s*section\.content\s*\}\}<\/text>/
);
assert.match(
component,
/sort\(\s*\(left, right\) => Number\(left\.sortOrder/
);
assert.match(component, /emit\('buy', product\.commodityId\)/);
assert.doesNotMatch(
component,
/product\.name|product\.salePrice|product\.included|product\.excluded|product\.purchaseNotice|product\.useRule|product\.refundRule|product\.id/
);
});
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"
);
assert.doesNotMatch(component, /bg-violet-50 px-\[7px\] py-\[3px\] text-\[11px\] text-violet-700/);
assert.match(component, /product\.description/);
assert.match(component, /使用说明/);
assert.match(component, /product\.useRule/);
assert.match(component, /退款说明/);
assert.match(component, /product\.refundRule/);
assert.match(data, /useRule/);
assert.match(data, /refundRule/);
});
test("booking flow matches the visitor prototype while preserving requested controls", async () => {

View File

@@ -3,7 +3,7 @@
<!-- 标题区域 -->
<view class="title-section">
<text class="title">
{{ goodsData.commodityName || "【成人票】云从朵花温泉门票" }}
{{ goodsData.commodityName }}
</text>
<view class="tag-wrapper" v-if="goodsData.timeTag">
<view class="time-tag">{{ goodsData.timeTag || "随时可退" }}</view>

View File

@@ -0,0 +1,64 @@
<template>
<view class="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#f5f5f5]">
<view class="skeleton-block relative h-[300px] w-full shrink-0 overflow-hidden bg-[#e7ebef]" />
<view class="relative z-10 -mt-[30px] min-h-0 flex-1 rounded-t-[28px] bg-white px-[16px] pb-[24px] pt-[24px]">
<view class="skeleton-block relative h-[22px] w-[72%] overflow-hidden rounded-[6px] bg-[#e9edf1]" />
<view class="mt-[12px] flex gap-[8px]">
<view class="skeleton-block relative h-[20px] w-[64px] overflow-hidden rounded-[4px] bg-[#edf0f3]" />
<view class="skeleton-block relative h-[20px] w-[76px] overflow-hidden rounded-[4px] bg-[#edf0f3]" />
<view class="skeleton-block relative h-[20px] w-[56px] overflow-hidden rounded-[4px] bg-[#edf0f3]" />
</view>
<view class="mt-[18px] flex items-end">
<view class="skeleton-block relative h-[28px] w-[110px] overflow-hidden rounded-[6px] bg-[#e9edf1]" />
<view class="skeleton-block relative ml-[12px] h-[16px] w-[70px] overflow-hidden rounded-[4px] bg-[#f0f2f4]" />
</view>
<view class="mt-[24px] rounded-[12px] bg-[#f8f9fa] p-[14px]">
<view class="flex items-center">
<view class="skeleton-block relative h-[24px] w-[24px] overflow-hidden rounded-full bg-[#e5e9ed]" />
<view class="ml-[10px] min-w-0 flex-1">
<view class="skeleton-block relative h-[16px] w-[58%] overflow-hidden rounded-[4px] bg-[#e8ecef]" />
<view class="skeleton-block relative mt-[8px] h-[12px] w-[86%] overflow-hidden rounded-[4px] bg-[#eef1f3]" />
</view>
</view>
</view>
<view class="mt-[24px]">
<view class="skeleton-block relative h-[20px] w-[88px] overflow-hidden rounded-[5px] bg-[#e8ecef]" />
<view class="skeleton-block relative mt-[14px] h-[14px] w-full overflow-hidden rounded-[4px] bg-[#eef1f3]" />
<view class="skeleton-block relative mt-[10px] h-[14px] w-[92%] overflow-hidden rounded-[4px] bg-[#eef1f3]" />
<view class="skeleton-block relative mt-[10px] h-[14px] w-[76%] overflow-hidden rounded-[4px] bg-[#eef1f3]" />
</view>
</view>
<view class="flex shrink-0 items-center bg-white px-[12px] pb-[24px] pt-[12px]">
<view class="skeleton-block relative h-[28px] w-[92px] overflow-hidden rounded-[6px] bg-[#e9edf1]" />
<view class="skeleton-block relative ml-auto h-[48px] w-[120px] overflow-hidden rounded-[10px] bg-[#e4e8ec]" />
</view>
</view>
</template>
<style scoped lang="scss">
.skeleton-block {
&::after {
position: absolute;
inset: 0;
content: "";
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.7) 50%,
transparent 100%
);
transform: translateX(-100%);
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
}
@keyframes skeleton-shimmer {
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -1,58 +1,73 @@
<template>
<view class="goods-container bg-[#f5f5f5]">
<TopNavBar :title="navOpacity < 0.5 ? '' : '商品详情'" :background="`rgba(217, 238, 255, ${navOpacity})`"
:titleColor="navOpacity < 0.5 ? '#ffffff' : '#000000'"
:backIconColor="navOpacity < 0.5 ? '#ffffff' : '#000000'" />
<TopNavBar :title="solidNavBar || navOpacity >= 0.5 ? '商品详情' : ''"
:background="solidNavBar ? '#ffffff' : `rgba(217, 238, 255, ${navOpacity})`"
:titleColor="solidNavBar || navOpacity >= 0.5 ? '#000000' : '#ffffff'"
:backIconColor="solidNavBar || navOpacity >= 0.5 ? '#000000' : '#ffffff'" />
<!-- 滚动区域 -->
<scroll-view class="content-wrapper" scroll-y @scroll="handleScroll">
<ImageSwiper :border-radius="0" :height="300" :images="goodsData.commodityPhotoList" thumbnailBottom="42px" />
<GoodsDetailSkeleton v-if="loadStatus === 'loading'" />
<view class="goods-content">
<!-- 商品信息组件 -->
<GoodInfo :goodsData="goodsData" />
<!-- 地址区域 -->
<LocationCard :orderData="goodsData" />
<!-- 日期选择区域 -->
<DateSelector v-if="goodsData.orderType == 0" @showCalendar="showCalendar" :checkInDate="selectedDate.startDate"
:checkOutDate="selectedDate.endDate" :checkInDay="''" :checkOutDay="''" :nights="selectedDate.totalDays" />
<!-- 商品套餐组件 -->
<GoodPackage
v-if="goodsData.orderType != 0 && goodsData.commodityPackageConfig && goodsData.commodityPackageConfig.length > 0"
:goodsData="goodsData" />
<!-- 商品设施组件 -->
<GoodFacility v-if="goodsData.commodityEquipment && goodsData.commodityEquipment.length > 0"
:goodsData="goodsData" />
<!-- 商品详情组件 -->
<GoodDetail :goodsData="goodsData" />
</view>
</scroll-view>
<!-- 立即抢购 -->
<view class="footer border-top">
<view class="amt text-[20px] font-bold text-[#ff3d60] line-height-28 flex items-center mr-[8px]">
{{ calculatedTotalPrice }}
</view>
<view class="btn rounded-[10px] flex items-center ml-auto pl-[8px]" @click="navigateToPay(goodsData)">
<image class="icon" src="https://oss.nianxx.cn/mp/static/version_101/common/btn.png" />
<text class="text-[16px] font-[500] text-white">立即预定</text>
<view v-else-if="loadStatus === 'error' || loadStatus === 'empty'"
class="box-border flex min-h-0 flex-1 flex-col items-center justify-center px-[24px] pb-[80px] pt-[96px]">
<CustomEmpty :statusText="loadStatus === 'error' ? '商品加载失败' : '暂无商品数据'" />
<view v-if="loadStatus === 'error'"
class="mt-[20px] flex h-[40px] items-center justify-center rounded-full bg-[#ff3d60] px-[28px]"
@click="reloadGoods">
<text class="text-[14px] font-[500] text-white">重新加载</text>
</view>
</view>
<!-- 日历组件 -->
<Calender :visible="calendarVisible" mode="range" :range-require-price="true" :price-data="priceData"
@close="handleCalendarClose" @range-select="handleDateSelect" />
<template v-else>
<!-- 滚动区域 -->
<scroll-view class="content-wrapper" scroll-y @scroll="handleScroll">
<ImageSwiper :border-radius="0" :height="300" :images="goodsData.commodityPhotoList" thumbnailBottom="42px" />
<view class="goods-content">
<!-- 商品信息组件 -->
<GoodInfo :goodsData="goodsData" />
<!-- 地址区域 -->
<LocationCard :orderData="goodsData" />
<!-- 日期选择区域 -->
<DateSelector v-if="goodsData.orderType == 0" @showCalendar="showCalendar" :checkInDate="selectedDate.startDate"
:checkOutDate="selectedDate.endDate" :checkInDay="''" :checkOutDay="''" :nights="selectedDate.totalDays" />
<!-- 商品套餐组件 -->
<GoodPackage
v-if="goodsData.orderType != 0 && goodsData.commodityPackageConfig && goodsData.commodityPackageConfig.length > 0"
:goodsData="goodsData" />
<!-- 商品设施组件 -->
<GoodFacility v-if="goodsData.commodityEquipment && goodsData.commodityEquipment.length > 0"
:goodsData="goodsData" />
<!-- 商品详情组件 -->
<GoodDetail :goodsData="goodsData" />
</view>
</scroll-view>
<!-- 立即抢购 -->
<view class="footer border-top">
<view class="amt text-[20px] font-bold text-[#ff3d60] line-height-28 flex items-center mr-[8px]">
{{ calculatedTotalPrice }}
</view>
<view class="btn rounded-[10px] flex items-center ml-auto pl-[8px]" @click="navigateToPay(goodsData)">
<image class="icon" src="https://oss.nianxx.cn/mp/static/version_101/common/btn.png" />
<text class="text-[16px] font-[500] text-white">立即预定</text>
</view>
</view>
<!-- 日历组件 -->
<Calender :visible="calendarVisible" mode="range" :range-require-price="true" :price-data="priceData"
@close="handleCalendarClose" @range-select="handleDateSelect" />
</template>
</view>
</template>
<script setup>
import { ref } from "vue";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { goodsDetail, commodityDailyPriceList } from "@/request/api/GoodsApi";
import { DateUtils } from "@/utils";
@@ -65,12 +80,17 @@ import DateSelector from "./components/DateSelector/index.vue";
import GoodDetail from "@/components/GoodDetail/index.vue";
import GoodFacility from "./components/GoodFacility/index.vue";
import GoodPackage from "./components/GoodPackage/index.vue";
import GoodsDetailSkeleton from "./components/GoodsDetailSkeleton/index.vue";
import CustomEmpty from "@/components/CustomEmpty/index.vue";
import { useSelectedDateStore } from "@/store";
// 导航栏透明度 - 默认透明,随滚动变为不透明
const navOpacity = ref(0);
const calendarVisible = ref(false);
const goodsData = ref({});
const loadStatus = ref("loading");
const currentCommodityId = ref("");
const solidNavBar = computed(() => loadStatus.value !== "success");
// 处理滚动事件
const handleScroll = (e) => {
@@ -87,39 +107,67 @@ const selectedDate = ref({
totalDays: 1,
});
const priceData = ref([])
const priceData = ref([]);
// 计算的总价格
const calculatedTotalPrice = ref(0);
// 获取商品详情数据
const goodsInfo = async (params) => {
const res = await goodsDetail(params);
loadStatus.value = "loading";
goodsData.value = {};
calculatedTotalPrice.value = 0;
priceData.value = [];
goodsData.value = res.data;
try {
const res = await goodsDetail(params);
// 初始化计算价格
calculatedTotalPrice.value = goodsData.value.specificationPrice || 0;
if (res?.code !== 0) {
console.error("查询商品详情失败:", res);
loadStatus.value = "error";
return;
}
// 判断是酒店类型订单再获取获取商品日价格及库存
if (goodsData.value.orderType == 0) {
configGoodsData();
getGoodsDailyPrice({
commodityId: goodsData.value.commodityId,
});
}
if (
!res.data ||
typeof res.data !== "object" ||
Array.isArray(res.data) ||
Object.keys(res.data).length === 0 ||
!res.data.commodityId
) {
loadStatus.value = "empty";
return;
}
if (goodsData.value.commodityStatus !== "1") {
uni.showModal({
title: "温馨提示",
content: "您选的商品暂不可预订,请重新选择。",
success: (res) => {
if (res.confirm) {
uni.navigateBack({ delta: 1 });
}
},
});
return;
goodsData.value = res.data;
// 初始化计算价格
calculatedTotalPrice.value = goodsData.value.specificationPrice || 0;
// 判断是酒店类型订单再获取获取商品日价格及库存
if (goodsData.value.orderType == 0) {
configGoodsData();
getGoodsDailyPrice({
commodityId: goodsData.value.commodityId,
});
}
loadStatus.value = "success";
if (goodsData.value.commodityStatus !== "1") {
uni.showModal({
title: "温馨提示",
content: "您选的商品暂不可预订,请重新选择。",
success: (modalRes) => {
if (modalRes.confirm) {
uni.navigateBack({ delta: 1 });
}
},
});
}
} catch (error) {
console.error("查询商品详情失败:", error);
loadStatus.value = "error";
}
};
@@ -139,12 +187,19 @@ const getGoodsDailyPrice = async (params) => {
const selectedDateStore = useSelectedDateStore();
onLoad(({ commodityId = "1950766939442774018" }) => {
currentCommodityId.value = commodityId;
// 从store中获取选中的日期
selectedDate.value = selectedDateStore.selectedDate;
goodsInfo({ commodityId });
});
const reloadGoods = () => {
if (!currentCommodityId.value) return;
goodsInfo({ commodityId: currentCommodityId.value });
};
// 显示日历弹窗
const showCalendar = () => (calendarVisible.value = true);

View File

@@ -13,16 +13,16 @@
*/
/* 主题颜色(由 switch-client 命令自动更新) */
$theme-color-900: #19428F;
$theme-color-800: #174BB6;
$theme-color-700: #145EE1;
$theme-color-600: #1B74F5;
$theme-color-500: #2D91FF;
$theme-color-400: #59B4FF;
$theme-color-300: #8ED0FF;
$theme-color-200: #BCE1FF;
$theme-color-100: #D9EEFF;
$theme-color-50: #EEF8FF;
$theme-color-900: #0B5C2D;
$theme-color-800: #0B7034;
$theme-color-700: #0B5C2D;
$theme-color-600: #02C34E;
$theme-color-500: #0CCD58;
$theme-color-400: #35F37F;
$theme-color-300: #77FEAB;
$theme-color-200: #A0E5BA;
$theme-color-100: #E8FFF1;
$theme-color-50: #F0F8F3;
// text 颜色
$text-color-900: #181b25;