Commit 0ae117a8 authored by 罗超's avatar 罗超

目的地模块更新

parent 287dcb71
This diff is collapsed.
This diff is collapsed.
<template>
<Teleport to="body">
<transition name="global-dialog-fade">
<div
v-if="visible"
class="fixed inset-0 z-[2000] flex items-center justify-center"
>
<!-- 遮罩 -->
<div
class="absolute inset-0 bg-black/40"
@click="onMaskClick"
/>
<!-- 对话框主体 -->
<div
class="relative !inline-block z-10 w-auto max-w-[1200px] mx-4"
@click.stop
>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- 头部 -->
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<div
v-if="title"
class="text-base font-semibold text-[var(--customColor-text-10)]"
>
{{ title }}
</div>
<button
type="button"
class="ml-auto flex h-8 w-8 items-center justify-center rounded-full hover:bg-black/5 transition-colors"
@click="close"
>
<VIcon
name="cross"
fill="outline"
class="text-3xl text-[var(--customColor-text-8)]"
/>
</button>
</div>
<!-- 正文插槽,完全交给外部控制 -->
<div class="w-auto inline-block">
<slot />
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import VIcon from './VIcon.vue'
const props = withDefaults(
defineProps<{
modelValue: boolean
title?: string
/** 是否允许点击遮罩关闭,默认 true */
maskClosable?: boolean
}>(),
{
maskClosable: true,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => {
emit('update:modelValue', val)
},
})
let originalBodyOverflow: string | null = null
// 锁定 / 还原页面滚动
const lockScroll = () => {
if (typeof document === 'undefined') return
if (originalBodyOverflow === null) {
originalBodyOverflow = document.body.style.overflow || ''
}
document.body.style.overflow = 'hidden'
}
const unlockScroll = () => {
if (typeof document === 'undefined') return
if (originalBodyOverflow !== null) {
document.body.style.overflow = originalBodyOverflow
originalBodyOverflow = null
}
}
const close = () => {
if (!visible.value) return
visible.value = false
emit('close')
}
const onMaskClick = () => {
if (!props.maskClosable) return
close()
}
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
close()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
// 初始值为 true 时也要锁定滚动
if (visible.value) {
lockScroll()
}
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown)
unlockScroll()
})
watch(
() => visible.value,
(val) => {
if (val) {
lockScroll()
} else {
unlockScroll()
}
},
)
</script>
<style scoped>
.global-dialog-fade-enter-active,
.global-dialog-fade-leave-active {
transition: opacity 0.18s ease-out;
}
.global-dialog-fade-enter-from,
.global-dialog-fade-leave-to {
opacity: 0;
}
</style>
<template>
<div class="relative">
<Swiper
v-if="enableSlide && dataList.length > slidesPerView"
class="hot-swiper"
:modules="swiperModules"
:slides-per-view="slidesPerView"
:slides-per-group="slidesPerView"
:space-between="12"
:loop="false"
@swiper="onSwiper"
>
<SwiperSlide
v-for="(item, index) in dataList"
:key="item.name || index"
>
<div class="hot-card" @click="handleClick(item)">
<img
:src="item.image"
:alt="item.name"
loading="lazy"
decoding="async"
class="w-full h-full object-cover"
/>
<div class="hot-card-mask"></div>
<div class="hot-card-text">
<div class="hot-card-name">
{{ item.name }}
</div>
<div class="hot-card-meta">
{{ item.activityText }}
</div>
</div>
</div>
</SwiperSlide>
</Swiper>
<div
v-else
class="flex gap-3"
>
<div
v-for="(item, index) in dataList"
:key="item.name || index"
class="hot-card"
@click="handleClick(item)"
>
<img
:src="item.image"
:alt="item.name"
loading="lazy"
decoding="async"
class="w-full h-full object-cover"
/>
<div class="hot-card-mask" ></div>
<div class="hot-card-text">
<div class="hot-card-name">
{{ item.name }}
</div>
<div class="hot-card-meta">
{{ item.activityText }}
</div>
</div>
</div>
</div>
<button
v-if="enableSlide && showPrev"
class="hot-arrow hot-arrow-prev"
type="button"
@click="handlePrev"
>
<IconLeft />
</button>
<button
v-if="enableSlide && showNext"
class="hot-arrow hot-arrow-next"
type="button"
@click="handleNext"
>
<IconRight />
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref, type PropType } from 'vue'
// @ts-ignore Swiper 兼容类型声明
import { Swiper, SwiperSlide } from 'swiper/vue'
// @ts-ignore Swiper 模块类型声明兼容
import { Navigation } from 'swiper/modules'
// @ts-ignore
import type { Swiper as SwiperType } from 'swiper'
// @ts-ignore
import 'swiper/css'
import { IconLeft, IconRight } from '@arco-design/web-vue/es/icon'
import { HotPoiTypeEnum } from '@/services/PoiService'
import { useRouter } from 'vue-router'
interface HotPlaceItem {
name: string
image: string
activityText: string,
type?: HotPoiTypeEnum,
id?:string
}
const props = defineProps({
dataList: {
type: Array as PropType<HotPlaceItem[]>,
default: () => [],
},
enableSlide: {
type: Boolean,
default: true,
},
slidesPerView: {
type: Number,
default: 7,
},
})
const swiperModules = [Navigation]
const swiperRef = ref<SwiperType | null>(null)
const router= useRouter()
const onSwiper = (swiper: SwiperType) => {
swiperRef.value = swiper
}
const showPrev = computed(() => {
if (!swiperRef.value) return false
return !swiperRef.value.isBeginning
})
const showNext = computed(() => {
if (!swiperRef.value) return false
return !swiperRef.value.isEnd
})
const handlePrev = () => {
swiperRef.value?.slidePrev()
}
const handleNext = () => {
swiperRef.value?.slideNext()
}
const handleClick=(item:HotPlaceItem)=>{
if(item.id){
if(!item.type || item.type !== HotPoiTypeEnum.POI){
router.push(`/place/${item.id}`)
}
}
}
</script>
<style scoped>
.hot-swiper{
position: relative;
padding: 0 12px;
}
.hot-card{
width: 140px;
height: 140px;
border-radius: 16px;
overflow: hidden;
position: relative;
cursor: pointer;
}
.hot-card-mask{
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.05) 20%, rgba(0,0,0,0.65) 100%);
}
.hot-card-text{
position: absolute;
left: 10px;
right: 10px;
bottom: 10px;
color: #fff;
font-size: 11px;
line-height: 1.3;
}
.hot-card-name{
font-size: 13px;
font-weight: 600;
margin-bottom: 2px;
}
.hot-card-meta{
opacity: 0.9;
}
.hot-arrow{
width: 32px;
height: 32px;
border-radius: 9999px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
color: #1f2933;
top: 40%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
z-index: 10;
font-size: 18px;
line-height: 1;
border: none;
cursor: pointer;
}
.hot-arrow-prev{
left: -40px;
}
.hot-arrow-next{
right: -40px;
}
</style>
<template>
<div>
<div class="destination-panel" v-if="placeTree.length">
<div class="continent-list">
<div class="continent-item" :class="{ active: -1 === activeIndex }" @mouseenter="activeIndex = -1">
{{ t('header.recommond') }}
</div>
<div v-for="(continent, index) in placeTree" :key="continent.value || index" class="continent-item"
:class="{ active: index === activeIndex }" @mouseenter="activeIndex = index">
{{ continent.label }}
</div>
</div>
<div v-show="activeIndex === -1" class="p-4 flex-1 min-w-0 overflow-y-auto">
<div class="font-bold text-lg">
{{ t('header.hotDestinations') }}
</div>
<div class="pt-4 mb-2" v-for="(val, index) in hotDestinations" :key="index">
<div class="font-bold text-md mb-3">{{ val.name }}</div>
<div class="grid grid-cols-4 gap-4">
<div @click="handleHotDestinationClick(item.placeId)"
class="rounded-md flex items-center justify-center cursor-pointer overlap relative overflow-hidden !aspect-[3/2] !object-cover"
v-for="(item, index) in val.reList" :key="index">
<a-image :src="item.placeDetail?.backgroundImage || ''" show-loader></a-image>
<div class="absolute bottom-0 left-0 right-0 p-2">
<div class="text-white text-sm font-bold">
{{ item.placeDetail?.name }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="continent-content" v-if="activeContinent && activeIndex !== -1">
<template v-for="country in activeContinent.children" :key="country.value">
<div class="country-block">
<div class="country-title">
{{ country.label }}
</div>
<div class="place-grid">
<span class="place-item destination-place-item rounded-md">
{{ t('place.all') }}
</span>
<span v-for="place in country.children" :key="place.value" @click="handleHotDestinationClick(place.value)"
class="place-item destination-place-item rounded-md">
{{ place.label }}
</span>
</div>
</div>
</template>
</div>
</div>
<div v-else class="destination-empty">
<a-spin size="small" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PlaceService, { type PlaceGroupOutputDto, } from '@/services/PlaceService'
import type { PlaceTreeNode } from '@/types/place'
import { Message } from '@arco-design/web-vue'
import { useSystemConfigStore } from '@/stores'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const placeTree = ref<PlaceTreeNode[]>([])
const activeIndex = ref(-1)
const router = useRouter()
const activeContinent = computed(() => placeTree.value[activeIndex.value])
const systemConfigStore = useSystemConfigStore()
const hotDestinations = ref<PlaceGroupOutputDto[]>([])
const handleHotDestinationClick = (val: string) => {
router.push(`/place/${val}`)
}
const loadPlaces = async () => {
try {
const data = await PlaceService.getPlaceTreeAsync(true)
placeTree.value = data || []
activeIndex.value = -1
console.log(data)
} catch (error) {
Message.error(t('header.destinationsFailed'))
}
}
const loadHotDestinations = async () => {
try {
const data = await PlaceService.getRecommendPlaceListAsync(systemConfigStore.distributorId || 0)
hotDestinations.value = data || []
} catch (error) {
Message.error(t('header.hotDestinationsFailed'))
}
}
onMounted(() => {
loadPlaces()
loadHotDestinations()
})
</script>
<style scoped lang="scss">
.destination-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 999px;
background-color: #fff;
cursor: pointer;
font-weight: 600;
transition: all .2s ease;
}
.destination-trigger:hover {
border-color: rgb(var(--arcoblue-6));
color: rgb(var(--arcoblue-6));
}
.destination-panel {
display: flex;
width: 720px;
height: 320px;
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.continent-list {
width: 160px;
background: #f7f8fa;
padding: 12px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.continent-item {
padding: 15px 18px;
cursor: pointer;
font-weight: 500;
color: rgb(var(--customColor-text-7));
transition: all .2s ease;
}
.continent-item.active,
.continent-item:hover {
background: #fff;
color: rgb(var(--arcoblue-6));
}
.continent-content {
flex: 1;
padding: 18px 24px;
overflow: auto;
}
.country-block+.country-block {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgb(var(--arcoblue-7));
}
.country-title {
font-weight: 600;
margin-bottom: 12px;
color: rgb(var(--customColor-text-10));
}
.place-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 12px 12px;
}
.place-item {
font-size: 13px;
cursor: pointer;
padding: 10px 10px;
}
// 目的地项使用动态配色
.destination-place-item {
color: rgb(var(--customColor-text-7));
transition: all 0.2s ease;
&:hover {
background-color: rgba(var(--arcoblue-6), 0.1);
color: rgb(var(--arcoblue-6));
}
}
.destination-empty {
width: 240px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
</style>
\ No newline at end of file
<template>
<div
class="product-card flex flex-col h-[320px] cursor-pointer"
>
<img
:src="item.image"
:alt="item.title"
loading="lazy"
decoding="async"
class="w-full h-[180px] object-cover rounded-md "
/>
<div class="product-body py-2 flex-1 min-h-0">
<div
v-if="item.tag"
class="text-[12px] text-[var(--customColor-text-7)] mb-1 line-clamp-1"
>
<a-tag size="small" class="!bg-[var(--customPrimary-2)] !text-[var(--customColor-text-7)] !font-normal !rounded-sm">{{ item.tag }}</a-tag>
</div>
<div class="text-sm text-[var(--customColor-text-10)] leading-snug line-clamp-2 mb-1">
{{ item.title }}
</div>
<div
v-if="item.meta"
class="text-[11px] text-[var(--customColor-text-7)] mb-1 line-clamp-1 flex items-center gap-1"
>
<v-icon
v-if="item.metaIcon"
:name="item.metaIcon"
class="text-xs"
/>
<span>{{ item.meta }}</span>
</div>
<div
v-if="item.rating || item.comments || item.orders"
class="flex items-center text-xs text-[var(--customColor-text-7)] mb-1"
>
<span
v-if="item.rating"
class="text-[var(--customPrimary-6)] font-semibold mr-1 flex items-center gap-1"
>
<v-icon name="star"></v-icon>
{{ item.rating }}
</span>
<span v-if="item.comments || item.orders">
({{ item.comments }}<template v-if="item.orders">{{ item.orders }} 已订购</template>)
</span>
</div>
</div>
<div class="text-[11px] text-[var(--customColor-text-7)] py-2">
<span class="text-[var(--customPrimary-6)] font-semibold mr-1">
CNY <span class="text-base">{{ parseFloat(item.price as string).toFixed(2) }}</span>
</span>
<span
v-if="item.originPrice"
class="line-through text-[var(--customColor-text-6)]"
>
CNY {{ item.originPrice }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
interface ProductCardItem {
title: string
image: string
tag?: string
meta?: string
metaIcon?: string
rating?: string | number
comments?: string
orders?: string
price?: string | number
originPrice?: string | number | null
}
defineProps<{
item: ProductCardItem
}>()
</script>
<style scoped>
.product-card:hover > img{
opacity: 0.8;
transition: opacity 0.3s ease;
}
</style>
\ No newline at end of file
<template>
<div :class="{ 'relative': isContainer }">
<Swiper v-if="enableSlide && dataList.length > slidesPerView" class="top10-swiper" :modules="swiperModules"
:slides-per-view="slidesPerView" :slides-per-group="slidesPerView" :space-between="16" :loop="false"
@swiper="onSwiper">
<SwiperSlide v-for="(item, index) in dataList" :key="item.title || index">
<div class="relative">
<ProductCard :item="item" />
<div v-if="showIndex" class="absolute left-2 top-2">
<span
class="absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] text-white text-[10px] z-10">
{{ index + 1 }}
</span>
<v-icon name="shield" fill="solid" class="text-orange-500 text-3xl"></v-icon>
</div>
<div v-if="showFavorite" class="absolute right-2 top-2 w-7 h-7 cursor-pointer"
:class="{ 'wish-button': isHeartbeat }" @click="handleWish">
<v-icon name="heart" style="-webkit-text-stroke: 2px #FFF;"
class="text-black/30 text-shadow-lg text-2xl" :class="{ 'text-orange-500': isHeartbeat }"
fill="solid"></v-icon>
</div>
</div>
</SwiperSlide>
</Swiper>
<!-- 不翻页:普通列表 -->
<div v-else class="flex gap-4">
<div v-for="(item, index) in dataList" :key="item.title || index" class="relative flex-shrink-0 w-[260px]">
<ProductCard :item="item" />
<div v-if="showIndex" class="absolute left-2 top-2">
<span
class="absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] text-white text-[10px] z-10">
{{ index + 1 }}
</span>
<v-icon name="shield" fill="solid" class="text-orange-500 text-3xl"></v-icon>
</div>
<div v-if="showFavorite" class="absolute right-2 top-2 w-7 h-7 cursor-pointer"
:class="{ 'wish-button': isHeartbeat }" @click="handleWish">
<v-icon name="heart" style="-webkit-text-stroke: 2px #FFF;"
class="text-black/30 text-shadow-lg text-2xl" :class="{ 'text-orange-500': isHeartbeat }"
fill="solid"></v-icon>
</div>
</div>
</div>
<!-- 自定义左右箭头 -->
<button v-if="enableSlide && showPrev" class="top10-arrow top10-arrow-prev" type="button" @click="handlePrev">
<!-- <v-icon name="arrow-left" class="text-black/50 text-shadow-lg text-2xl" fill="solid"></v-icon> -->
<IconLeft />
</button>
<button v-if="enableSlide && showNext" class="top10-arrow top10-arrow-next" type="button" @click="handleNext">
<IconRight />
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref, type PropType } from 'vue'
// @ts-ignore Swiper 兼容类型声明
import { Swiper, SwiperSlide } from 'swiper/vue'
// @ts-ignore Swiper 模块类型声明兼容
import { Navigation } from 'swiper/modules'
// @ts-ignore
import type { Swiper as SwiperType } from 'swiper'
// @ts-ignore
import 'swiper/css'
import ProductCard from './ProductCard.vue'
interface ProductCardItem {
title: string
image: string
tag?: string
meta?: string
metaIcon?: string
rating?: string | number
comments?: string
orders?: string
price?: string | number
originPrice?: string | number | null
}
const props = defineProps({
dataList: {
type: Array as PropType<ProductCardItem[]>,
default: () => [],
},
enableSlide: {
type: Boolean,
default: true,
},
slidesPerView: {
type: Number,
default: 4,
},
showFavorite: {
type: Boolean,
default: false,
},
showIndex: {
type: Boolean,
default: false,
},
isContainer: {
type: Boolean,
default: true,
},
})
const swiperModules = [Navigation]
const swiperRef = ref<SwiperType | null>(null)
const onSwiper = (swiper: SwiperType) => {
swiperRef.value = swiper
}
const showPrev = computed(() => {
if (!swiperRef.value) return false
return !swiperRef.value.isBeginning
})
const showNext = computed(() => {
if (!swiperRef.value) return false
return !swiperRef.value.isEnd
})
const handlePrev = () => {
swiperRef.value?.slidePrev()
}
const handleNext = () => {
swiperRef.value?.slideNext()
}
const isHeartbeat = ref(false)
const handleWish = () => {
isHeartbeat.value = !isHeartbeat.value
// setTimeout(() => {
// isHeartbeat.value = false
// }, 300)
}
</script>
<style scoped>
@keyframes wish-heartbeat {
0% {
transform: scale(.6);
}
30% {
transform: scale(1.4);
}
100% {
transform: scale(1);
}
}
.wish-button {
animation: wish-heartbeat .3s linear forwards;
}
.top10-swiper {
position: relative;
/* 预留左右内边距放置箭头,避免被外层容器裁剪 */
}
.top10-arrow {
width: 32px;
height: 32px;
border-radius: 9999px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
color: #1f2933;
top: 40%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
z-index: 10;
font-size: 18px;
line-height: 1;
border: none;
cursor: pointer;
}
.top10-arrow-prev {
/* 放在内容区域内侧,避免被 overflow 裁剪 */
left: -50px;
}
.top10-arrow-next {
right: -50px;
}
</style>
<template>
<div class="travel-card cursor-pointer">
<div class="relative h-[200px] rounded-xl overflow-hidden">
<img
:src="item.image"
:alt="item.title"
loading="lazy"
decoding="async"
class="w-full h-full object-cover"
/>
<!-- 顶部标题色块(参考站风格) -->
<div class="absolute inset-x-4 top-6">
<div class="inline-flex rounded-md px-3 py-1 bg-black/60 text-white text-[13px] font-semibold">
{{ item.title }}
</div>
<div
v-if="item.subtitle"
class="mt-1 inline-flex rounded-md px-3 py-0.5 bg-black/50 text-white/90 text-[11px]"
>
{{ item.subtitle }}
</div>
</div>
<!-- 底部渐变 & 日期 -->
<div class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/75 via-black/30 to-transparent" />
<div class="absolute left-4 bottom-3 text-[11px] text-white/90">
<span v-if="item.date">{{ item.date }}</span>
</div>
</div>
<!-- 下方描述 -->
<div class="mt-2 text-[13px] text-[var(--customColor-text-9)] leading-snug line-clamp-2">
{{ item.description }}
</div>
</div>
</template>
<script setup lang="ts">
interface TravelArticleItem {
title: string
subtitle?: string
description: string
image: string
date?: string
}
defineProps<{
item: TravelArticleItem
}>()
</script>
<template>
<div class="relative">
<Swiper
v-if="enableSlide && dataList.length > slidesPerView"
class="travel-swiper"
:modules="swiperModules"
:slides-per-view="slidesPerView"
:slides-per-group="slidesPerView"
:space-between="16"
:loop="false"
@swiper="onSwiper"
>
<SwiperSlide
v-for="(item, index) in dataList"
:key="item.title || index"
>
<TravelArticleCard :item="item" />
</SwiperSlide>
</Swiper>
<div
v-else
class="flex gap-4"
>
<div
v-for="(item, index) in dataList"
:key="item.title || index"
class="w-[260px] flex-shrink-0"
>
<TravelArticleCard :item="item" />
</div>
</div>
<button
v-if="enableSlide && showPrev"
class="travel-arrow travel-arrow-prev"
type="button"
@click="handlePrev"
>
<IconLeft />
</button>
<button
v-if="enableSlide && showNext"
class="travel-arrow travel-arrow-next"
type="button"
@click="handleNext"
>
<IconRight />
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref, type PropType } from 'vue'
// @ts-ignore
import { Swiper, SwiperSlide } from 'swiper/vue'
// @ts-ignore
import { Navigation } from 'swiper/modules'
// @ts-ignore
import type { Swiper as SwiperType } from 'swiper'
// @ts-ignore
import 'swiper/css'
import { IconLeft, IconRight } from '@arco-design/web-vue/es/icon'
import TravelArticleCard from './TravelArticleCard.vue'
interface TravelArticleItem {
title: string
subtitle?: string
description: string
image: string
date?: string
}
const props = defineProps({
dataList: {
type: Array as PropType<TravelArticleItem[]>,
default: () => [],
},
enableSlide: {
type: Boolean,
default: true,
},
slidesPerView: {
type: Number,
default: 4,
},
})
const swiperModules = [Navigation]
const swiperRef = ref<SwiperType | null>(null)
const onSwiper = (swiper: SwiperType) => {
swiperRef.value = swiper
}
const showPrev = computed(() => {
if (!swiperRef.value) return false
return !swiperRef.value.isBeginning
})
const showNext = computed(() => {
if (!swiperRef.value) return false
return !swiperRef.value.isEnd
})
const handlePrev = () => {
swiperRef.value?.slidePrev()
}
const handleNext = () => {
swiperRef.value?.slideNext()
}
</script>
<style scoped>
.travel-swiper{
position: relative;
padding: 0 8px;
}
.travel-arrow{
width: 32px;
height: 32px;
border-radius: 9999px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
color: #1f2933;
top: 40%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
z-index: 10;
font-size: 18px;
line-height: 1;
border: none;
cursor: pointer;
}
.travel-arrow-prev{
left: -40px;
}
.travel-arrow-next{
right: -40px;
}
</style>
......@@ -121,8 +121,32 @@ export default {
recommond:'Recommend',
hotDestinations: 'Hot Destinations',
},
place:{
all:'Explore all'
place: {
all: 'Explore all',
activitiesCount: '{count} activities',
top10Title: '{city} Top 10 experiences',
hotAttractionsTitle: 'Top attractions in {city}',
productsTitle: 'Top tours & experiences in {city}',
tabFeatured: 'Featured experiences',
tabMonthly: 'This month\'s picks',
travelGuideTitle: '{city} travel guides & tips',
nearbyCitiesTitle: 'Popular cities near {city}',
moreDestinations: 'More destinations',
faqTitle: '{city} FAQ & travel tips',
faqSubtitle: 'Covering best travel time, budget planning, transport, food and itinerary tips.',
travelInfo: 'Travel info',
section: {
sightseeingTitle: 'Sightseeing tours',
sightseeingSubtitle: 'City highlights & signature spots',
halfdayTitle: 'Half / one-day trips',
halfdaySubtitle: 'Classic nearby day-trip routes',
hotelTitle: 'Hotels',
hotelSubtitle: 'Popular districts & convenient locations',
trafficTitle: 'Transport',
trafficSubtitle: 'Tickets & transfer services',
ticketTitle: 'Attraction tickets',
ticketSubtitle: 'Must‑see spots & fast entry',
},
},
currency: {
AUD: 'Australian Dollar',
......
......@@ -121,8 +121,32 @@ export default {
recommond:'Giới thiệu',
hotDestinations: 'Điểm đến nổi tiếng',
},
place:{
all:'Khám phá tất cả'
place: {
all: 'Khám phá tất cả',
activitiesCount: '{count} hoạt động',
top10Title: 'Top 10 trải nghiệm tại {city}',
hotAttractionsTitle: 'Điểm tham quan nổi bật ở {city}',
productsTitle: 'Hoạt động & trải nghiệm nổi bật tại {city}',
tabFeatured: 'Ưu đãi nổi bật',
tabMonthly: 'Gợi ý trong tháng',
travelGuideTitle: 'Cẩm nang du lịch & gợi ý lịch trình {city}',
nearbyCitiesTitle: 'Thành phố lân cận nổi bật gần {city}',
moreDestinations: 'Thêm điểm đến',
faqTitle: 'Câu hỏi thường gặp & thông tin hữu ích về {city}',
faqSubtitle: 'Bao gồm thời điểm du lịch lý tưởng, dự trù chi phí, đi lại, ẩm thực và gợi ý lịch trình.',
travelInfo: 'Thông tin du lịch',
section: {
sightseeingTitle: 'Tham quan ngắm cảnh',
sightseeingSubtitle: 'Điểm nổi bật trong thành phố & địa danh biểu tượng',
halfdayTitle: 'Tour nửa ngày / 1 ngày',
halfdaySubtitle: 'Hành trình trong ngày tới các điểm lân cận nổi bật',
hotelTitle: 'Khách sạn',
hotelSubtitle: 'Khu vực trung tâm & tiện di chuyển',
trafficTitle: 'Di chuyển',
trafficSubtitle: 'Vé tàu xe & dịch vụ đưa đón',
ticketTitle: 'Vé tham quan',
ticketSubtitle: 'Điểm đến phải ghé & ưu tiên vào cửa nhanh',
},
},
currency: {
AUD: 'Đô la Úc',
......
......@@ -121,8 +121,33 @@ export default {
recommond:'推荐',
hotDestinations: '热门目的地',
},
place:{
all:'探索全部'
place: {
all: '探索全部',
activitiesCount: '{count} 个活动',
top10Title: '{city} Top 10 旅游资讯',
hotAttractionsTitle: '{city} 热门景点',
productsTitle: '{city} 热门旅游商品与体验推荐',
tabFeatured: '精选商品',
tabMonthly: '本月推荐',
travelGuideTitle: '{city} 旅行攻略与玩法推荐',
nearbyCitiesTitle: '{city} 周边热门城市推荐',
moreDestinations: '更多目的地',
faqTitle: '{city} 常见问题 FAQ 与实用资讯',
faqSubtitle: '覆盖最佳出行时间、预算规划、交通、美食与行程安排等常见问题。',
travelInfo: '旅游资讯',
section: {
sightseeingTitle: '观光旅游',
sightseeingSubtitle: '城市景点 · 特色体验',
halfdayTitle: '半/一日游',
// 使用更具指向性的文案,强调「周边一日游 · 经典路线」
halfdaySubtitle: '周边一日游 · 经典路线',
hotelTitle: '酒店',
hotelSubtitle: '人气商圈 · 交通便利',
trafficTitle: '交通',
trafficSubtitle: '交通票券 · 接送服务',
ticketTitle: '景点门票',
ticketSubtitle: '热门景点 · 快速入场',
},
},
currency: {
AUD: '澳元',
......
......@@ -121,8 +121,32 @@ export default {
recommond:'推薦',
hotDestinations: '熱門目的地',
},
place:{
all:'探索全部',
place: {
all: '探索全部',
activitiesCount: '{count} 個活動',
top10Title: '{city} Top 10 旅遊資訊',
hotAttractionsTitle: '{city} 熱門景點',
productsTitle: '{city} 熱門旅遊商品與體驗推薦',
tabFeatured: '精選商品',
tabMonthly: '本月推薦',
travelGuideTitle: '{city} 旅遊攻略與玩法推薦',
nearbyCitiesTitle: '{city} 周邊熱門城市推薦',
moreDestinations: '更多目的地',
faqTitle: '{city} 常見問題 FAQ 與實用資訊',
faqSubtitle: '涵蓋最佳出行時間、預算規劃、交通、美食與行程安排等常見問題。',
travelInfo: '旅遊資訊',
section: {
sightseeingTitle: '觀光旅遊',
sightseeingSubtitle: '城市景點 · 特色體驗',
halfdayTitle: '半/一日遊',
halfdaySubtitle: '周邊一日遊 · 經典路線',
hotelTitle: '飯店/酒店',
hotelSubtitle: '人氣商圈 · 交通便利',
trafficTitle: '交通',
trafficSubtitle: '交通票券 · 接送服務',
ticketTitle: '景點門票',
ticketSubtitle: '熱門景點 · 快速入場',
},
},
currency: {
AUD: '澳幣',
......
<template>
<div class="overflow-y-auto">
<!-- 顶部工具栏 -->
<Headers />
<!-- 页面内容 -->
<main>
<router-view />
</main>
<!-- 底部 -->
<Footer />
<div
ref="scrollContainer"
>
<!-- 顶部工具栏 -->
<Headers />
<!-- 页面内容 -->
<main>
<router-view />
</main>
<!-- 底部 -->
<Footer />
<!-- 全局返回顶部按钮 -->
<button
v-if="showBackTop"
type="button"
class="fixed right-6 bottom-8 z-[1900] w-10 h-10 rounded-full bg-white shadow-lg flex items-center justify-center text-[var(--customPrimary-6)] hover:bg-[var(--customPrimary-1)] hover:text-white transition-colors"
@click="scrollToTop"
>
<v-icon
name="arrow-up"
class="text-2xl"
fill="solid"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import Headers from './components/Headers.vue'
import Footer from './components/Footer.vue'
const loading = ref(true)
const showBackTop = ref(false)
onMounted(() => {
setTimeout(()=>{
loading.value = false
},500)
const handleScroll = () => {
showBackTop.value = ( window.scrollY || document.documentElement.scrollTop || 0) > 200
}
window.addEventListener('scroll', handleScroll)
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
})
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
</script>
<style scoped lang="scss">
......
<template>
<a-dropdown trigger="click" position="bl">
<a-dropdown trigger="hover" position="bl">
<a-button type="text" class="!px-2">
<v-icon name="geolocation" class="text-[16px]" />
<span class="ml-1">{{ t('header.destinations') }}</span>
</a-button>
<template #content>
<div class="destination-panel" v-if="placeTree.length">
<div class="continent-list">
<div class="continent-item" :class="{ active: -1 === activeIndex }" @mouseenter="activeIndex = -1">
{{ t('header.recommond') }}
</div>
<div
v-for="(continent, index) in placeTree"
:key="continent.value || index"
class="continent-item"
:class="{ active: index === activeIndex }"
@mouseenter="activeIndex = index"
>
{{ continent.label }}
</div>
</div>
<div v-show="activeIndex === -1" class="p-4 flex-1 min-w-0 overflow-y-auto">
<div class="font-bold text-lg">
{{ t('header.hotDestinations') }}
</div>
<div class="pt-4 mb-2" v-for="(val,index) in hotDestinations" :key="index">
<div class="font-bold text-md mb-3">{{ val.name }}</div>
<div class="grid grid-cols-4 gap-4">
<div @click="handleHotDestinationClick(item.placeId)" class="rounded-md flex items-center justify-center cursor-pointer overlap relative overflow-hidden !aspect-[3/2] !object-cover" v-for="(item,index) in val.reList" :key="index">
<a-image :src="item.placeDetail?.backgroundImage || ''" show-loader></a-image>
<div class="absolute bottom-0 left-0 right-0 p-2">
<div class="text-white text-sm font-bold">
{{ item.placeDetail?.name }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="continent-content" v-if="activeContinent && activeIndex !== -1">
<template v-for="country in activeContinent.children" :key="country.value">
<div class="country-block">
<div class="country-title">
{{ country.label }}
</div>
<div class="place-grid">
<span class="place-item destination-place-item rounded-md">
{{ t('place.all') }}
</span>
<span
v-for="place in country.children"
:key="place.value"
@click="handleHotDestinationClick(place.value)"
class="place-item destination-place-item rounded-md"
>
{{ place.label }}
</span>
</div>
</div>
</template>
</div>
</div>
<div v-else class="destination-empty">
<a-spin size="small" />
<div>
<place-tree />
</div>
</template>
</a-dropdown>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PlaceService, { type PlaceGroupOutputDto, } from '@/services/PlaceService'
import type { PlaceTreeNode } from '@/types/place'
import { Message } from '@arco-design/web-vue'
import { useSystemConfigStore } from '@/stores'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const placeTree = ref<PlaceTreeNode[]>([])
const activeIndex = ref(-1)
const router = useRouter()
const activeContinent = computed(() => placeTree.value[activeIndex.value])
const systemConfigStore = useSystemConfigStore()
const hotDestinations = ref<PlaceGroupOutputDto[]>([])
const handleHotDestinationClick = (val: string) => {
router.push(`/place/${val}`)
}
const loadPlaces = async () => {
try {
const data = await PlaceService.getPlaceTreeAsync()
placeTree.value = data || []
activeIndex.value = -1
console.log(data)
} catch (error) {
Message.error(t('header.destinationsFailed'))
}
}
const loadHotDestinations = async () => {
try {
const data = await PlaceService.getRecommendPlaceListAsync(systemConfigStore.distributorId || 0)
hotDestinations.value = data || []
} catch (error) {
Message.error(t('header.hotDestinationsFailed'))
}
}
import PlaceTree from '@/components/place/PlaceTree.vue';
import { useI18n } from 'vue-i18n';
onMounted(() => {
loadPlaces()
loadHotDestinations()
})
const {t} = useI18n()
</script>
<style scoped lang="scss">
.destination-trigger{
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 999px;
background-color: #fff;
cursor: pointer;
font-weight: 600;
transition: all .2s ease;
}
.destination-trigger:hover{
border-color: rgb(var(--arcoblue-6));
color: rgb(var(--arcoblue-6));
}
.destination-panel{
display: flex;
width: 720px;
height: 320px;
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
overflow: hidden;
}
.continent-list{
width: 160px;
background: #f7f8fa;
padding: 12px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.continent-item{
padding: 15px 18px;
cursor: pointer;
font-weight: 500;
color: rgb(var(--customColor-text-7));
transition: all .2s ease;
}
.continent-item.active,
.continent-item:hover{
background: #fff;
color: rgb(var(--arcoblue-6));
}
.continent-content{
flex: 1;
padding: 18px 24px;
overflow: auto;
}
.country-block + .country-block{
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgb(var(--arcoblue-7));
}
.country-title{
font-weight: 600;
margin-bottom: 12px;
color: rgb(var(--customColor-text-10));
}
.place-grid{
display: grid;
grid-template-columns: repeat(4 , minmax(120px, 1fr));
gap: 12px 12px;
}
.place-item{
font-size: 13px;
cursor: pointer;
padding: 10px 10px;
}
// 目的地项使用动态配色
.destination-place-item {
color: rgb(var(--customColor-text-7));
transition: all 0.2s ease;
&:hover {
background-color: rgba(var(--arcoblue-6), 0.1);
color: rgb(var(--arcoblue-6));
}
}
.destination-empty{
width: 240px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
</style>
<template>
<div class="w-[1200px] mx-2 py-2 flex items-center justify-between h-[60px] text-xs text-gray-700">
<div class="w-[1200px] mx-2 py-2 flex items-center justify-between h-[60px] text-xs text-[var(--customColor-text-10)]">
<div class="flex items-center gap-4">
<img
v-if="logo"
:src="logo"
alt="logo"
class="h-[40px] cursor-pointer mr-3"
@click="handleGoHome"
v-if="logo"
:src="logo"
class="h-6 cursor-pointer mr-3 w-auto"
@click="handleGoHome"
/>
<Search v-if="shouldShowSearch" />
</div>
<div class="flex items-center gap-[8px]">
<LanguageSwitcher />
<a-dropdown position="br" trigger="click">
<a-button size="small" type="text" class="px-1 !rounded-md">
<span class="text-gray-800">{{ selectedCurrencyCode }}</span>
<a-button size="small" type="text" class="px-1 !rounded-md text-[var(--customColor-text-10)]">
<span class="text-[var(--customColor-text-10)]">{{ selectedCurrencyCode }}</span>
<icon-down class="ml-[4px] text-[12px]" />
</a-button>
<template #content>
......@@ -37,7 +36,7 @@
v-if="!isLoggedIn"
type="text"
size="small"
class="loginButton flex-1 font-bold text-gray-200 !rounded-[4px] !text-sm ml-[6px]"
class="loginButton flex-1 font-bold !text-[var(--customPrimary-6)] !rounded-[4px] !text-sm ml-[6px]"
@click="handleGoLogin"
>
{{ t('login.loginButton') }}/{{ t('login.register') }}
......@@ -198,7 +197,7 @@ const handleGoHome = () => {
align-items: center;
gap: 6px;
font-size: 14px;
color: #333;
color: var(--customColor-text-10);
cursor: pointer;
border-radius: 6px;
padding: 6px 8px;
......@@ -208,16 +207,16 @@ const handleGoHome = () => {
font-weight: 600;
}
.currency-option .name{
color: #666;
color: var(--customColor-text-7);
font-size: 13px;
}
.currency-option:hover{
background: rgba(var(--arcoblue-6), 0.1);
color: rgb(var(--arcoblue-6));
background: rgba(var(--customPrimary-6), 0.12);
color: var(--customPrimary-6);
}
.currency-option.active{
background: rgba(var(--arcoblue-6), 0.15);
color: rgb(var(--arcoblue-6));
background: rgba(var(--customPrimary-6), 0.18);
color: var(--customPrimary-6);
font-weight: 600;
}
</style>
......
<template>
<header class="app-header flex flex-col items-center pt-[60px]">
<div class="fixed top-0 left-0 right-0 z-20 shadow-sm customPrimary-bg-7 flex justify-center">
<div
class="fixed top-0 left-0 right-0 z-20 shadow-sm flex justify-center"
:style="headerStyle"
>
<HeaderTopBar />
</div>
<div class="header-divider"></div>
......@@ -11,7 +14,7 @@
import HeaderTopBar from './HeaderTopBar.vue'
import HeaderNavBar from './HeaderNavBar.vue'
import { useRoute } from 'vue-router';
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
const route = useRoute()
const showNavBar = ref(false)
......@@ -21,6 +24,18 @@ watch(()=>route.path, () => {
showNavBar.value = needNavs.value.includes(route.path)
})
showNavBar.value = needNavs.value.includes(route.path)
// 顶部背景:使用动态主题色并添加轻微模糊与渐变,避免沉闷
const headerStyle = computed(() => ({
background: `
linear-gradient(
180deg,
rgba(var(--arcoblue-11), 0.8) 0%,
rgba(var(--arcoblue-7), 0.55) 100%
)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
}))
</script>
<style scoped lang="scss">
......
......@@ -25,5 +25,6 @@ export const createPermissionGuard = (
return
}
console.log(to.path)
next()
}
......@@ -93,7 +93,7 @@ const router = createRouter({
path: '/place/:id',
name: 'placeDetail',
meta: { title: 'page.placeDetail' },
component: () => import ('../views/place/Detail.vue')
component: () => import ('../views/place/Place.vue')
},
]
},
......
This diff is collapsed.
///
import OtaRequest from '@/api/OtaRequest'
export enum HotPoiTypeEnum {
PLACE = 0,
POI = 1
}
export interface HotPoiDto{
id:string,
name:string,
backgroundImage:string,
activitiesCount:number,
type:HotPoiTypeEnum
}
class PoiService{
static async getHotPoisByPlaceIdAsync(placeId: string,top:number = 20): Promise<HotPoiDto[]> {
const response = await OtaRequest.get(
`sys-management/poi-services/pois-by-place/${placeId}?top=${top}`
)
return response as unknown as HotPoiDto[]
}
}
export default PoiService
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment