Files
YGChatCS/src/pages-service/tickets/components/TicketHome.vue

224 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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" />
<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">
<TopNavBar
title=""
background="transparent"
title-color="#ffffff"
back-icon-color="#ffffff"
@back="emit('back')"
/>
<view class="mt-[8px] flex flex-col items-end gap-[10px] pr-[16px]">
<view class="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-black/35 shadow-sm" @tap.stop="emit('open-travelers')">
<uni-icons type="contact" size="21" color="#FFFFFF" />
</view>
<view class="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-black/35 shadow-sm" @tap.stop="emit('open-orders')">
<uni-icons type="list" size="21" color="#FFFFFF" />
</view>
</view>
</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">
<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="relative z-10 -mt-[28px] min-h-0 flex-1 overflow-hidden rounded-t-[28px] bg-[#f4f6f8] shadow-[0_-8px_24px_rgba(6,17,31,0.12)]">
<view class="flex h-full min-h-0">
<scroll-view
class="h-full w-[80px] shrink-0 bg-white"
scroll-y
:scroll-into-view="leftScrollIntoViewId"
scroll-with-animation
>
<view
v-for="group in groups"
:id="`ticket-nav-${group.id}`"
: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
id="ticket-product-scroll"
class="h-full min-w-0 flex-1 bg-[#f4f6f8]"
scroll-y
:scroll-into-view="scrollIntoViewId"
scroll-with-animation
@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 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">
<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 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>
<view class="mt-auto flex items-end justify-between pt-[6px]">
<view>
<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>
</view>
<view class="flex h-[28px] items-center justify-center rounded-full bg-[#f26b32] px-[13px]" @click="emit('buy', product.id)">
<text class="text-[12px] font-medium leading-none 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]">{{ loading ? '加载中...' : '暂无可售商品' }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
import TopNavBar from "@/components/TopNavBar/index.vue"
const props = defineProps({
scenicSpot: { type: Object, required: true },
groups: { type: Array, default: () => [] },
products: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
const activeGroupId = ref(props.groups[0]?.id || '')
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 selectGroup = (groupId) => {
activeGroupId.value = groupId
scrollIntoViewId.value = ''
nextTick(() => {
scrollIntoViewId.value = `ticket-group-${groupId}`
})
}
const measureGroupOffsets = () => {
if (!props.groups.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()
})
query.exec((rects = []) => {
const containerRect = rects[0]
if (!containerRect) return
groupOffsets.value = props.groups
.map((group, index) => ({
id: group.id,
top: Math.max(
0,
Number(rects[index + 1]?.top || 0) -
Number(containerRect.top || 0) +
rightScrollTop.value
),
}))
.sort((left, right) => left.top - right.top)
})
})
}
const handleProductScroll = (event) => {
rightScrollTop.value = Number(event.detail?.scrollTop || 0)
if (!groupOffsets.value.length) {
measureGroupOffsets()
return
}
const activationLine = rightScrollTop.value + 24
const activeGroup = groupOffsets.value.reduce(
(current, group) => (group.top <= activationLine ? group : current),
groupOffsets.value[0]
)
if (activeGroup?.id) activeGroupId.value = activeGroup.id
}
onMounted(measureGroupOffsets)
watch(() => props.groups.map((group) => group.id).join(','), measureGroupOffsets)
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>