feat: add ticket storefront views
This commit is contained in:
74
src/pages-service/tickets/components/ProductDetail.vue
Normal file
74
src/pages-service/tickets/components/ProductDetail.vue
Normal 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>
|
||||
143
src/pages-service/tickets/components/TicketHome.vue
Normal file
143
src/pages-service/tickets/components/TicketHome.vue
Normal 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>
|
||||
Reference in New Issue
Block a user