feat: 拆分了商品详情页

This commit is contained in:
2026-07-31 11:38:53 +08:00
parent eecc79f31a
commit 91567851ac
6 changed files with 139 additions and 45 deletions

View File

@@ -1,28 +1,40 @@
<template>
<view class="h-screen overflow-hidden bg-slate-50 flex flex-col">
<TopNavBar
title="商品详情"
subtitle="费用说明与购买须知"
title-align="left"
background="#ffffff"
@back="emit('back')"
/>
<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>
<image class="h-[280px] w-full bg-slate-200" :src="product.images && product.images[0]" mode="aspectFill" />
<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 class="mx-[12px] -mt-[14px] rounded-[16px] bg-white p-[16px]">
<text v-if="product.shortTitle" class="block text-[13px] text-slate-500">{{ product.shortTitle }}</text>
<text class="mt-[4px] block text-[20px] font-bold leading-[28px] text-slate-900">{{ product.name }}</text>
<view class="mt-[10px] flex flex-wrap gap-[6px]">
<text v-for="highlight in product.highlights || []" :key="highlight" class="rounded-[5px] bg-violet-50 px-[7px] py-[3px] text-[11px] text-violet-700">{{ highlight }}</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="mt-[14px] flex items-end justify-between">
<text class="text-[12px] text-slate-400">已售 {{ product.sold || 0 }}</text>
<view class="flex items-end">
<text v-if="product.marketPrice" class="mr-[8px] text-[12px] text-slate-400 line-through">¥{{ product.marketPrice }}</text>
<text class="text-[14px] font-medium text-[#f26b32]">¥<text class="text-[28px] font-bold">{{ product.salePrice }}</text></text>
<view class="bg-white px-4 pb-4 pt-4">
<view class="flex flex-wrap gap-1.5">
<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>
<view class="mt-3 flex items-end gap-1 text-orange-600">
<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">¥</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>
@@ -43,13 +55,23 @@
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.detail || product.description || '暂无商品详情' }}</text>
</view>
<view class="mx-[12px] mb-[20px] mt-[12px] rounded-[16px] bg-white p-[16px]">
<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>
<view class="shrink-0 border-t border-slate-100 bg-white px-[16px] pb-[calc(env(safe-area-inset-bottom)+10px)] pt-[10px]">
<view class="shrink-0 border-t border-slate-100 bg-white px-[16px] pb-[calc(env(safe-area-inset-bottom)+2px)] pt-[10px]">
<view class="flex items-center justify-between">
<view>
<text class="text-[12px] text-slate-500">优惠价</text>
@@ -64,11 +86,19 @@
</template>
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue"
import { computed } from "vue";
defineProps({
const props = defineProps({
product: { type: Object, required: true },
})
});
const emit = defineEmits(['back', 'buy'])
const emit = defineEmits(["buy"]);
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

