feat: 拉取接口填充了一下新版首页

This commit is contained in:
2026-08-17 20:27:31 +08:00
parent 21659f61e3
commit ec20e11c32
5 changed files with 93 additions and 27 deletions

View File

@@ -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'])

View File

@@ -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']

View File

@@ -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>

View File

@@ -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']

View File

@@ -63,13 +63,17 @@ 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 () => { 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/);