feat: 商品详情接口对接
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100">
|
<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>
|
<scroll-view class="min-h-0 flex-1" scroll-y>
|
||||||
<view class="relative h-56 overflow-hidden bg-white">
|
<view v-if="productImages.length" class="relative h-56 overflow-hidden bg-white">
|
||||||
<image class="h-full w-full bg-slate-200" :src="productImages[0]" mode="aspectFill" />
|
<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 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>
|
||||||
|
<view v-else class="flex h-56 items-center justify-center bg-slate-200">
|
||||||
|
<text class="text-[12px] text-slate-400">暂无商品图片</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="productImages.length > 1" class="flex gap-2 overflow-hidden bg-white px-4 pb-3 pt-2">
|
<view v-if="productImages.length > 1" class="flex gap-2 overflow-hidden bg-white px-4 pb-3 pt-2">
|
||||||
<image
|
<image
|
||||||
@@ -19,55 +22,29 @@
|
|||||||
|
|
||||||
<view class="bg-white px-4 pb-4 pt-4">
|
<view class="bg-white px-4 pb-4 pt-4">
|
||||||
<view class="flex flex-wrap gap-1.5">
|
<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.fulfillmentLabel" class="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600">{{ product.fulfillmentLabel }}</text>
|
||||||
<text v-if="!(product.highlights || []).length" class="text-[10px] text-slate-400">尚未配置销售标签</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>
|
||||||
</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>
|
||||||
|
<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">
|
<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>
|
<text v-if="Number(product.startingPrice) === 0" class="text-2xl font-bold leading-none">免费预约</text>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<text class="pb-0.5 text-xs">¥</text>
|
<text class="pb-0.5 text-xs">{{ currencySymbol }}</text>
|
||||||
<text class="text-3xl font-bold leading-none">{{ product.salePrice }}</text>
|
<text class="text-3xl font-bold leading-none">{{ product.startingPrice }}</text>
|
||||||
<text class="pb-0.5 text-[10px] text-slate-400"></text>
|
<text class="pb-0.5 text-[10px] text-slate-400">起</text>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
<view
|
||||||
<text class="block text-[16px] font-semibold text-slate-800">费用包含</text>
|
v-for="section in detailSections"
|
||||||
<text v-for="item in product.included || []" :key="item" class="mt-[10px] block text-[14px] leading-[20px] text-slate-600">· {{ item }}</text>
|
:key="section.sectionCode"
|
||||||
<text v-if="!(product.included || []).length" class="mt-[10px] block text-[14px] text-slate-400">以商品说明为准</text>
|
class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]"
|
||||||
</view>
|
:class="section === detailSections[detailSections.length - 1] ? 'mb-[20px]' : ''"
|
||||||
|
>
|
||||||
<view class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
<text class="block text-[16px] font-semibold text-slate-800">{{ section.title }}</text>
|
||||||
<text class="block text-[16px] font-semibold text-slate-800">费用不含</text>
|
<text class="mt-[10px] block whitespace-pre-wrap text-[14px] leading-[22px] text-slate-600">{{ section.content }}</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>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
@@ -75,10 +52,10 @@
|
|||||||
<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>
|
||||||
<text class="ml-[4px] text-[14px] font-medium text-[#f26b32]">¥<text class="text-[25px] font-bold">{{ product.salePrice }}</text>起</text>
|
<text class="ml-[4px] text-[14px] font-medium text-[#f26b32]">{{ currencySymbol }}<text class="text-[25px] font-bold">{{ product.startingPrice }}</text>起</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="rounded-full bg-[#f26b32] px-[28px] py-[11px]" @click="emit('buy', product.id)">
|
<view class="rounded-full bg-[#f26b32] px-[28px] py-[11px]" @click="emit('buy', product.commodityId)">
|
||||||
<text class="text-[15px] font-semibold text-white">去预订</text>
|
<text class="text-[15px] font-semibold text-white">{{ product.bookingButtonText || '去预订' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -94,11 +71,14 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(["buy"]);
|
const emit = defineEmits(["buy"]);
|
||||||
const productImages = computed(() =>
|
const productImages = computed(() =>
|
||||||
(props.product.images || []).filter(Boolean)
|
[props.product.coverImage, ...(props.product.imageList || [])].filter(Boolean)
|
||||||
|
);
|
||||||
|
const detailSections = computed(() =>
|
||||||
|
[...(props.product.detailSections || [])].sort(
|
||||||
|
(left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const currencySymbol = computed(() =>
|
||||||
|
props.product.currency === "CNY" ? "¥" : props.product.currency || ""
|
||||||
);
|
);
|
||||||
const ageRuleText = computed(() => {
|
|
||||||
const { min, max } = props.product.ageRule || {};
|
|
||||||
if (Number(max) >= 999) return `${min}周岁及以上`;
|
|
||||||
return `${min}—${max}周岁`;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ 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 = (commodityId) =>
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages-service/tickets/product-detail?productId=${productId}`,
|
url: `/pages-service/tickets/product-detail?commodityId=${commodityId}`,
|
||||||
});
|
});
|
||||||
const startBooking = (productId) =>
|
const startBooking = (productId) =>
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
|
|||||||
@@ -6,31 +6,83 @@
|
|||||||
title-align="left"
|
title-align="left"
|
||||||
background="#ffffff"
|
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
|
<ProductDetail
|
||||||
v-if="product"
|
v-else-if="product"
|
||||||
class="min-h-0 flex-1"
|
class="min-h-0 flex-1"
|
||||||
:product="product"
|
:product="product"
|
||||||
@buy="startBooking"
|
@buy="startBooking"
|
||||||
/>
|
/>
|
||||||
<view v-else class="flex min-h-0 flex-1 items-center justify-center text-sm text-slate-400">商品不存在或已下架</view>
|
<view v-else class="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<text class="text-[14px] text-slate-400">商品不存在或已下架</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||||
import ProductDetail from "./components/ProductDetail.vue";
|
import ProductDetail from "./components/ProductDetail.vue";
|
||||||
import { useTicketStore } from "./store.js";
|
import { scenicProductDetail } from "./api/scenic.js";
|
||||||
|
|
||||||
const ticketStore = useTicketStore();
|
const commodityId = ref("");
|
||||||
const productId = ref("");
|
const product = ref(null);
|
||||||
const product = computed(() =>
|
const loading = ref(false);
|
||||||
ticketStore.products.find((item) => item.id === productId.value)
|
const errorMessage = ref("");
|
||||||
);
|
|
||||||
|
|
||||||
onLoad((options) => {
|
const loadProductDetail = async () => {
|
||||||
productId.value = options?.productId || "";
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
const startBooking = (selectedProductId) =>
|
const startBooking = (selectedProductId) =>
|
||||||
|
|||||||
@@ -180,25 +180,54 @@ test("product detail is an independent prototype-aligned page", async () => {
|
|||||||
const indexPage = await readTicketFile("index.vue");
|
const indexPage = await readTicketFile("index.vue");
|
||||||
const detailPage = await readTicketFile("product-detail.vue");
|
const detailPage = await readTicketFile("product-detail.vue");
|
||||||
const component = await readTicketFile("components/ProductDetail.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.match(indexPage, /uni\.navigateTo\(\{\s*url: `\/pages-service\/tickets\/product-detail\?commodityId=\$\{commodityId\}`/);
|
||||||
assert.doesNotMatch(indexPage, /detailProduct|<ProductDetail/);
|
assert.doesNotMatch(indexPage, /detailProduct|<ProductDetail/);
|
||||||
assert.match(detailPage, /onLoad\(\(options\)/);
|
assert.match(detailPage, /onLoad\(async \(options\)/);
|
||||||
assert.match(detailPage, /options\?\.productId/);
|
assert.match(detailPage, /options\?\.commodityId/);
|
||||||
assert.match(detailPage, /<ProductDetail/);
|
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, /\/pages-service\/tickets\/booking\?productId=/);
|
assert.match(detailPage, /\/pages-service\/tickets\/booking\?productId=/);
|
||||||
assert.match(component, /relative h-56 overflow-hidden bg-white/);
|
assert.match(component, /relative h-56 overflow-hidden bg-white/);
|
||||||
assert.match(component, /1 \/ \{\{ productImages\.length \}\}/);
|
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.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/);
|
for (const field of [
|
||||||
assert.match(component, /product\.description/);
|
"product.coverImage",
|
||||||
assert.match(component, /使用说明/);
|
"product.imageList",
|
||||||
assert.match(component, /product\.useRule/);
|
"product.tags",
|
||||||
assert.match(component, /退款说明/);
|
"product.fulfillmentLabel",
|
||||||
assert.match(component, /product\.refundRule/);
|
"product.commodityName",
|
||||||
assert.match(data, /useRule/);
|
"product.subtitle",
|
||||||
assert.match(data, /refundRule/);
|
"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 in detailSections"/);
|
||||||
|
assert.match(component, /section\.sectionCode/);
|
||||||
|
assert.match(component, /section\.title/);
|
||||||
|
assert.match(component, /section\.content/);
|
||||||
|
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("booking flow matches the visitor prototype while preserving requested controls", async () => {
|
test("booking flow matches the visitor prototype while preserving requested controls", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user