Commit c61848ff authored by 罗超's avatar 罗超

目的地与分类二级页面开发

parent 0ae117a8
<template>
<div>
<!-- 单个卡片模式(向后兼容) -->
<a-card
v-if="!hasItems"
class="category-card cursor-pointer transition-all duration-300"
:hoverable="true"
:bordered="false"
@click="handleClickSingle"
>
<div class="category-card-content flex flex-col items-center text-center">
<div
v-if="icon"
class="category-icon mb-3"
:style="{ color: iconColor || 'var(--customPrimary-6)' }"
>
<img class="w-2/5 h-auto" :src="icon" />
</div>
<div class="category-name text-[var(--customColor-text-10)] font-semibold mb-1">
{{ name }}
</div>
</div>
</a-card>
<!-- 批量渲染:grid 模式 -->
<div v-else-if="mode === 'grid'">
<a-grid :cols="computedGridCols" :col-gap="16" :row-gap="16" class="categories-grid">
<a-grid-item v-for="item in items" :key="item.id">
<a-card
class="category-card cursor-pointer transition-all duration-300"
:hoverable="true"
:bordered="false"
@click="handleClickItem(item)"
>
<div class="category-card-content flex flex-col items-center text-center">
<div v-if="item.icon" class="category-icon mb-3" :style="{ color: item.iconColor || 'var(--customPrimary-6)' }">
<img class="w-2/5 h-auto" :src="item.icon" />
</div>
<div class="text-[var(--customColor-text-10)] mb-1">
{{ item.name }}
</div>
</div>
</a-card>
</a-grid-item>
</a-grid>
</div>
<!-- 批量渲染:swiper 模式 -->
<div v-else-if="mode === 'swiper'" class="relative">
<Swiper
v-if="items && items.length > 0"
class="category-swiper"
:modules="swiperModules"
:slides-per-view="baseSlidesPerView"
:breakpoints="swiperBreakpointsForSwiper"
:space-between="12"
:loop="false"
@swiper="onSwiper"
>
<SwiperSlide v-for="item in items" :key="item.id">
<div class="px-2">
<a-card
class="category-card cursor-pointer transition-all duration-300"
:hoverable="true"
:bordered="false"
@click="handleClickItem(item)"
>
<div class="category-card-content flex flex-col items-center text-center">
<div v-if="item.icon" class="category-icon mb-3" :style="{ color: item.iconColor || 'var(--customPrimary-6)' }">
<img class="w-2/5 h-auto" :src="item.icon" />
</div>
<div class="text-[var(--customColor-text-10)] mb-1">
{{ item.name }}
</div>
</div>
</a-card>
</div>
</SwiperSlide>
</Swiper>
<button v-if="showPrev" class="category-arrow category-arrow-prev" type="button" @click="handlePrev">
<IconLeft />
</button>
<button v-if="showNext" class="category-arrow category-arrow-next" type="button" @click="handleNext">
<IconRight />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { computed, ref, type PropType, onMounted, onBeforeUnmount } from 'vue'
// swiper
// @ts-ignore
import { Swiper, SwiperSlide } from 'swiper/vue'
// @ts-ignore
import { Navigation } from 'swiper/modules'
// @ts-ignore
import type { Swiper as SwiperType } from 'swiper'
import { IconLeft, IconRight } from '@arco-design/web-vue/es/icon'
import { defaultCategorySwiperBreakpoints, type CategoryDisplayMode, type CategoryItem as ServiceCategoryItem } from '@/services/ProductCategoryService'
interface LocalCategoryItem extends ServiceCategoryItem {
iconColor?: string
link?: string
}
const props = defineProps({
// 批量渲染的数据(如果传入 items,则进入批量渲染分支)
items: {
type: Array as PropType<LocalCategoryItem[]>,
default: () => [],
},
// 渲染模式:'grid' | 'swiper'
mode: {
type: String as PropType<CategoryDisplayMode>,
default: 'grid',
},
// 单个卡片的兼容属性
name: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
iconColor: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
link: {
type: String,
default: '',
},
categoryId: {
type: String,
default: '',
},
// 可选覆盖默认断点
swiperBreakpoints: {
type: Object as PropType<Record<number, number>>,
default: () => defaultCategorySwiperBreakpoints,
},
})
const router = useRouter()
// 计算当前视口下 grid 模式的列数(基于 swiperBreakpoints 的断点 -> 列数映射)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1200)
const handleResize = () => {
viewportWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
const computedGridCols = computed(() => {
const bps = props.swiperBreakpoints || defaultCategorySwiperBreakpoints
const keys = Object.keys(bps).map(k => parseInt(k, 10)).filter(n => !Number.isNaN(n)).sort((a, b) => a - b)
if (keys.length === 0) return 2
let cols = (bps[keys[0]] ?? 2)
for (const k of keys) {
if (viewportWidth.value >= k) {
cols = (bps as any)[k] ?? cols
} else {
break
}
}
return cols
})
const hasItems = computed(() => Array.isArray(props.items) && props.items.length > 0)
const handleClickSingle = () => {
if (props.link) {
router.push(props.link)
} else if (props.categoryId) {
router.push(`/categories/${props.categoryId}`)
}
}
const handleClickItem = (item: LocalCategoryItem) => {
if (item.link) {
router.push(item.link)
} else if (item.id) {
router.push(`/categories/${item.id}`)
}
}
// Swiper 控制
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()
// 将 props.swiperBreakpoints 转换为 Swiper 的 breakpoints 配置
const swiperBreakpointsForSwiper = computed(() => {
const bps = (props.swiperBreakpoints || defaultCategorySwiperBreakpoints) as Record<string, number>
const result: Record<number, { slidesPerView: number }> = {}
Object.keys(bps).forEach((k) => {
const key = parseInt(k, 10)
const slides = bps[k]
if (!Number.isNaN(key) && slides) {
result[key] = { slidesPerView: slides }
}
})
return result
})
// 基础 slidesPerView(当没有命中 breakpoint 时的默认值)
const baseSlidesPerView = computed(() => {
const bps = props.swiperBreakpoints || defaultCategorySwiperBreakpoints
// 使用最小断点(key 0)或第一个可用值作为基础值
if ((bps as any)[0]) return (bps as any)[0]
const keys = Object.keys(bps).map(k => parseInt(k, 10)).sort((a, b) => a - b)
return (bps as any)[keys[0]] ?? 2
})
</script>
<style scoped>
.category-card {
background: var(--customPrimary-10, #F5F6F0);
border-radius: 12px;
padding: 0px;
display: flex;
align-items: center;
justify-content: center;
height: 120px;
}
.category-card:hover {
background: var(--customPrimary-7, #e3e6da);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.category-card-content {
width: 100%;
}
.category-icon {
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.category-card:hover .category-icon {
transform: scale(1.1);
}
.category-description {
line-height: 1.5;
min-height: 36px;
}
.category-swiper {
position: relative;
}
.category-arrow {
width: 32px;
height: 32px;
border-radius: 9999px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
color: #1f2933;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
position: absolute;
z-index: 10;
font-size: 18px;
line-height: 1;
border: none;
cursor: pointer;
}
.category-arrow-prev {
left: -40px;
}
.category-arrow-next {
right: -40px;
}
</style>
<template>
<div
class="destination-card relative cursor-pointer overflow-hidden rounded-xl"
@click="handleClick"
>
<img
:src="image"
:alt="name"
loading="lazy"
decoding="async"
class="w-full h-full object-cover transition-transform duration-300"
/>
<div class="destination-mask"></div>
<div class="destination-content">
<div class="destination-name text-white font-semibold mb-1">
{{ name }}
</div>
<div
v-if="count !== undefined"
class="destination-count text-white/90 text-sm"
>
{{ count }}+ 行程
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
interface Props {
name: string
image: string
count?: number
destinationId?: string
link?: string
}
const props = defineProps<Props>()
const router = useRouter()
const handleClick = () => {
if (props.link) {
router.push(props.link)
} else if (props.destinationId) {
router.push(`/place/${props.destinationId}`)
}
}
</script>
<style scoped>
.destination-card {
width: 200px;
height: 200px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.destination-card {
width: 160px;
height: 160px;
}
}
.destination-card:hover img {
transform: scale(1.05);
}
.destination-mask {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.05) 0%,
rgba(0, 0, 0, 0.3) 50%,
rgba(0, 0, 0, 0.7) 100%
);
}
.destination-content {
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
z-index: 1;
}
.destination-name {
font-size: 16px;
line-height: 1.3;
margin-bottom: 4px;
}
.destination-count {
font-size: 13px;
opacity: 0.95;
}
</style>
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
<!-- 对话框主体 --> <!-- 对话框主体 -->
<div <div
class="relative !inline-block z-10 w-auto max-w-[1200px] mx-4" class="relative !inline-block z-10 w-auto max-w-7xl mx-4"
@click.stop @click.stop
> >
<div class="bg-white rounded-2xl shadow-xl overflow-hidden"> <div class="bg-white rounded-2xl shadow-xl overflow-hidden">
......
<template>
<div class="place-flat-panel">
<div v-if="countries.length" class="place-flat-body">
<div class="px-3 py-2 bg-gray-100" v-if="props.country || props.place">
<div class="text-sm text-gray-500">
{{ t('place.current.welcome') }}
<span class="font-semibold text-black mx-1">{{ props.place?.name||props.country?.name || '' }}</span>
{{ t('place.current.more') }}
<span class="font-semibold text-black cursor-pointer mx-1" @click="handleSelectAll(null)">{{ t('place.global')
}}</span>
{{ t('place.current.activity') }}
</div>
</div>
<div class="px-3 py-2">
<div v-for="country in countries" :key="country.id" class="country-block">
<div class="country-title">
{{ country.name }}
</div>
<div class="place-grid">
<!-- 所有城市 -->
<span class="place-item destination-place-item rounded-md" @click="handleSelectAll(country)">
{{ t('place.all') }}
</span>
<!-- 具体城市 -->
<span v-for="place in country.places" :key="place.value"
class="place-item destination-place-item rounded-md" @click="handleSelectPlace(country, place)">
{{ place.label }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="place-flat-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 from '@/services/PlaceService'
import type { PlaceTreeNode } from '@/types/place'
interface SelectResult {
id: string,
name: string
}
const props = withDefaults(defineProps<{
country: { id: string, name: string } | null
place: { id: string, name: string } | null
}>(), {
country: null,
place: null
})
const emit = defineEmits<{
/**
* 目的地变更
* @param country 国家
* @param place 城市/目的地
*/
(e: 'change', country: SelectResult | null, place: SelectResult | null): void
}>()
const { t } = useI18n()
const placeTree = ref<PlaceTreeNode[]>([])
interface FlatCountry {
id: string
name: string
places: PlaceTreeNode[]
}
// 从树形结构中提取所有国家与目的地
const countries = computed<FlatCountry[]>(() => {
const result: FlatCountry[] = []
for (const continent of placeTree.value) {
const children = continent.children || []
for (const country of children) {
result.push({
id: country.value,
name: country.label,
places: country.children || [],
})
}
}
return result
})
const loadPlaces = async () => {
try {
const data = await PlaceService.getPlaceTreeAsync(true)
placeTree.value = data || []
} catch (error) {
// 静默失败,外层可根据空状态展示 loading / error
console.error('加载目的地失败:', error)
}
}
const handleSelectAll = (country: any) => {
if (country) {
emit('change', { id: country.id, name: country.name }, { id: '', name: '' })
} else {
emit('change', null, null)
}
}
const handleSelectPlace = (country: any, place: any) => {
emit('change', { id: country.id, name: country.name }, { id: place.value, name: place.label })
}
onMounted(() => {
loadPlaces()
})
</script>
<style scoped lang="scss">
.place-flat-panel {
width: 564px;
height: 368px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
overflow: hidden;
display: flex;
}
.place-flat-body {
flex: 1;
overflow: auto;
}
.country-block+.country-block {
margin-top: 16px;
padding-top: 16px;
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(100px, 1fr));
gap: 8px 16px;
}
.place-item {
font-size: 13px;
cursor: pointer;
padding: 6px 4px;
}
// 与 PlaceTree 保持一致的动态主题交互样式
.destination-place-item {
color: rgb(var(--customColor-text-7));
transition: all 0.2s ease;
&:hover {
background-color: rgba(var(--arcoblue-6), 0.08);
color: rgb(var(--arcoblue-6));
}
}
.place-flat-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
</style>
...@@ -62,7 +62,7 @@ ...@@ -62,7 +62,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface ProductCardItem { export interface ProductCardItem {
title: string title: string
image: string image: string
tag?: string tag?: string
......
...@@ -10,6 +10,25 @@ export default { ...@@ -10,6 +10,25 @@ export default {
hotSearch: 'Hot Searches', hotSearch: 'Hot Searches',
hotDestination: 'Popular Destinations', hotDestination: 'Popular Destinations',
}, },
travel: {
categories: {
title: 'Travel Categories',
},
destinations: {
title: 'Popular Destinations',
viewAll: 'View All',
},
products: {
title: 'Featured Experiences',
viewAll: 'View All',
},
explore: {
title: 'Explore More',
description: 'Discover more amazing destinations and travel experiences',
allDestinations: 'View All Destinations',
allProducts: 'View All Products',
},
},
login: { login: {
// Page titles // Page titles
title: 'Tten Joy', title: 'Tten Joy',
...@@ -121,8 +140,19 @@ export default { ...@@ -121,8 +140,19 @@ export default {
recommond:'Recommend', recommond:'Recommend',
hotDestinations: 'Hot Destinations', hotDestinations: 'Hot Destinations',
}, },
caterogy: {
tour: 'Tour & Experience',
toursub: 'Explore global hot destinations and selected travel experiences',
recommendTitle:'Must-see'
},
place: { place: {
all: 'Explore all', all: 'Explore all',
global:'Global',
current: {
welcome: 'You are currently viewing',
more: 'activities, explore more destinations or',
activity: 'activities.',
},
activitiesCount: '{count} activities', activitiesCount: '{count} activities',
top10Title: '{city} Top 10 experiences', top10Title: '{city} Top 10 experiences',
hotAttractionsTitle: 'Top attractions in {city}', hotAttractionsTitle: 'Top attractions in {city}',
......
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
"distributionCenter": "Distribution Center", "distributionCenter": "Distribution Center",
"resetPassword": "Reset Password", "resetPassword": "Reset Password",
"editEmail": "Bind/Edit Email", "editEmail": "Bind/Edit Email",
"placeDetail": "Place Detail" "placeDetail": "Place Detail",
"travel": "Travel & Experiences"
} }
} }
\ No newline at end of file
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
"distributionCenter": "Trung tâm phân phối", "distributionCenter": "Trung tâm phân phối",
"resetPassword": "Đặt lại mật khẩu", "resetPassword": "Đặt lại mật khẩu",
"editEmail": "Liên kết/Sửa đổi email", "editEmail": "Liên kết/Sửa đổi email",
"placeDetail": "Chi tiết địa điểm" "placeDetail": "Chi tiết địa điểm",
"travel": "Du lịch & Trải nghiệm"
} }
} }
\ No newline at end of file
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
"account": "账户信息", "account": "账户信息",
"passengerList": "常用旅客", "passengerList": "常用旅客",
"mailingAddressList": "邮寄地址", "mailingAddressList": "邮寄地址",
"placeDetail": "目的地详情" "placeDetail": "目的地详情",
"travel": "行程与体验"
} }
} }
\ No newline at end of file
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
"distributionCenter": "分銷中心", "distributionCenter": "分銷中心",
"resetPassword": "重設密碼", "resetPassword": "重設密碼",
"editEmail": "綁定/修改郵箱", "editEmail": "綁定/修改郵箱",
"placeDetail": "目的地詳情" "placeDetail": "目的地詳情",
"travel": "行程與體驗"
} }
} }
\ No newline at end of file
...@@ -10,6 +10,25 @@ export default { ...@@ -10,6 +10,25 @@ export default {
hotSearch: 'Tìm kiếm nổi bật', hotSearch: 'Tìm kiếm nổi bật',
hotDestination: 'Điểm đến nổi bật', hotDestination: 'Điểm đến nổi bật',
}, },
travel: {
categories: {
title: 'Danh mục du lịch',
},
destinations: {
title: 'Điểm đến phổ biến',
viewAll: 'Xem tất cả',
},
products: {
title: 'Trải nghiệm nổi bật',
viewAll: 'Xem tất cả',
},
explore: {
title: 'Khám phá thêm',
description: 'Khám phá thêm nhiều điểm đến và trải nghiệm du lịch tuyệt vời',
allDestinations: 'Xem tất cả điểm đến',
allProducts: 'Xem tất cả sản phẩm',
},
},
login: { login: {
// Tiêu đề trang // Tiêu đề trang
title: 'Tten Joy', title: 'Tten Joy',
...@@ -121,8 +140,19 @@ export default { ...@@ -121,8 +140,19 @@ export default {
recommond:'Giới thiệu', recommond:'Giới thiệu',
hotDestinations: 'Điểm đến nổi tiếng', hotDestinations: 'Điểm đến nổi tiếng',
}, },
caterogy: {
tour: 'Tour & Experience',
toursub: 'Khám phá điểm đến và trải nghiệm du lịch nổi bật',
recommendTitle:'Không thể bỏ qua'
},
place: { place: {
all: 'Khám phá tất cả', all: 'Khám phá tất cả',
global:'Toàn cầu',
current: {
welcome: 'Bạn đang xem',
more: 'hoạt động, khám phá thêm điểm đến hoặc',
activity: 'hoạt động.',
},
activitiesCount: '{count} hoạt động', activitiesCount: '{count} hoạt động',
top10Title: 'Top 10 trải nghiệm tại {city}', top10Title: 'Top 10 trải nghiệm tại {city}',
hotAttractionsTitle: 'Điểm tham quan nổi bật ở {city}', hotAttractionsTitle: 'Điểm tham quan nổi bật ở {city}',
......
...@@ -10,6 +10,25 @@ export default { ...@@ -10,6 +10,25 @@ export default {
hotSearch: '热门搜索', hotSearch: '热门搜索',
hotDestination: '热门目的地', hotDestination: '热门目的地',
}, },
travel: {
categories: {
title: '行程分类',
},
destinations: {
title: '热门目的地',
viewAll: '查看全部',
},
products: {
title: '热门行程推荐',
viewAll: '查看全部',
},
explore: {
title: '探索更多',
description: '发现更多精彩目的地和行程体验',
allDestinations: '查看全部目的地',
allProducts: '查看全部行程',
},
},
login: { login: {
// 页面标题 // 页面标题
title: 'Tten Joy', title: 'Tten Joy',
...@@ -121,8 +140,19 @@ export default { ...@@ -121,8 +140,19 @@ export default {
recommond:'推荐', recommond:'推荐',
hotDestinations: '热门目的地', hotDestinations: '热门目的地',
}, },
caterogy: {
tour: '游览 & 体验',
toursub: '探索全球热门目的地和精选旅行体验',
recommendTitle:'不能錯過的'
},
place: { place: {
all: '探索全部', all: '探索全部',
global:'全球',
current: {
welcome: '您正在查看',
more: '的活動, 探索以下更多目的地或',
activity: '的活動。',
},
activitiesCount: '{count} 个活动', activitiesCount: '{count} 个活动',
top10Title: '{city} Top 10 旅游资讯', top10Title: '{city} Top 10 旅游资讯',
hotAttractionsTitle: '{city} 热门景点', hotAttractionsTitle: '{city} 热门景点',
......
...@@ -10,6 +10,25 @@ export default { ...@@ -10,6 +10,25 @@ export default {
hotSearch: '熱門搜尋', hotSearch: '熱門搜尋',
hotDestination: '熱門目的地', hotDestination: '熱門目的地',
}, },
travel: {
categories: {
title: '行程分類',
},
destinations: {
title: '熱門目的地',
viewAll: '查看全部',
},
products: {
title: '熱門行程推薦',
viewAll: '查看全部',
},
explore: {
title: '探索更多',
description: '發現更多精彩目的地和行程體驗',
allDestinations: '查看全部目的地',
allProducts: '查看全部行程',
},
},
login: { login: {
// 頁面標題 // 頁面標題
title: 'Tten Joy', title: 'Tten Joy',
...@@ -121,8 +140,19 @@ export default { ...@@ -121,8 +140,19 @@ export default {
recommond:'推薦', recommond:'推薦',
hotDestinations: '熱門目的地', hotDestinations: '熱門目的地',
}, },
caterogy: {
tour: '游览 & 体验',
toursub: '探索全球熱門目的地和精選旅行體驗',
recommendTitle:'不能錯過的'
},
place: { place: {
all: '探索全部', all: '探索全部',
global:'全球',
current: {
welcome: '您正在查看',
more: '的活動, 探索以下更多目的地或',
activity: '的活動。',
},
activitiesCount: '{count} 個活動', activitiesCount: '{count} 個活動',
top10Title: '{city} Top 10 旅遊資訊', top10Title: '{city} Top 10 旅遊資訊',
hotAttractionsTitle: '{city} 熱門景點', hotAttractionsTitle: '{city} 熱門景點',
......
...@@ -250,7 +250,7 @@ const scrollToTop = () => { ...@@ -250,7 +250,7 @@ const scrollToTop = () => {
} }
.footer-container { .footer-container {
max-width: 1200px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 0 20px; padding: 0 20px;
} }
......
<template> <template>
<div class="bg-white w-full flex justify-center"> <div class="bg-white w-full flex justify-center">
<div class="w-[1200px] flex items-center"> <div class="w-[1280px] flex items-center">
<HeaderDestinationMenu /> <HeaderDestinationMenu />
<a-menu <a-menu
mode="horizontal" mode="horizontal"
...@@ -34,6 +34,7 @@ ...@@ -34,6 +34,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useSystemConfigStore } from '@/stores/index' import { useSystemConfigStore } from '@/stores/index'
import type { NavItemDto } from '@/types/systemConfig' import type { NavItemDto } from '@/types/systemConfig'
...@@ -43,7 +44,40 @@ const { t } = useI18n() ...@@ -43,7 +44,40 @@ const { t } = useI18n()
const systemConfigStore = useSystemConfigStore() const systemConfigStore = useSystemConfigStore()
const activeKey = ref<string[]>([]) const activeKey = ref<string[]>([])
const navs = computed(() => systemConfigStore.getTopNavs) const navs = computed(() => systemConfigStore.getTopNavs)
const router = useRouter()
// 扁平化导航:把嵌套的 nav 结构展开为数组,方便根据 id/key 查询
const flattenNavs = (items: NavItemDto[] = []) : NavItemDto[] => {
const out: NavItemDto[] = []
const walk = (list: NavItemDto[] = []) => {
for (const it of list) {
out.push(it)
if (it.childList && it.childList.length) {
walk(it.childList)
}
}
}
walk(items)
return out
}
const flatNavs = computed(() => flattenNavs(navs.value ?? []))
const navMap = computed(() => {
const m = new Map<string | number, NavItemDto>()
for (const n of flatNavs.value) {
if (n.id != null) m.set(n.id as string, n)
if ((n as any).navKey) m.set((n as any).navKey as string, n)
}
return m
})
// 根据任意 key(id 或 navKey 或 navUrl)查找菜单项
const findByKey = (key: string) : NavItemDto | null => {
if (!key) return null
const byId = navMap.value.get(key)
if (byId) return byId
return flatNavs.value.find(n => n.navUrl === key) ?? null
}
const findMatch = (items: NavItemDto[] = [], path: string): NavItemDto | null => { const findMatch = (items: NavItemDto[] = [], path: string): NavItemDto | null => {
for (const item of items) { for (const item of items) {
...@@ -58,13 +92,13 @@ const findMatch = (items: NavItemDto[] = [], path: string): NavItemDto | null => ...@@ -58,13 +92,13 @@ const findMatch = (items: NavItemDto[] = [], path: string): NavItemDto | null =>
const handleMenuClick = (key: string) => { const handleMenuClick = (key: string) => {
activeKey.value = [key] activeKey.value = [key]
// const target = navMap.value.get(key) const target = findByKey(key)
// if (!target || !target.navUrl) return if (!target || !target.navUrl) return
// if (target.isNewOpen) { if ((target as any).isNewOpen) {
// window.open(target.navUrl, '_blank') window.open(target.navUrl, '_blank')
// return return
// } }
// router.push(target.navUrl) router.push(target.navUrl)
} }
</script> </script>
......
<template> <template>
<div class="w-[1200px] mx-2 py-2 flex items-center justify-between h-[60px] text-xs text-[var(--customColor-text-10)]"> <div class="w-[1280px] 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"> <div class="flex items-center gap-4">
<img <img
v-if="logo" v-if="logo"
......
...@@ -95,6 +95,18 @@ const router = createRouter({ ...@@ -95,6 +95,18 @@ const router = createRouter({
meta: { title: 'page.placeDetail' }, meta: { title: 'page.placeDetail' },
component: () => import ('../views/place/Place.vue') component: () => import ('../views/place/Place.vue')
}, },
{
path: '/categories/travel',
name: 'travel',
meta: { title: 'page.travel' },
component: () => import ('../views/categories/Travel.vue')
},
{
path: '/categories/list',
name: 'tour_list',
meta: { title: 'page.tour_list' },
component: () => import ('../views/categories/List.vue')
}
] ]
}, },
{ {
......
...@@ -134,7 +134,16 @@ class PlaceService { ...@@ -134,7 +134,16 @@ class PlaceService {
} }
static async getPlaceChildrenAsync(parentId: string): Promise<PlaceChildrenOutputDto[]> { static async getPlaceChildrenAsync(parentId: string): Promise<PlaceChildrenOutputDto[]> {
const response = await OtaRequest.get(`/sys-management/place-services/${parentId}/child-place-flat`) const response = await OtaRequest.get(`/sys-management/place-services/${parentId}/child-place-flat?isRecommend=${recommend}`)
return response as unknown as PlaceChildrenOutputDto[]
}
static async getRecommendByCategoryAsync(top:number,countryId?: string,placeId?: string,categoryId?:string): Promise<PlaceChildrenOutputDto[]> {
let url = `/sys-management/place-services/recommend-place-async-by-catetory?top=${top}`
if(countryId && countryId!='') url += `&countryId=${countryId}`
if(placeId && placeId!='') url += `&placeId=${placeId}`
if(categoryId && categoryId!='') url += `&countryId=${categoryId}`
const response = await OtaRequest.get(url)
return response as unknown as PlaceChildrenOutputDto[] return response as unknown as PlaceChildrenOutputDto[]
} }
......
...@@ -9,7 +9,54 @@ export interface ProductCategoryListOutDto { ...@@ -9,7 +9,54 @@ export interface ProductCategoryListOutDto {
name?: null | string; name?: null | string;
parentId?: null | string; parentId?: null | string;
sort?: number; sort?: number;
} }
export interface CategoryItem {
categoryChildren?: CategoryItem[] | null;
/**
* 封面图
*/
coverImage?: null | string;
description?: null | string;
/**
* 展示渠道:0=全部,1=PC,2=H5,3=PC+H5
*/
displayChannel?: number;
/**
* 小图标
*/
icon?: null | string;
id?: string;
isEnabled?: number;
/**
* 是否叶子分类(仅叶子分类下允许挂商品)
*/
isLeaf?: boolean;
/**
* 是否系统内置属性(=1的话不能删除)
*/
isSystem?: number;
level?: number;
name?: null | string;
parentId?: null | string;
/**
* 产品类型枚举
*/
productTypeDisplay?: null | string;
sort?: number;
}
// Display mode for category listing components
export type CategoryDisplayMode = 'grid' | 'swiper';
// Default responsive breakpoints for category swiper.
// Consumers can override with their own breakpoints mapping (minWidth -> slidesPerView).
export const defaultCategorySwiperBreakpoints: Record<number, number> = {
0: 2, // mobile
768: 4, // tablet
1024: 6, // small desktop
1280: 8, // large desktop
};
class ProductCategoryService { class ProductCategoryService {
static async GetCategoryListAsync( static async GetCategoryListAsync(
...@@ -19,6 +66,17 @@ class ProductCategoryService { ...@@ -19,6 +66,17 @@ class ProductCategoryService {
return response as ProductCategoryListOutDto return response as ProductCategoryListOutDto
} }
static async GetCategoryByParentAsync(
parentId?: string,
displayChannel: number = 0,
name?: string
): Promise<CategoryItem[]> {
let url =`/product/category/children?displayChannel=${displayChannel}`
if(parentId) url += `&parentId=${parentId}`
if(name) url += `&name=${name}`
const response = await OtaRequest.get(url);
return response as unknown as CategoryItem[]
}
} }
export default ProductCategoryService; export default ProductCategoryService;
\ No newline at end of file
<template>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>
\ No newline at end of file
This diff is collapsed.
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="h-screen overflow-hidden"> <div class="h-screen overflow-hidden">
<a-spin :loading="loading" style="height: 100%;width: 100%;"> <a-spin :loading="loading" style="height: 100%;width: 100%;">
<div ref="loginPage" <div ref="loginPage"
class="min-w-[1200px] light-login-bg pl-[85px] pr-[98px] pt-[90px] h-full !overflow-y-auto"> class="min-w-7xl light-login-bg pl-[85px] pr-[98px] pt-[90px] h-full !overflow-y-auto">
<!-- 忘记密码表单 --> <!-- 忘记密码表单 -->
<div class="w-full flex flex-col loginForm"> <div class="w-full flex flex-col loginForm">
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="h-screen overflow-hidden"> <div class="h-screen overflow-hidden">
<a-spin :loading="loading" style="height: 100%;width: 100%;"> <a-spin :loading="loading" style="height: 100%;width: 100%;">
<div ref="loginPage" <div ref="loginPage"
class="min-w-[1200px] light-login-bg pl-[85px] pr-[98px] pt-[90px] h-full !overflow-y-auto"> class="min-w-7xl light-login-bg pl-[85px] pr-[98px] pt-[90px] h-full !overflow-y-auto">
<!-- 忘记密码表单 --> <!-- 忘记密码表单 -->
<div class="w-full flex flex-col loginForm"> <div class="w-full flex flex-col loginForm">
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="h-screen overflow-hidden"> <div class="h-screen overflow-hidden">
<a-spin :loading="loading" style="height: 100%;width: 100%;"> <a-spin :loading="loading" style="height: 100%;width: 100%;">
<div ref="loginPage" <div ref="loginPage"
class="min-w-[1200px] light-login-bg pl-[85px] pr-[98px] pt-[90px] h-full !overflow-y-auto"> class="min-w-7xl light-login-bg pl-[85px] pr-[98px] pt-[90px] h-full !overflow-y-auto">
<!-- 忘记密码表单 --> <!-- 忘记密码表单 -->
<div class="w-full flex flex-col loginForm"> <div class="w-full flex flex-col loginForm">
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
......
<template> <template>
<!-- h-screen --> <!-- h-screen -->
<div class="flex justify-center pb-[12px] h-[797px]"> <div class="flex justify-center pb-[12px] h-[797px]">
<div class="h-full flex justify-between w-[1200px]"> <div class="h-full flex justify-between w-[1280px]">
<!-- 左侧导航栏 --> <!-- 左侧导航栏 -->
<LeftView class="pt-[25px]" <LeftView class="pt-[25px]"
:menu-list="menuList" :menu-list="menuList"
......
This diff is collapsed.
<template> <template>
<div class="max-w-[1200px] mx-auto p-4" v-if="loading"> <div class="max-w-7xl mx-auto p-4" v-if="loading">
<a-skeleton :loading="true" :animation="true" class="home-skeleton"> <a-skeleton :loading="true" :animation="true" class="home-skeleton">
<!-- Banner 区域骨架 --> <!-- Banner 区域骨架 -->
<a-skeleton-shape shape="square" size="large" class="mb-4" style="width: 100%; height: 400px;" /> <a-skeleton-shape shape="square" size="large" class="mb-4" style="width: 100%; height: 400px;" />
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
</div> </div>
<template v-else> <template v-else>
<div class="place-page"> <div class="place-page">
<div class=" max-w-[1200px] mx-auto p-4"> <div class=" max-w-7xl mx-auto p-4">
<!-- 面包屑 --> <!-- 面包屑 -->
<a-breadcrumb :routes="routes"> <a-breadcrumb :routes="routes">
<template #item-render="{ route }"> <template #item-render="{ route }">
...@@ -193,7 +193,7 @@ ...@@ -193,7 +193,7 @@
<div v-if="heroList.length > 0" ref="cityIntroRef" class="w-full relative bg-cover bg-center" <div v-if="heroList.length > 0" ref="cityIntroRef" class="w-full relative bg-cover bg-center"
:style="{ backgroundImage: `url(${heroList[0]})` }"> :style="{ backgroundImage: `url(${heroList[0]})` }">
<div class="py-16 min-h-[400px] w-full description-mask flex flex-col justify-center"> <div class="py-16 min-h-[400px] w-full description-mask flex flex-col justify-center">
<div class="max-w-[1200px] mx-auto text-white w-full"> <div class="max-w-7xl mx-auto text-white w-full">
<div class="mb-6 text-4xl font-semibold">{{ cityName }}</div> <div class="mb-6 text-4xl font-semibold">{{ cityName }}</div>
<div class="leading-7 text-sm"> <div class="leading-7 text-sm">
<p v-for="value in description.split('\n')">{{ value }}</p> <p v-for="value in description.split('\n')">{{ value }}</p>
...@@ -204,7 +204,7 @@ ...@@ -204,7 +204,7 @@
<!-- 城市FAQ --> <!-- 城市FAQ -->
<div class="bg-gray-100 py-16 w-full"> <div class="bg-gray-100 py-16 w-full">
<div class="max-w-[1200px] mx-auto"> <div class="max-w-7xl mx-auto">
<div class="flex items-end justify-between mb-6"> <div class="flex items-end justify-between mb-6">
<div> <div>
<div class="text-xl font-bold text-[var(--customColor-text-10)] mb-1"> <div class="text-xl font-bold text-[var(--customColor-text-10)] mb-1">
......
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