feat: 拆分了商品详情页
This commit is contained in:
@@ -1,28 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="h-screen overflow-hidden bg-slate-50 flex flex-col">
|
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100">
|
||||||
<TopNavBar
|
|
||||||
title="商品详情"
|
|
||||||
subtitle="费用说明与购买须知"
|
|
||||||
title-align="left"
|
|
||||||
background="#ffffff"
|
|
||||||
@back="emit('back')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<scroll-view class="min-h-0 flex-1" scroll-y>
|
<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]">
|
<view v-if="productImages.length > 1" class="flex gap-2 overflow-hidden bg-white px-4 pb-3 pt-2">
|
||||||
<text v-if="product.shortTitle" class="block text-[13px] text-slate-500">{{ product.shortTitle }}</text>
|
<image
|
||||||
<text class="mt-[4px] block text-[20px] font-bold leading-[28px] text-slate-900">{{ product.name }}</text>
|
v-for="(image, index) in productImages.slice(0, 4)"
|
||||||
<view class="mt-[10px] flex flex-wrap gap-[6px]">
|
:key="`${image}-${index}`"
|
||||||
<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>
|
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>
|
||||||
<view class="mt-[14px] flex items-end justify-between">
|
<text class="mt-2 block text-lg font-bold leading-7 text-slate-950">{{ product.name }}</text>
|
||||||
<text class="text-[12px] text-slate-400">已售 {{ product.sold || 0 }}</text>
|
<text class="mt-1 block text-xs leading-5 text-slate-500">{{ product.description || product.shortTitle || '暂无商品简介' }}</text>
|
||||||
<view class="flex items-end">
|
<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 v-if="product.marketPrice" class="mr-[8px] text-[12px] text-slate-400 line-through">¥{{ product.marketPrice }}</text>
|
<text class="font-bold">年龄限制:{{ ageRuleText }}</text>
|
||||||
<text class="text-[14px] font-medium text-[#f26b32]">¥<text class="text-[28px] font-bold">{{ product.salePrice }}</text></text>
|
<text class="block">下单时仅对门票游客按使用日和身份证校验。</text>
|
||||||
</view>
|
</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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -43,13 +55,23 @@
|
|||||||
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.detail || product.description || '暂无商品详情' }}</text>
|
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.detail || product.description || '暂无商品详情' }}</text>
|
||||||
</view>
|
</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="block text-[16px] font-semibold text-slate-800">购买须知</text>
|
||||||
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.purchaseNotice || '请以实际预约规则为准。' }}</text>
|
<text class="mt-[10px] block text-[14px] leading-[22px] text-slate-600">{{ product.purchaseNotice || '请以实际预约规则为准。' }}</text>
|
||||||
</view>
|
</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>
|
</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 class="flex items-center justify-between">
|
||||||
<view>
|
<view>
|
||||||
<text class="text-[12px] text-slate-500">优惠价</text>
|
<text class="text-[12px] text-slate-500">优惠价</text>
|
||||||
@@ -64,11 +86,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import TopNavBar from "@/components/TopNavBar/index.vue"
|
import { computed } from "vue";
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
product: { type: Object, required: true },
|
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>
|
</script>
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ const createProduct = ({
|
|||||||
excluded,
|
excluded,
|
||||||
detail,
|
detail,
|
||||||
purchaseNotice,
|
purchaseNotice,
|
||||||
|
useRule = '请按预约日期和场次使用;实名商品需携带下单时填写的有效证件,具体核验方式以出票凭证及现场指引为准。',
|
||||||
|
refundRule = '未使用且符合商品退改规则时可申请退款;已使用、已过有效期或超过退改时间的权益不可退款,审核结果以订单页面为准。',
|
||||||
reservation,
|
reservation,
|
||||||
entitlements,
|
entitlements,
|
||||||
status = 'on_sale',
|
status = 'on_sale',
|
||||||
@@ -102,6 +104,8 @@ const createProduct = ({
|
|||||||
excluded: [...excluded],
|
excluded: [...excluded],
|
||||||
detail,
|
detail,
|
||||||
purchaseNotice,
|
purchaseNotice,
|
||||||
|
useRule,
|
||||||
|
refundRule,
|
||||||
images: [PLACEHOLDER_IMAGE],
|
images: [PLACEHOLDER_IMAGE],
|
||||||
reservation: createReservation(reservation),
|
reservation: createReservation(reservation),
|
||||||
entitlements: entitlements.map((entitlement) => ({ ...entitlement })),
|
entitlements: entitlements.map((entitlement) => ({ ...entitlement })),
|
||||||
@@ -142,7 +146,7 @@ export const PRODUCTS = [
|
|||||||
}),
|
}),
|
||||||
createProduct({
|
createProduct({
|
||||||
id: 'classic', name: '小七孔经典畅游套餐', shortTitle: '门票 + 观光车 + 鸳鸯湖游船(成人套餐)', category: '组合套餐', description: '门票 + 观光车 + 鸳鸯湖游船,一次购买,两类凭证独立履约。', salePrice: 188, marketPrice: 210, stock: 480, sold: 184,
|
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, '次')],
|
reservation: { advanceDays: 30 }, entitlements: [ticketEntitlement('classic-ticket', '小七孔成人门票', 'third_party', 120), ticketEntitlement('classic-shuttle', '观光车票', 'third_party', 40), ticketEntitlement('classic-boat', '鸳鸯湖游船', 'self', 28, '次')],
|
||||||
}),
|
}),
|
||||||
createProduct({
|
createProduct({
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="h-screen min-w-0 overflow-hidden bg-[#f5f7fa] text-[#172033]">
|
<view class="h-screen min-w-0 overflow-hidden bg-[#f5f7fa] text-[#172033]">
|
||||||
<TicketHome
|
<TicketHome
|
||||||
v-if="!detailProduct"
|
|
||||||
:scenic-spot="SCENIC_SPOT"
|
:scenic-spot="SCENIC_SPOT"
|
||||||
:groups="visibleGroups"
|
:groups="visibleGroups"
|
||||||
:products="ticketStore.products"
|
:products="ticketStore.products"
|
||||||
@@ -11,42 +10,31 @@
|
|||||||
@open-detail="openProductDetail"
|
@open-detail="openProductDetail"
|
||||||
@buy="startBooking"
|
@buy="startBooking"
|
||||||
/>
|
/>
|
||||||
<ProductDetail
|
|
||||||
v-else
|
|
||||||
:product="detailProduct"
|
|
||||||
@back="detailProductId = ''"
|
|
||||||
@buy="startBooking"
|
|
||||||
/>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from "vue";
|
import { computed } from "vue";
|
||||||
import TicketHome from "./components/TicketHome.vue";
|
import TicketHome from "./components/TicketHome.vue";
|
||||||
import ProductDetail from "./components/ProductDetail.vue";
|
|
||||||
import { GROUPS, SCENIC_SPOT } from "./data.js";
|
import { GROUPS, SCENIC_SPOT } from "./data.js";
|
||||||
import { useTicketStore } from "./store.js";
|
import { useTicketStore } from "./store.js";
|
||||||
|
|
||||||
const ticketStore = useTicketStore();
|
const ticketStore = useTicketStore();
|
||||||
const detailProductId = ref("");
|
|
||||||
|
|
||||||
const visibleGroups = computed(() =>
|
const visibleGroups = computed(() =>
|
||||||
[...GROUPS]
|
[...GROUPS]
|
||||||
.filter((group) => group.enabled)
|
.filter((group) => group.enabled)
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||||
);
|
);
|
||||||
const detailProduct = computed(() =>
|
|
||||||
ticketStore.products.find((product) => product.id === detailProductId.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const goBack = () => uni.navigateBack();
|
const goBack = () => uni.navigateBack();
|
||||||
const openTravelers = () =>
|
const openTravelers = () =>
|
||||||
uni.navigateTo({ url: "/pages-service/tickets/travelers" });
|
uni.navigateTo({ url: "/pages-service/tickets/travelers" });
|
||||||
const openOrders = () =>
|
const openOrders = () =>
|
||||||
uni.navigateTo({ url: "/pages-service/tickets/orders" });
|
uni.navigateTo({ url: "/pages-service/tickets/orders" });
|
||||||
const openProductDetail = (productId) => {
|
const openProductDetail = (productId) =>
|
||||||
detailProductId.value = productId;
|
uni.navigateTo({
|
||||||
};
|
url: `/pages-service/tickets/product-detail?productId=${productId}`,
|
||||||
|
});
|
||||||
const startBooking = (productId) =>
|
const startBooking = (productId) =>
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages-service/tickets/booking?productId=${productId}`,
|
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 readTicketFile = (path) => readFile(new URL(path, ticketRoot), "utf8");
|
||||||
|
|
||||||
const pageNames = [
|
const pageNames = [
|
||||||
|
"product-detail",
|
||||||
"booking",
|
"booking",
|
||||||
"travelers",
|
"travelers",
|
||||||
"orders",
|
"orders",
|
||||||
@@ -62,6 +63,31 @@ test("storefront synchronizes the left category from right-column scrolling", as
|
|||||||
assert.match(source, /TopNavBar/);
|
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 () => {
|
test("booking flow matches the visitor prototype while preserving requested controls", async () => {
|
||||||
const component = await readTicketFile("components/BookingFlow.vue");
|
const component = await readTicketFile("components/BookingFlow.vue");
|
||||||
const page = await readTicketFile("booking.vue");
|
const page = await readTicketFile("booking.vue");
|
||||||
|
|||||||
@@ -149,6 +149,12 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "tickets/product-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "tickets/booking",
|
"path": "tickets/booking",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
Reference in New Issue
Block a user