Compare commits
5 Commits
home3.0
...
cheagehome
| Author | SHA1 | Date | |
|---|---|---|---|
| ebad453023 | |||
| d567d1a2c4 | |||
| 0461ba2ea9 | |||
| ec20e11c32 | |||
| 21659f61e3 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"appid": "wx5e79df5996572539",
|
"appid": "wx8531ca7339685eca",
|
||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"libVersion": "3.8.10",
|
"libVersion": "3.8.10",
|
||||||
"packOptions": {
|
"packOptions": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import rawConfigs from "../../client-configs.json" with { type: "json" };
|
|||||||
export const CLIENT_CONFIGS = rawConfigs;
|
export const CLIENT_CONFIGS = rawConfigs;
|
||||||
|
|
||||||
// 获取当前用户端配置
|
// 获取当前用户端配置
|
||||||
export const getCurrentConfig = () => CLIENT_CONFIGS.zhinian;
|
export const getCurrentConfig = () => CLIENT_CONFIGS.xiaoqi;
|
||||||
export const clientId = getCurrentConfig().clientId;
|
export const clientId = getCurrentConfig().clientId;
|
||||||
export const appId = getCurrentConfig().appId;
|
export const appId = getCurrentConfig().appId;
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
"quickapp" : {},
|
"quickapp" : {},
|
||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "wx5e79df5996572539",
|
"appid" : "wx8531ca7339685eca",
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false,
|
"urlCheck" : false,
|
||||||
"minified" : true
|
"minified" : true
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="mt-auto flex items-end justify-between pt-[6px]">
|
<view class="mt-auto flex items-end justify-between pt-[6px]">
|
||||||
<view>
|
<view>
|
||||||
<text class="block text-[10px] text-[#9aa2ad]">已售 {{ product.sold || 0 }}</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>
|
<text class="text-[12px] font-medium text-[#f26b32]">¥<text class="text-[20px] font-bold">{{ product.salePrice }}</text></text>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex h-[28px] items-center justify-center rounded-full bg-[#f26b32] px-[13px]" @click="emit('buy', product.id)">
|
<view class="flex h-[28px] items-center justify-center rounded-full bg-[#f26b32] px-[13px]" @click="emit('buy', product.id)">
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="mt-[10px] rounded-[14px] bg-white px-[16px] py-[28px] text-center">
|
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -124,6 +124,7 @@ const props = defineProps({
|
|||||||
scenicSpot: { type: Object, required: true },
|
scenicSpot: { type: Object, required: true },
|
||||||
groups: { type: Array, default: () => [] },
|
groups: { type: Array, default: () => [] },
|
||||||
products: { type: Array, default: () => [] },
|
products: { type: Array, default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
|
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
|
||||||
@@ -134,6 +135,9 @@ const groupOffsets = ref([])
|
|||||||
const rightScrollTop = ref(0)
|
const rightScrollTop = ref(0)
|
||||||
const instance = getCurrentInstance()
|
const instance = getCurrentInstance()
|
||||||
const productsById = computed(() => new Map(props.products.map((product) => [product.id, product])))
|
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('|'))
|
||||||
|
|
||||||
const productsForGroup = (group) => (group.productIds || [])
|
const productsForGroup = (group) => (group.productIds || [])
|
||||||
.map((productId) => productsById.value.get(productId))
|
.map((productId) => productsById.value.get(productId))
|
||||||
@@ -196,7 +200,10 @@ const handleProductScroll = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(measureGroupOffsets)
|
onMounted(measureGroupOffsets)
|
||||||
watch(() => props.groups.map((group) => group.id).join(','), measureGroupOffsets)
|
watch(groupLayoutSignature, () => {
|
||||||
|
groupOffsets.value = []
|
||||||
|
measureGroupOffsets()
|
||||||
|
}, { flush: 'post' })
|
||||||
|
|
||||||
const makePhoneCall = () => {
|
const makePhoneCall = () => {
|
||||||
if (!props.scenicSpot.phone) return
|
if (!props.scenicSpot.phone) return
|
||||||
|
|||||||
@@ -49,12 +49,9 @@ export const SCENIC_SPOT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GROUPS = [
|
export const GROUPS = [
|
||||||
{ id: 'ticket', name: '景点门票', subtitle: '累计销量 5000+', description: '一票通玩,核销后门票7日内有效', tag: '官方', productIds: ['adult', 'elderly', 'minor', 'free'], sortOrder: 1, enabled: true },
|
{ id: 'ticket', name: '门票', commodityTypeCode: '1', productIds: [], sortOrder: 1, enabled: true },
|
||||||
{ id: 'vip', name: 'VIP速通', subtitle: '专属服务', description: '减少排队等待时间,按预约场次使用', tag: '推荐', productIds: ['vip-fast'], sortOrder: 2, enabled: true },
|
{ id: 'maolan', name: '茂兰景区', commodityTypeCode: '67d07734e0af4caa918309f5a346a86e', productIds: [], sortOrder: 2, enabled: true },
|
||||||
{ id: 'bus', name: '直通车', subtitle: '往返直达', description: '贵阳市区集合,直达小七孔景区', tag: '省心', productIds: ['direct-bus'], sortOrder: 3, enabled: true },
|
{ id: 'heritage-tribe', name: '世遗部落', commodityTypeCode: 'd5766c63028e46f1bd9aaa25d1dc7482', productIds: [], 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 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const TIME_SLOTS = ['08:00—09:30', '09:30—11:30', '11:30—13:30', '13:30—15:00', '15:00—16:00']
|
const TIME_SLOTS = ['08:00—09:30', '09:30—11:30', '11:30—13:30', '13:30—15:00', '15:00—16:00']
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<TicketHome
|
<TicketHome
|
||||||
:scenic-spot="SCENIC_SPOT"
|
:scenic-spot="SCENIC_SPOT"
|
||||||
:groups="visibleGroups"
|
:groups="visibleGroups"
|
||||||
:products="ticketStore.products"
|
:products="products"
|
||||||
|
:loading="loading"
|
||||||
@back="goBack"
|
@back="goBack"
|
||||||
@open-travelers="openTravelers"
|
@open-travelers="openTravelers"
|
||||||
@open-orders="openOrders"
|
@open-orders="openOrders"
|
||||||
@@ -14,18 +15,86 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { quickBookingList } from "@/request/api/GoodsApi";
|
||||||
import TicketHome from "./components/TicketHome.vue";
|
import TicketHome from "./components/TicketHome.vue";
|
||||||
import { GROUPS, SCENIC_SPOT } from "./data.js";
|
import { GROUPS, PLACEHOLDER_IMAGE, SCENIC_SPOT } from "./data.js";
|
||||||
import { useTicketStore } from "./store.js";
|
|
||||||
|
|
||||||
const ticketStore = useTicketStore();
|
const groups = ref(GROUPS.map((group) => ({ ...group, productIds: [] })));
|
||||||
|
const products = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
const visibleGroups = computed(() =>
|
const visibleGroups = computed(() =>
|
||||||
[...GROUPS]
|
[...groups.value]
|
||||||
.filter((group) => group.enabled)
|
.filter((group) => group.enabled)
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
.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;
|
||||||
|
|
||||||
|
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 (!Array.isArray(res?.data?.records)) {
|
||||||
|
console.error(`查询${group.name}商品失败:`, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(loadQuickBookingProducts);
|
||||||
|
|
||||||
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" });
|
||||||
@@ -33,10 +102,10 @@ const openOrders = () =>
|
|||||||
uni.navigateTo({ url: "/pages-service/tickets/orders" });
|
uni.navigateTo({ url: "/pages-service/tickets/orders" });
|
||||||
const openProductDetail = (productId) =>
|
const openProductDetail = (productId) =>
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages-service/tickets/product-detail?productId=${productId}`,
|
url: `/pages/goods/index?commodityId=${encodeURIComponent(productId)}`,
|
||||||
});
|
});
|
||||||
const startBooking = (productId) =>
|
const startBooking = (productId) =>
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages-service/tickets/booking?productId=${productId}`,
|
url: `/pages/goods/index?commodityId=${encodeURIComponent(productId)}`,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,18 +23,13 @@ test('provides coherent default visitor ticket data', async () => {
|
|||||||
assert.deepEqual(SCENIC_SPOT, {
|
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,
|
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 })), [
|
assert.deepEqual(GROUPS.map(({ id, name, commodityTypeCode, sortOrder, enabled, productIds }) => ({ id, name, commodityTypeCode, sortOrder, enabled, productIds })), [
|
||||||
{ id: 'ticket', name: '景点门票', sortOrder: 1, enabled: true, productIds: ['adult', 'elderly', 'minor', 'free'] },
|
{ id: 'ticket', name: '门票', commodityTypeCode: '1', sortOrder: 1, enabled: true, productIds: [] },
|
||||||
{ id: 'vip', name: 'VIP速通', sortOrder: 2, enabled: true, productIds: ['vip-fast'] },
|
{ id: 'maolan', name: '茂兰景区', commodityTypeCode: '67d07734e0af4caa918309f5a346a86e', sortOrder: 2, enabled: true, productIds: [] },
|
||||||
{ id: 'bus', name: '直通车', sortOrder: 3, enabled: true, productIds: ['direct-bus'] },
|
{ id: 'heritage-tribe', name: '世遗部落', commodityTypeCode: 'd5766c63028e46f1bd9aaa25d1dc7482', sortOrder: 3, enabled: true, productIds: [] },
|
||||||
{ 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(PRODUCTS.map(({ id }) => id), ['adult', 'elderly', 'minor', 'free', 'vip-fast', 'direct-bus', 'classic', 'day-tour', 'boat', 'guide'])
|
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]))
|
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 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 reservationKeys = ['requiresDate', 'advanceDays', 'requiresTimeSlot', 'timeSlots', 'realNameRequired', 'idTypes', 'maxQuantity', 'contactPhoneRequired']
|
||||||
const entitlementKeys = ['id', 'name', 'fulfillment', 'quantity', 'unit', 'refundAllocation']
|
const entitlementKeys = ['id', 'name', 'fulfillment', 'quantity', 'unit', 'refundAllocation']
|
||||||
|
|||||||
@@ -60,16 +60,23 @@ test("storefront synchronizes the left category from right-column scrolling", as
|
|||||||
assert.match(source, /groupOffsets/);
|
assert.match(source, /groupOffsets/);
|
||||||
assert.match(source, /scrollTop/);
|
assert.match(source, /scrollTop/);
|
||||||
assert.match(source, /leftScrollIntoViewId/);
|
assert.match(source, /leftScrollIntoViewId/);
|
||||||
|
assert.match(source, /groupLayoutSignature/);
|
||||||
|
assert.match(source, /group\.productIds/);
|
||||||
|
assert.match(source, /groupOffsets\.value = \[\]/);
|
||||||
assert.match(source, /TopNavBar/);
|
assert.match(source, /TopNavBar/);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 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");
|
const data = await readTicketFile("data.js");
|
||||||
|
|
||||||
assert.match(indexPage, /uni\.navigateTo\(\{\s*url: `\/pages-service\/tickets\/product-detail\?productId=\$\{productId\}`/);
|
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.doesNotMatch(indexPage, /detailProduct|<ProductDetail/);
|
||||||
assert.match(detailPage, /onLoad\(\(options\)/);
|
assert.match(detailPage, /onLoad\(\(options\)/);
|
||||||
assert.match(detailPage, /options\?\.productId/);
|
assert.match(detailPage, /options\?\.productId/);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 标题区域 -->
|
<!-- 标题区域 -->
|
||||||
<view class="title-section">
|
<view class="title-section">
|
||||||
<text class="title">
|
<text class="title">
|
||||||
{{ goodsData.commodityName || "【成人票】云从朵花温泉门票" }}
|
{{ goodsData.commodityName }}
|
||||||
</text>
|
</text>
|
||||||
<view class="tag-wrapper" v-if="goodsData.timeTag">
|
<view class="tag-wrapper" v-if="goodsData.timeTag">
|
||||||
<view class="time-tag">{{ goodsData.timeTag || "随时可退" }}</view>
|
<view class="time-tag">{{ goodsData.timeTag || "随时可退" }}</view>
|
||||||
|
|||||||
64
src/pages/goods/components/GoodsDetailSkeleton/index.vue
Normal file
64
src/pages/goods/components/GoodsDetailSkeleton/index.vue
Normal 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>
|
||||||
@@ -1,58 +1,73 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="goods-container bg-[#f5f5f5]">
|
<view class="goods-container bg-[#f5f5f5]">
|
||||||
<TopNavBar :title="navOpacity < 0.5 ? '' : '商品详情'" :background="`rgba(217, 238, 255, ${navOpacity})`"
|
<TopNavBar :title="solidNavBar || navOpacity >= 0.5 ? '商品详情' : ''"
|
||||||
:titleColor="navOpacity < 0.5 ? '#ffffff' : '#000000'"
|
:background="solidNavBar ? '#ffffff' : `rgba(217, 238, 255, ${navOpacity})`"
|
||||||
:backIconColor="navOpacity < 0.5 ? '#ffffff' : '#000000'" />
|
:titleColor="solidNavBar || navOpacity >= 0.5 ? '#000000' : '#ffffff'"
|
||||||
|
:backIconColor="solidNavBar || navOpacity >= 0.5 ? '#000000' : '#ffffff'" />
|
||||||
|
|
||||||
<!-- 滚动区域 -->
|
<GoodsDetailSkeleton v-if="loadStatus === 'loading'" />
|
||||||
<scroll-view class="content-wrapper" scroll-y @scroll="handleScroll">
|
|
||||||
<ImageSwiper :border-radius="0" :height="300" :images="goodsData.commodityPhotoList" thumbnailBottom="42px" />
|
|
||||||
|
|
||||||
<view class="goods-content">
|
<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]">
|
||||||
<GoodInfo :goodsData="goodsData" />
|
<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]"
|
||||||
<LocationCard :orderData="goodsData" />
|
@click="reloadGoods">
|
||||||
|
<text class="text-[14px] font-[500] text-white">重新加载</text>
|
||||||
<!-- 日期选择区域 -->
|
|
||||||
<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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 日历组件 -->
|
<template v-else>
|
||||||
<Calender :visible="calendarVisible" mode="range" :range-require-price="true" :price-data="priceData"
|
<!-- 滚动区域 -->
|
||||||
@close="handleCalendarClose" @range-select="handleDateSelect" />
|
<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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import { goodsDetail, commodityDailyPriceList } from "@/request/api/GoodsApi";
|
import { goodsDetail, commodityDailyPriceList } from "@/request/api/GoodsApi";
|
||||||
import { DateUtils } from "@/utils";
|
import { DateUtils } from "@/utils";
|
||||||
@@ -65,12 +80,17 @@ import DateSelector from "./components/DateSelector/index.vue";
|
|||||||
import GoodDetail from "@/components/GoodDetail/index.vue";
|
import GoodDetail from "@/components/GoodDetail/index.vue";
|
||||||
import GoodFacility from "./components/GoodFacility/index.vue";
|
import GoodFacility from "./components/GoodFacility/index.vue";
|
||||||
import GoodPackage from "./components/GoodPackage/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";
|
import { useSelectedDateStore } from "@/store";
|
||||||
|
|
||||||
// 导航栏透明度 - 默认透明,随滚动变为不透明
|
// 导航栏透明度 - 默认透明,随滚动变为不透明
|
||||||
const navOpacity = ref(0);
|
const navOpacity = ref(0);
|
||||||
const calendarVisible = ref(false);
|
const calendarVisible = ref(false);
|
||||||
const goodsData = ref({});
|
const goodsData = ref({});
|
||||||
|
const loadStatus = ref("loading");
|
||||||
|
const currentCommodityId = ref("");
|
||||||
|
const solidNavBar = computed(() => loadStatus.value !== "success");
|
||||||
|
|
||||||
// 处理滚动事件
|
// 处理滚动事件
|
||||||
const handleScroll = (e) => {
|
const handleScroll = (e) => {
|
||||||
@@ -87,39 +107,67 @@ const selectedDate = ref({
|
|||||||
totalDays: 1,
|
totalDays: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const priceData = ref([])
|
const priceData = ref([]);
|
||||||
|
|
||||||
// 计算的总价格
|
// 计算的总价格
|
||||||
const calculatedTotalPrice = ref(0);
|
const calculatedTotalPrice = ref(0);
|
||||||
|
|
||||||
// 获取商品详情数据
|
// 获取商品详情数据
|
||||||
const goodsInfo = async (params) => {
|
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);
|
||||||
|
|
||||||
// 初始化计算价格
|
if (res?.code !== 0) {
|
||||||
calculatedTotalPrice.value = goodsData.value.specificationPrice || 0;
|
console.error("查询商品详情失败:", res);
|
||||||
|
loadStatus.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 判断是酒店类型订单再获取获取商品日价格及库存
|
if (
|
||||||
if (goodsData.value.orderType == 0) {
|
!res.data ||
|
||||||
configGoodsData();
|
typeof res.data !== "object" ||
|
||||||
getGoodsDailyPrice({
|
Array.isArray(res.data) ||
|
||||||
commodityId: goodsData.value.commodityId,
|
Object.keys(res.data).length === 0 ||
|
||||||
});
|
!res.data.commodityId
|
||||||
}
|
) {
|
||||||
|
loadStatus.value = "empty";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (goodsData.value.commodityStatus !== "1") {
|
goodsData.value = res.data;
|
||||||
uni.showModal({
|
|
||||||
title: "温馨提示",
|
// 初始化计算价格
|
||||||
content: "您选的商品暂不可预订,请重新选择。",
|
calculatedTotalPrice.value = goodsData.value.specificationPrice || 0;
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm) {
|
// 判断是酒店类型订单再获取获取商品日价格及库存
|
||||||
uni.navigateBack({ delta: 1 });
|
if (goodsData.value.orderType == 0) {
|
||||||
}
|
configGoodsData();
|
||||||
},
|
getGoodsDailyPrice({
|
||||||
});
|
commodityId: goodsData.value.commodityId,
|
||||||
return;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
const selectedDateStore = useSelectedDateStore();
|
||||||
onLoad(({ commodityId = "1950766939442774018" }) => {
|
onLoad(({ commodityId = "1950766939442774018" }) => {
|
||||||
|
currentCommodityId.value = commodityId;
|
||||||
|
|
||||||
// 从store中获取选中的日期
|
// 从store中获取选中的日期
|
||||||
selectedDate.value = selectedDateStore.selectedDate;
|
selectedDate.value = selectedDateStore.selectedDate;
|
||||||
|
|
||||||
goodsInfo({ commodityId });
|
goodsInfo({ commodityId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reloadGoods = () => {
|
||||||
|
if (!currentCommodityId.value) return;
|
||||||
|
goodsInfo({ commodityId: currentCommodityId.value });
|
||||||
|
};
|
||||||
|
|
||||||
// 显示日历弹窗
|
// 显示日历弹窗
|
||||||
const showCalendar = () => (calendarVisible.value = true);
|
const showCalendar = () => (calendarVisible.value = true);
|
||||||
|
|
||||||
|
|||||||
20
src/uni.scss
20
src/uni.scss
@@ -13,16 +13,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* 主题颜色(由 switch-client 命令自动更新) */
|
/* 主题颜色(由 switch-client 命令自动更新) */
|
||||||
$theme-color-900: #19428F;
|
$theme-color-900: #0B5C2D;
|
||||||
$theme-color-800: #174BB6;
|
$theme-color-800: #0B7034;
|
||||||
$theme-color-700: #145EE1;
|
$theme-color-700: #0B5C2D;
|
||||||
$theme-color-600: #1B74F5;
|
$theme-color-600: #02C34E;
|
||||||
$theme-color-500: #2D91FF;
|
$theme-color-500: #0CCD58;
|
||||||
$theme-color-400: #59B4FF;
|
$theme-color-400: #35F37F;
|
||||||
$theme-color-300: #8ED0FF;
|
$theme-color-300: #77FEAB;
|
||||||
$theme-color-200: #BCE1FF;
|
$theme-color-200: #A0E5BA;
|
||||||
$theme-color-100: #D9EEFF;
|
$theme-color-100: #E8FFF1;
|
||||||
$theme-color-50: #EEF8FF;
|
$theme-color-50: #F0F8F3;
|
||||||
|
|
||||||
// text 颜色
|
// text 颜色
|
||||||
$text-color-900: #181b25;
|
$text-color-900: #181b25;
|
||||||
|
|||||||
Reference in New Issue
Block a user