@@ -80,6 +80,8 @@ const createProduct = ({
excluded,
detail,
purchaseNotice,
useRule = '请按预约日期和场次使用;实名商品需携带下单时填写的有效证件,具体核验方式以出票凭证及现场指引为准。',
refundRule = '未使用且符合商品退改规则时可申请退款;已使用、已过有效期或超过退改时间的权益不可退款,审核结果以订单页面为准。',
reservation,
entitlements,
status = 'on_sale',
@@ -102,6 +104,8 @@ const createProduct = ({
excluded: [...excluded],
detail,
purchaseNotice,
useRule,
refundRule,
images: [PLACEHOLDER_IMAGE],
reservation: createReservation(reservation),
entitlements: entitlements.map((entitlement) => ({ ...entitlement })),
@@ -142,7 +146,7 @@ export const PRODUCTS = [
}),
createProduct({
id: 'classic', name: '小七孔经典畅游套餐', shortTitle: '门票 + 观光车 + 鸳鸯湖游船(成人套餐)', category: '组合套餐', description: '门票 + 观光车 + 鸳鸯湖游船,一次购买,两类凭证独立履约。', salePrice: 188, marketPrice: 210, stock: 480, sold: 184,
highlights: ['官方', '实名预约', '整包退', '双凭证'], included: ['成人门票 1 张、观光车票 1 张、鸳鸯湖游船 1 次。'], excluded: ['不含景区保险、餐饮及套餐外个人消费。'], detail: '一个订单包含两类凭证:第三方票务凭证包含景区门票和观光车票;自营权益码包含鸳鸯湖游船,由工作人员扫码核销。', purchaseNotice: '套餐统一选择使用日期和入园时段。全部权益未使用可整单全额退;任一权益使用后整单不可退,不支持拆项退款。',
highlights: ['官方', '实名预约', '整包退', '双凭证'], included: ['成人门票 1 张、观光车票 1 张、鸳鸯湖游船 1 次。'], excluded: ['不含景区保险、餐饮及套餐外个人消费。'], detail: '一个订单包含两类凭证:第三方票务凭证包含景区门票和观光车票;自营权益码包含鸳鸯湖游船,由工作人员扫码核销。', purchaseNotice: '套餐统一选择使用日期和入园时段。全部权益未使用可整单全额退;任一权益使用后整单不可退,不支持拆项退款。', refundRule: '套餐内全部权益均未使用时支持整单全额退款;任一权益使用后整单不可退款,不支持拆项退款。',
reservation: { advanceDays: 30 }, entitlements: [ticketEntitlement('classic-ticket', '小七孔成人门票', 'third_party', 120), ticketEntitlement('classic-shuttle', '观光车票', 'third_party', 40), ticketEntitlement('classic-boat', '鸳鸯湖游船', 'self', 28, '次')],
}),
createProduct({

View File

@@ -1,7 +1,6 @@
<template>
<view class="h-screen min-w-0 overflow-hidden bg-[#f5f7fa] text-[#172033]">
<TicketHome
v-if="!detailProduct"
:scenic-spot="SCENIC_SPOT"
:groups="visibleGroups"
:products="ticketStore.products"
@@ -11,42 +10,31 @@
@open-detail="openProductDetail"
@buy="startBooking"
/>
<ProductDetail
v-else
:product="detailProduct"
@back="detailProductId = ''"
@buy="startBooking"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { computed } from "vue";
import TicketHome from "./components/TicketHome.vue";
import ProductDetail from "./components/ProductDetail.vue";
import { GROUPS, SCENIC_SPOT } from "./data.js";
import { useTicketStore } from "./store.js";
const ticketStore = useTicketStore();
const detailProductId = ref("");
const visibleGroups = computed(() =>
[...GROUPS]
.filter((group) => group.enabled)
.sort((left, right) => left.sortOrder - right.sortOrder)
);
const detailProduct = computed(() =>
ticketStore.products.find((product) => product.id === detailProductId.value)
);
const goBack = () => uni.navigateBack();
const openTravelers = () =>
uni.navigateTo({ url: "/pages-service/tickets/travelers" });
const openOrders = () =>
uni.navigateTo({ url: "/pages-service/tickets/orders" });
const openProductDetail = (productId) => {
detailProductId.value = productId;
};
const openProductDetail = (productId) =>
uni.navigateTo({
url: `/pages-service/tickets/product-detail?productId=${productId}`,
});
const startBooking = (productId) =>
uni.navigateTo({
url: `/pages-service/tickets/booking?productId=${productId}`,

View File

@@ -0,0 +1,40 @@
<template>
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-slate-100">
<TopNavBar
title="商品详情"
subtitle="费用说明与购买须知"
title-align="left"
background="#ffffff"
/>
<ProductDetail
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-sm text-slate-400">商品不存在或已下架</view>
</view>
</template>
<script setup>
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 { useTicketStore } from "./store.js";
const ticketStore = useTicketStore();
const productId = ref("");
const product = computed(() =>
ticketStore.products.find((item) => item.id === productId.value)
);
onLoad((options) => {
productId.value = options?.productId || "";
});
const startBooking = (selectedProductId) =>
uni.navigateTo({
url: `/pages-service/tickets/booking?productId=${selectedProductId}`,
});
</script>

View File

@@ -6,6 +6,7 @@ const ticketRoot = new URL("./", import.meta.url);
const readTicketFile = (path) => readFile(new URL(path, ticketRoot), "utf8");
const pageNames = [
"product-detail",
"booking",
"travelers",
"orders",
@@ -62,6 +63,31 @@ test("storefront synchronizes the left category from right-column scrolling", as
assert.match(source, /TopNavBar/);
});
test("product detail is an independent prototype-aligned 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\?productId=\$\{productId\}`/);
assert.doesNotMatch(indexPage, /detailProduct|<ProductDetail/);
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, /relative h-56 overflow-hidden bg-white/);
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/);
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 () => {
const component = await readTicketFile("components/BookingFlow.vue");
const page = await readTicketFile("booking.vue");

View File

@@ -149,6 +149,12 @@
"navigationStyle": "custom"
}
},
{
"path": "tickets/product-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "tickets/booking",
"style": {