feat: 拆分了商品详情页
This commit is contained in:
@@ -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="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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
40
src/pages-service/tickets/product-detail.vue
Normal file
40
src/pages-service/tickets/product-detail.vue
Normal 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>
|
||||
@@ -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");
|
||||
|
||||
@@ -149,6 +149,12 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "tickets/product-detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "tickets/booking",
|
||||
"style": {
|
||||
|
||||
Reference in New Issue
Block a user