feat: render scenic ticket home data

This commit is contained in:
2026-08-11 11:16:49 +08:00
parent e2dc7d98bb
commit 182e7101c0
2 changed files with 129 additions and 55 deletions

View File

@@ -1,7 +1,7 @@
<template>
<view class="h-screen overflow-hidden bg-[#06111f] flex flex-col">
<view class="relative z-0 h-[43vh] shrink-0 overflow-hidden bg-[#173a34]">
<image class="absolute inset-0 h-full w-full" :src="scenicSpot.heroImage" mode="aspectFill" />
<image class="absolute inset-0 h-full w-full" :src="homeData.coverImage" mode="aspectFill" />
<view class="absolute inset-0 bg-[linear-gradient(180deg,rgba(6,17,31,0.15)_18%,rgba(6,17,31,0.1)_45%,rgba(6,17,31,0.92)_100%)]" />
<view class="absolute inset-x-0 top-0 z-20">
@@ -23,16 +23,16 @@
</view>
<view class="absolute bottom-[42px] left-[20px] right-[20px]">
<text class="block text-[28px] font-bold leading-[1.2] text-white">{{ scenicSpot.name }}</text>
<text class="mt-[7px] block text-[13px] text-white/85">{{ scenicSpot.level }}{{ scenicSpot.heritage }}</text>
<view class="mt-[10px] flex items-center">
<text class="block text-[28px] font-bold leading-[1.2] text-white">{{ homeData.scenicName }}</text>
<text v-if="scenicMetaText" class="mt-[7px] block text-[13px] text-white/85">{{ scenicMetaText }}</text>
<view v-if="homeData.address" class="mt-[10px] flex items-center">
<uni-icons type="location" size="15" color="#FFFFFF" />
<text class="ml-[4px] flex-1 text-[12px] leading-[17px] text-white/85">{{ scenicSpot.address }}</text>
<text class="ml-[4px] flex-1 text-[12px] leading-[17px] text-white/85">{{ homeData.address }}</text>
</view>
<view class="mt-[10px] flex items-center gap-[18px]">
<view class="flex items-center" @click="makePhoneCall">
<view v-if="homeData.phone" class="flex items-center" @click="makePhoneCall">
<uni-icons type="phone" size="15" color="#FFFFFF" />
<text class="ml-[4px] text-[12px] text-white">{{ scenicSpot.phone }}</text>
<text class="ml-[4px] text-[12px] text-white">{{ homeData.phone }}</text>
</view>
<view class="flex items-center" @click="openLocation">
<uni-icons type="navigate" size="15" color="#FFFFFF" />
@@ -52,14 +52,14 @@
>
<view
v-for="group in groups"
:id="`ticket-nav-${group.id}`"
:key="group.id"
:id="`ticket-nav-${group.categoryId}`"
:key="group.categoryId"
class="relative flex min-h-[68px] items-center justify-center px-[10px] py-[12px] text-center"
:class="group.id === activeGroupId ? 'bg-[#ecf8f2] text-[#11795c]' : 'bg-white text-[#5f6875]'"
@click="selectGroup(group.id)"
:class="isActiveGroup(group.categoryId) ? 'bg-[#ecf8f2] text-[#11795c]' : 'bg-white text-[#5f6875]'"
@click="selectGroup(group.categoryId)"
>
<view v-if="group.id === activeGroupId" class="absolute left-0 h-[28px] w-[3px] rounded-r-full bg-[#149474]" />
<text class="text-[13px] leading-[18px]" :class="group.id === activeGroupId ? 'font-semibold' : 'font-normal'">{{ group.name }}</text>
<view v-if="isActiveGroup(group.categoryId)" class="absolute left-0 h-[28px] w-[3px] rounded-r-full bg-[#149474]" />
<text class="text-[13px] leading-[18px]" :class="isActiveGroup(group.categoryId) ? 'font-semibold' : 'font-normal'">{{ group.categoryName }}</text>
</view>
</scroll-view>
@@ -72,33 +72,35 @@
@scroll="handleProductScroll"
>
<view class="px-[12px] pb-[24px]">
<view v-for="group in groups" :id="`ticket-group-${group.id}`" :key="group.id" class="pt-[14px]">
<view v-if="!groups.length" class="pt-[80px] text-center">
<text class="text-[13px] text-[#9aa2ad]">暂无商品分类</text>
</view>
<view v-for="group in groups" :id="`ticket-group-${group.categoryId}`" :key="group.categoryId" class="pt-[14px]">
<view class="flex items-center">
<text class="text-[18px] font-bold text-[#202733]">{{ group.name }}</text>
<text v-if="group.tag" class="ml-[7px] rounded-full bg-[#e5f6ee] px-[7px] py-[2px] text-[10px] text-[#158061]">{{ group.tag }}</text>
<text class="text-[18px] font-bold text-[#202733]">{{ group.categoryName }}</text>
<text v-if="group.title" class="ml-[7px] rounded-full bg-[#e5f6ee] px-[7px] py-[2px] text-[10px] text-[#158061]">{{ group.title }}</text>
</view>
<text v-if="group.description" class="mt-[4px] block text-[12px] leading-[17px] text-[#808997]">{{ group.description }}</text>
<text v-if="group.subtitle" class="mt-[4px] block text-[12px] leading-[17px] text-[#808997]">{{ group.subtitle }}</text>
<view v-if="productsForGroup(group).length" class="mt-[10px] flex flex-col gap-[10px]">
<view v-for="product in productsForGroup(group)" :key="product.id" class="flex rounded-[14px] bg-white p-[10px]">
<image class="h-[100px] w-[100px] shrink-0 rounded-[10px] bg-[#e8edf0]" :src="product.images && product.images[0]" mode="aspectFill" @click="emit('open-detail', product.id)" />
<view v-for="product in productsForGroup(group)" :key="product.commodityId" class="flex rounded-[14px] bg-white p-[10px]">
<image class="h-[100px] w-[100px] shrink-0 rounded-[10px] bg-[#e8edf0]" :src="product.image" mode="aspectFill" @click="emit('open-detail', product.commodityId)" />
<view class="ml-[10px] flex min-w-0 flex-1 flex-col">
<view class="flex min-w-0 items-start" @click="emit('open-detail', product.id)">
<text class="line-clamp-2 min-w-0 text-[14px] font-semibold leading-[20px] text-[#242b36]">{{ product.shortTitle || product.name }}</text>
<view class="flex min-w-0 items-start" @click="emit('open-detail', product.commodityId)">
<text class="line-clamp-2 min-w-0 text-[14px] font-semibold leading-[20px] text-[#242b36]">{{ product.title }}</text>
<view class="flex h-[20px] shrink-0 items-center">
<uni-icons type="right" size="14" color="#242b36" />
</view>
</view>
<view class="mt-[5px] flex flex-wrap gap-[4px]">
<text v-if="product.ageRule" class="rounded-[4px] bg-[#f2ebff] px-[5px] py-[2px] text-[10px] text-[#7a52b3]">{{ ageRuleText(product.ageRule) }}</text>
<text v-for="highlight in product.highlights || []" :key="highlight" class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]">{{ highlight }}</text>
<view v-if="product.tags?.length" class="mt-[5px] flex flex-wrap gap-[4px]">
<text v-for="tag in product.tags" :key="tag" class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]">{{ tag }}</text>
</view>
<view class="mt-auto flex items-end justify-between pt-[6px]">
<view>
<text class="block text-[10px] text-[#9aa2ad]">已售 {{ product.sold || 0 }}</text>
<text class="text-[12px] font-medium text-[#f26b32]">¥<text class="text-[20px] font-bold">{{ product.salePrice }}</text></text>
<text v-if="product.salesText" class="block text-[10px] text-[#9aa2ad]">{{ product.salesText }}</text>
<text class="text-[12px] font-medium text-[#f26b32]">¥<text class="text-[20px] font-bold">{{ product.displayPrice }}</text></text>
</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.commodityId)">
<text class="text-[12px] font-medium leading-none text-white">选购</text>
</view>
</view>
@@ -121,29 +123,40 @@ import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'v
import TopNavBar from "@/components/TopNavBar/index.vue"
const props = defineProps({
scenicSpot: { type: Object, required: true },
groups: { type: Array, default: () => [] },
products: { type: Array, default: () => [] },
homeData: { type: Object, required: true },
})
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
const activeGroupId = ref(props.groups[0]?.id || '')
const groups = computed(() =>
Array.isArray(props.homeData.categories) ? props.homeData.categories : []
)
const products = computed(() =>
Array.isArray(props.homeData.products?.records)
? props.homeData.products.records
: []
)
const preferredCategoryId = String(props.homeData.selectedCategoryId || '')
const activeGroupId = ref(
groups.value.some(
(group) => String(group.categoryId) === preferredCategoryId
)
? props.homeData.selectedCategoryId
: groups.value[0]?.categoryId || ''
)
const scrollIntoViewId = ref('')
const leftScrollIntoViewId = computed(() => `ticket-nav-${activeGroupId.value}`)
const groupOffsets = ref([])
const rightScrollTop = ref(0)
const instance = getCurrentInstance()
const productsById = computed(() => new Map(props.products.map((product) => [product.id, product])))
const productsForGroup = (group) => (group.productIds || [])
.map((productId) => productsById.value.get(productId))
.filter((product) => product?.status === 'on_sale' && product.clientVisible !== false)
const ageRuleText = (rule) => {
if (rule.min > 0 && rule.max >= 999) return `${rule.min}周岁及以上`
if (rule.min <= 0 && rule.max < 999) return `${rule.max}周岁及以下`
return `${rule.min}${rule.max}周岁`
}
const scenicMetaText = computed(() =>
[props.homeData.levelText, props.homeData.subtitle].filter(Boolean).join('')
)
const isActiveGroup = (groupId) =>
String(groupId) === String(activeGroupId.value)
const productsForGroup = (group) =>
products.value.filter(
(product) => String(product.categoryId) === String(group.categoryId)
)
const selectGroup = (groupId) => {
activeGroupId.value = groupId
@@ -154,20 +167,20 @@ const selectGroup = (groupId) => {
}
const measureGroupOffsets = () => {
if (!props.groups.length) return
if (!groups.value.length) return
nextTick(() => {
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('#ticket-product-scroll').boundingClientRect()
props.groups.forEach((group) => {
query.select(`#ticket-group-${group.id}`).boundingClientRect()
groups.value.forEach((group) => {
query.select(`#ticket-group-${group.categoryId}`).boundingClientRect()
})
query.exec((rects = []) => {
const containerRect = rects[0]
if (!containerRect) return
groupOffsets.value = props.groups
groupOffsets.value = groups.value
.map((group, index) => ({
id: group.id,
id: group.categoryId,
top: Math.max(
0,
Number(rects[index + 1]?.top || 0) -
@@ -196,17 +209,43 @@ const handleProductScroll = (event) => {
}
onMounted(measureGroupOffsets)
watch(() => props.groups.map((group) => group.id).join(','), measureGroupOffsets)
watch(
() => groups.value.map((group) => group.categoryId).join(','),
() => {
if (!groups.value.some((group) => isActiveGroup(group.categoryId))) {
const selectedCategoryId = String(props.homeData.selectedCategoryId || '')
activeGroupId.value = groups.value.some(
(group) => String(group.categoryId) === selectedCategoryId
)
? props.homeData.selectedCategoryId
: groups.value[0]?.categoryId || ''
}
measureGroupOffsets()
}
)
const makePhoneCall = () => {
if (!props.scenicSpot.phone) return
uni.makePhoneCall({ phoneNumber: props.scenicSpot.phone })
if (!props.homeData.phone) return
uni.makePhoneCall({ phoneNumber: props.homeData.phone })
}
const openLocation = () => {
const latitude = Number(props.scenicSpot.latitude)
const longitude = Number(props.scenicSpot.longitude)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
const latitudeValue = props.homeData.latitude
const longitudeValue = props.homeData.longitude
const coordinatesMissing =
latitudeValue === '' ||
latitudeValue === null ||
latitudeValue === undefined ||
longitudeValue === '' ||
longitudeValue === null ||
longitudeValue === undefined
const latitude = Number(latitudeValue)
const longitude = Number(longitudeValue)
if (
coordinatesMissing ||
!Number.isFinite(latitude) ||
!Number.isFinite(longitude)
) {
uni.showToast({ title: '暂无导航坐标', icon: 'none' })
return
}
@@ -214,8 +253,8 @@ const openLocation = () => {
uni.openLocation({
latitude,
longitude,
name: props.scenicSpot.name || '',
address: props.scenicSpot.address || '',
name: props.homeData.scenicName || '',
address: props.homeData.address || '',
fail: () => uni.showToast({ title: '打开导航失败', icon: 'none' }),
})
}

View File

@@ -82,6 +82,41 @@ test("ticket storefront loads API home data without a static adapter", async ()
);
});
test("TicketHome consumes scenic home response fields directly", async () => {
const source = await readTicketFile("components/TicketHome.vue");
for (const field of [
"homeData.coverImage",
"homeData.scenicName",
"homeData.levelText",
"homeData.subtitle",
"group.categoryId",
"group.categoryName",
"group.title",
"group.subtitle",
"product.commodityId",
"product.image",
"product.title",
"product.tags",
"product.displayPrice",
"product.salesText",
]) {
assert.ok(source.includes(field), `${field} is not rendered`);
}
assert.match(source, /props\.homeData\.products\?\.records/);
assert.match(
source,
/product\.categoryId\) === String\(group\.categoryId/
);
assert.match(source, /emit\('open-detail', product\.commodityId\)/);
assert.match(source, /emit\('buy', product\.commodityId\)/);
assert.doesNotMatch(
source,
/scenicSpot|productIds|ageRuleText|product\.shortTitle|product\.salePrice/
);
});
test("product detail is an independent prototype-aligned page", async () => {
const indexPage = await readTicketFile("index.vue");
const detailPage = await readTicketFile("product-detail.vue");