feat: add ticket storefront views

This commit is contained in:
2026-07-30 16:45:04 +08:00
parent 80cbf49812
commit fa9a1500d2
2 changed files with 217 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
<template>
<view class="h-screen overflow-hidden bg-slate-50 flex flex-col">
<view class="shrink-0 bg-white px-[16px] pb-[12px] pt-[calc(env(safe-area-inset-top)+12px)]">
<view class="flex h-[32px] items-center justify-between">
<view class="flex h-[32px] w-[32px] items-center justify-center" @click="emit('back')">
<uni-icons type="left" size="22" color="#1f2937" />
</view>
<text class="text-[17px] font-semibold text-slate-800">商品详情</text>
<view class="h-[32px] w-[32px]" />
</view>
</view>
<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="mx-[12px] -mt-[14px] rounded-[16px] bg-white p-[16px]">
<text v-if="product.shortTitle" class="block text-[13px] text-slate-500">{{ product.shortTitle }}</text>
<text class="mt-[4px] block text-[20px] font-bold leading-[28px] text-slate-900">{{ product.name }}</text>
<view class="mt-[10px] flex flex-wrap gap-[6px]">
<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>
</view>
<view class="mt-[14px] flex items-end justify-between">
<text class="text-[12px] text-slate-400">已售 {{ product.sold || 0 }}</text>
<view class="flex items-end">
<text v-if="product.marketPrice" class="mr-[8px] text-[12px] text-slate-400 line-through">¥{{ product.marketPrice }}</text>
<text class="text-[14px] font-medium text-[#f26b32]">¥<text class="text-[28px] font-bold">{{ product.salePrice }}</text></text>
</view>
</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 v-for="item in product.included || []" :key="item" class="mt-[10px] block text-[14px] leading-[20px] text-slate-600">· {{ item }}</text>
<text v-if="!(product.included || []).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 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] 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.purchaseNotice || '请以实际预约规则为准。' }}</text>
</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="flex items-center justify-between">
<view>
<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>
</view>
<view class="rounded-full bg-[#f26b32] px-[28px] py-[11px]" @click="emit('buy', product.id)">
<text class="text-[15px] font-semibold text-white">去预订</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
defineProps({
product: { type: Object, required: true },
})
const emit = defineEmits(['back', 'buy'])
</script>

View File

@@ -0,0 +1,143 @@
<template>
<view class="h-screen overflow-hidden bg-[#06111f] flex flex-col">
<view class="relative h-[43vh] shrink-0 overflow-hidden bg-[#173a34]">
<image class="absolute inset-0 h-full w-full" :src="scenicSpot.heroImage" 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 left-[16px] right-[16px] top-[calc(env(safe-area-inset-top)+12px)] flex items-center justify-between">
<view class="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-black/30" @click="emit('back')">
<uni-icons type="left" size="20" color="#FFFFFF" />
</view>
<view class="flex items-center gap-[10px]">
<view class="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-black/30" @click="emit('open-travelers')">
<uni-icons type="contact" size="20" color="#FFFFFF" />
</view>
<view class="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-black/30" @click="emit('open-orders')">
<uni-icons type="list" size="20" color="#FFFFFF" />
</view>
</view>
</view>
<view class="absolute bottom-[22px] 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">
<uni-icons type="location" size="15" color="#FFFFFF" />
<text class="ml-[4px] flex-1 text-[12px] leading-[17px] text-white/85">{{ scenicSpot.address }}</text>
</view>
<view class="mt-[10px] flex items-center gap-[18px]">
<view 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>
</view>
<view class="flex items-center" @click="openLocation">
<uni-icons type="navigate" size="15" color="#FFFFFF" />
<text class="ml-[4px] text-[12px] text-white">导航</text>
</view>
</view>
</view>
</view>
<view class="-mt-[16px] min-h-0 flex-1 overflow-hidden rounded-t-[22px] bg-white pt-[14px]">
<view class="flex h-full min-h-0">
<scroll-view class="h-full w-[96px] shrink-0 bg-white" scroll-y>
<view
v-for="group in groups"
:key="group.id"
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)"
>
<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>
</scroll-view>
<scroll-view class="h-full min-w-0 flex-1 bg-[#f4f6f8]" scroll-y :scroll-into-view="scrollIntoViewId" scroll-with-animation>
<view class="px-[12px] pb-[24px]">
<view v-for="group in groups" :id="`ticket-group-${group.id}`" :key="group.id" 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>
</view>
<text v-if="group.description" class="mt-[4px] block text-[12px] leading-[17px] text-[#808997]">{{ group.description }}</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 class="ml-[10px] flex min-w-0 flex-1 flex-col">
<text class="line-clamp-2 text-[14px] font-semibold leading-[20px] text-[#242b36]" @click="emit('open-detail', product.id)">{{ product.shortTitle || product.name }}</text>
<view class="mt-[5px] flex flex-wrap gap-[4px]">
<text v-if="product.ageRule" class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]">{{ ageRuleText(product.ageRule) }}</text>
<text v-for="highlight in product.highlights || []" :key="highlight" class="rounded-[4px] bg-[#f2ebff] px-[5px] py-[2px] text-[10px] text-[#7a52b3]">{{ highlight }}</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>
</view>
<view class="rounded-full bg-[#f26b32] px-[13px] py-[6px]" @click="emit('buy', product.id)">
<text class="text-[12px] font-medium text-white">选购</text>
</view>
</view>
</view>
</view>
</view>
<view v-else class="mt-[10px] rounded-[14px] bg-white px-[16px] py-[28px] text-center">
<text class="text-[13px] text-[#9aa2ad]">暂无可售商品</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
scenicSpot: { type: Object, required: true },
groups: { type: Array, default: () => [] },
products: { type: Array, default: () => [] },
})
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
const activeGroupId = ref(props.groups[0]?.id || '')
const scrollIntoViewId = ref('')
const productsById = computed(() => new Map(props.products.map((product) => [product.id, product])))
const productsForGroup = (group) => (group.productIds || [])
.map((productId) => productsById.value.get(productId))
.filter(Boolean)
const ageRuleText = (rule) => `${rule.min}${rule.max}周岁`
const selectGroup = (groupId) => {
activeGroupId.value = groupId
scrollIntoViewId.value = `ticket-group-${groupId}`
}
const makePhoneCall = () => {
if (!props.scenicSpot.phone) return
uni.makePhoneCall({ phoneNumber: props.scenicSpot.phone })
}
const openLocation = () => {
const latitude = Number(props.scenicSpot.latitude)
const longitude = Number(props.scenicSpot.longitude)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
uni.showToast({ title: '暂无导航坐标', icon: 'none' })
return
}
uni.openLocation({
latitude,
longitude,
name: props.scenicSpot.name || '',
address: props.scenicSpot.address || '',
fail: () => uni.showToast({ title: '打开导航失败', icon: 'none' }),
})
}
</script>