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

商品列表,404等页面提交

parent c61848ff
......@@ -23,6 +23,7 @@
"md5-ts": "^0.1.6",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"pinyin-pro": "^3.27.0",
"sass-embedded": "^1.93.2",
"secure-ls": "^2.0.0",
"swiper": "^12.0.3",
......
This diff is collapsed.
......@@ -24,6 +24,10 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.num, .price, .stat {
font-family: "Roboto", "IBM Plex Sans", monospace;
font-variant-numeric: tabular-nums; /* 使数字等宽 */
}
.arco-dropdown-list-wrapper{
max-height: unset !important;
overflow: unset !important;
......@@ -51,4 +55,14 @@ body {
top: 0;
width: 100%;
background: linear-gradient(transparent 60%, rgba(0, 0, 0, .85));
}
.arco-scrollbar-track-direction-vertical{
width: 4px!important;
}
.arco-scrollbar-thumb-bar{
width: 4px!important;
margin: 0 !important;
}
.arco-scrollbar-track{
z-index: 2 !important;
}
\ No newline at end of file
......@@ -45,7 +45,8 @@ import type { PlaceTreeNode } from '@/types/place'
interface SelectResult {
id: string,
name: string
name: string,
path: string
}
const props = withDefaults(defineProps<{
......@@ -72,6 +73,7 @@ const placeTree = ref<PlaceTreeNode[]>([])
interface FlatCountry {
id: string
name: string
path: string
places: PlaceTreeNode[]
}
......@@ -84,6 +86,7 @@ const countries = computed<FlatCountry[]>(() => {
result.push({
id: country.value,
name: country.label,
path: country.path,
places: country.children || [],
})
}
......@@ -103,14 +106,14 @@ const loadPlaces = async () => {
const handleSelectAll = (country: any) => {
if (country) {
emit('change', { id: country.id, name: country.name }, { id: '', name: '' })
emit('change', { id: country.id, name: country.name, path:country.path }, { id: '', name: '',path:'' })
} 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 })
emit('change', { id: country.id, name: country.name , path:country.path }, { id: place.value, name: place.label,path:place.path })
}
onMounted(() => {
......
<template>
<div
class="product-card flex flex-col h-[320px] cursor-pointer"
class="product-card flex flex-col cursor-pointer"
:style="{background:bgColor?bgColor:'transparent'}"
:class="{'overflow-hidden rounded-md':bgColor}"
>
<img
:src="item.image"
:alt="item.title"
loading="lazy"
decoding="async"
class="w-full h-[180px] object-cover rounded-md "
class="w-full aspect-video object-cover rounded-md "
/>
<div class="product-body py-2 flex-1 min-h-0">
<div class="product-body py-2 flex-1 min-h-0" :class="{'px-2':bgColor}">
<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">
<div class="text-sm text-[var(--customColor-text-10)] leading-snug line-clamp-2 mb-1 h-[39px]">
{{ item.title }}
</div>
<div
......@@ -47,7 +49,7 @@
</div>
</div>
<div class="text-[11px] text-[var(--customColor-text-7)] py-2">
<div class="text-[11px] text-[var(--customColor-text-7)] py-2" :class="{'px-2':bgColor}">
<span class="text-[var(--customPrimary-6)] font-semibold mr-1">
CNY <span class="text-base">{{ parseFloat(item.price as string).toFixed(2) }}</span>
</span>
......@@ -76,7 +78,8 @@ export interface ProductCardItem {
}
defineProps<{
item: ProductCardItem
item: ProductCardItem,
bgColor?: string|null
}>()
</script>
<style scoped>
......
<template>
<div class="product-list">
<!-- toolbar: toggle + total + sort slot -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<div class="text-gray-800 text-xl font-semibold" v-if="total !== null">
共找到 {{ total }} 个产品
</div>
</div>
<div class="flex items-center gap-3">
<a-radio-group type="button" v-model="internalMode" class="!bg-gray-200 !rounded-md">
<a-radio value="card" title="卡片" class="px-2 !rounded-md">
卡片
</a-radio>
<a-radio value="list" title="列表" class="px-2 !rounded-md">
列表
</a-radio>
</a-radio-group>
<div class="orderBy">
<a-select class="!w-[156px] !bg-gray-200 !rounded-md" v-model="orderBy">
<a-option value="recommend">推荐排序</a-option>
<a-option value="orderCount">近期销量(高到低)</a-option>
<a-option value="commit">好评优先</a-option>
<a-option value="priceAsc">价格最低</a-option>
<a-option value="PriceDesc">价格最高</a-option>
</a-select>
</div>
</div>
</div>
<div class="py-3 border-t border-gray-200 flex flex-wrap items-center gap-1">
<a-tag
class="!bg-[var(--customPrimary-7)] !text-[var(--customPrimary-1)]"
v-for="value in filterParameters"
:key="value.key"
closable
@close="handleDeleteFilter(value)"
@mouseenter="() => onHoverFilter(value.key)"
@mouseleave="() => onHoverFilter(null)"
>
{{ value.name }}
</a-tag>
</div>
<!-- content -->
<div v-if="loading">
<ProductSkeleton :mode="internalMode" :count="skeletonCount" />
</div>
<div v-else>
<div
v-if="internalMode === 'card'"
ref="gridRoot"
class="grid gap-4 xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 sm:grid-cols-3"
>
<component
v-for="item in items"
:is="PlaceProductCard"
:key="item.id"
:item="item"
mode="card"
@click="onSelect"
@favorite="onFavorite"
bgColor="#FFF"
/>
</div>
<div v-else class="space-y-4">
<ProductRow
v-for="item in items"
:key="item.id"
:item="item"
@select="onSelect"
/>
</div>
<div class="mt-4 text-center" v-if="showLoadMore && !allLoaded">
<button class="px-4 py-2 bg-white border rounded-md shadow-sm" @click="onLoadMore">加载更多</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import ProductSkeleton from './ProductSkeleton.vue'
import PlaceProductCard from '@/components/place/ProductCard.vue'
import ProductRow from './ProductRow.vue'
type Mode = 'card' | 'list'
const props = defineProps<{
items?: any[]
modelValue?: Mode
loading?: boolean
columns?: number | Record<string, number>
total?: number | null
showLoadMore?: boolean
skeletonCount?: number
allLoaded?: boolean
cardMinWidth?: number
filters?: FilterItem[]
}>()
import type { FilterItem } from '@/types/filter'
const emits = defineEmits<{
(e: 'select', item: any): void
(e: 'loadMore'): void
(e: 'favorite', item: any): void
(e: 'update:modelValue', mode: Mode): void
(e: 'update:filters', filters: any[]): void
(e: 'highlight:filter', key: string | null): void
}>()
const items = computed(() => props.items || [])
const loading = computed(() => !!props.loading)
const total = computed(() => (typeof props.total === 'number' ? props.total : null))
const showLoadMore = computed(() => props.showLoadMore ?? true)
const skeletonCount = computed(() => props.skeletonCount ?? 4)
const allLoaded = computed(() => props.allLoaded ?? false)
const orderBy = ref('recommend')
// support external filters via prop `filters`; fallback to internal state
const internalFilterParameters = ref<{key:string,name:string}[]>([])
const filterParameters = computed(() => {
return props.filters ?? internalFilterParameters.value
})
const STORAGE_KEY = 'product_view_mode'
const internalMode = ref<Mode>((props.modelValue as Mode) || 'card')
watch(() => props.modelValue, (v) => {
if (v) internalMode.value = v as Mode
})
// persist when internalMode changed (e.g. via radio)
watch(internalMode, (v) => {
if (!v) return
try {
localStorage.setItem(STORAGE_KEY, v)
} catch (e) {}
emits('update:modelValue', v)
})
function onSelect(item: any) {
emits('select', item)
}
function onFavorite(item: any) {
emits('favorite', item)
}
function onLoadMore() {
emits('loadMore')
}
// gridClass removed: using CSS grid auto-fill with cardMinWidth for responsive columns
const cardMinWidth = Number(props.cardMinWidth ?? 274)
import { onMounted, onBeforeUnmount } from 'vue'
const gridRoot = ref<HTMLElement|null>(null)
const cols = ref<number>(1)
let ro: ResizeObserver | null = null
function updateCols() {
const el = gridRoot.value ? (gridRoot.value.parentElement || gridRoot.value) : document.documentElement
const width = el ? (el as HTMLElement).clientWidth : window.innerWidth
const computedCols = Math.max(1, Math.floor(width / cardMinWidth))
cols.value = computedCols
}
const handleDeleteFilter = (val:any)=>{
// remove filter locally or emit update for parent to handle
const current = (props as any).filters ?? internalFilterParameters.value
const updated = current.filter((f: any) => f.key !== val.key || f.name !== val.name)
// if parent is controlling filters, emit update
emits('update:filters', updated)
// if uncontrolled, update internal state
if (!(props as any).filters) {
internalFilterParameters.value = updated
}
}
function onHoverFilter(key: string | null) {
emits('highlight:filter', key)
}
onMounted(() => {
updateCols()
if (typeof ResizeObserver !== 'undefined' && gridRoot.value) {
ro = new ResizeObserver(() => updateCols())
ro.observe(gridRoot.value)
}
window.addEventListener('resize', updateCols)
// load saved view mode preference
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'card' || saved === 'list') {
internalMode.value = saved as Mode
emits('update:modelValue', internalMode.value)
}
} catch (e) {}
})
onBeforeUnmount(() => {
if (ro) ro.disconnect()
window.removeEventListener('resize', updateCols)
})
</script>
<style scoped>
.product-list :global(.product-card) {
/* ensure consistent card height inside grid */
display: block;
}
</style>
<template>
<div
class="product-row bg-white rounded-lg p-3 flex flex-col sm:flex-row gap-4 cursor-pointer hover:shadow-md transition-shadow duration-200"
@click="onClick" role="button" tabindex="0">
<!-- 左侧图片区域 -->
<div class="product-image-container w-[200px] aspect-[200/230] rounded-lg overflow-hidden">
<img :src="item.image" :alt="item.title" class="w-full h-full object-cover" loading="lazy"
decoding="async" />
</div>
<!-- 中间内容区 -->
<div class="flex-1 min-w-0 flex flex-col justify-between">
<div class="flex flex-col gap-3">
<h3 class="text-lg font-semibold text-[var(--customColor-text-10)]">
{{ item.title }}
</h3>
<div class="line-clamp-2 leading-[21px] text-sm text-[#000]">{{ item.description }}</div>
<div class="flex items-center gap-3" v-if="item.tag && item.tag.length > 0">
<a-tag size="small" v-for="tag in item.tag.split(',')" :key="tag">{{ tag }}</a-tag>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-[var(--customColor-text-7)]">
<span v-if="item.rating"
class="inline-flex items-center bg-[var(--customPrimary-6)] text-white text-xs font-semibold px-1.5 py-0.5 rounded-sm">
{{ item.rating }}<v-icon name="star" class="text-[10px] ml-0.5" />
</span>
<span v-if="item.comments" class="ml-1">{{ item.comments }}則評價</span>
<span v-if="item.orders" class="flex items-center">
<span class="text-gray-300 mr-1">|</span>
{{ item.orders }}人已預訂
</span>
<!-- 出发地 -->
<span v-if="item.departure" class="text-[var(--customColor-text-6)]">
{{ item.departure }}出發
</span>
</div>
</div>
<div class="text-right">
<div class="text-xs text-[var(--customColor-text-6)] mb-2 flex items-end justify-end" v-if="item.originPrice>item.price">
<span class="line-through">CNY{{ formatPrice(item.originPrice) }}</span>
<a-tag size="small" color="#ff5722" class="ml-2"> 優惠{{ item.discountRate }}%</a-tag>
</div>
<div class="text-[var(--customPrimary-6)]">
<span class="text-xl font-bold">CNY{{ formatPrice(item.price) }}</span>
<span class="text-sm ml-0.5"></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
export interface ProductRowItem {
id: any
title: string
image?: string
tag?: string
description?: string
features?: string[]
rating?: number | string
comments?: number | string
orders?: number | string
departure?: string
price?: any
originPrice?: any
favorited?: boolean
}
const props = defineProps<{
item: ProductRowItem
}>()
const emits = defineEmits<{
(e: 'select', item: ProductRowItem): void
}>()
function formatPrice(price: any): string {
if (!price) return '0'
const num = typeof price === 'string' ? parseFloat(price) : price
return num.toLocaleString('en-US')
}
function onClick() {
emits('select', props.item)
}
function onMore() {
emits('select', props.item)
}
</script>
<style scoped>
.product-row:hover .product-image-container img {
scale: 1.15;
transition: scale 1s ease;
}
.product-row .product-image-container img {
scale: 1;
transition: scale 1s ease;
}
</style>
<template>
<div>
<div v-for="n in count" :key="n" class="animate-pulse mb-4">
<div v-if="mode === 'card'" class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="w-full h-44 bg-gray-200"></div>
<div class="p-4">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-gray-200 rounded w-1/2 mb-3"></div>
<div class="flex items-center justify-between">
<div class="h-3 bg-gray-200 rounded w-1/4"></div>
<div class="h-8 bg-gray-200 rounded w-20"></div>
</div>
</div>
</div>
<div v-else class="bg-white rounded-lg shadow-sm p-4 flex gap-4 items-start">
<div class="w-36 h-24 bg-gray-200 rounded"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-2/3"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
<div class="h-3 bg-gray-200 rounded w-1/4"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
mode?: 'card' | 'list'
count?: number
}>()
const mode = props.mode || 'card'
const count = props.count ?? 4
</script>
<style scoped>
.animate-pulse {
/* keep tailwind-like pulse fallback for environments without tailwind */
-webkit-animation: pulse 1.5s ease-in-out infinite;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
</style>
......@@ -145,6 +145,39 @@ export default {
toursub: 'Explore global hot destinations and selected travel experiences',
recommendTitle:'Must-see'
},
tourToolBar: {
days: 'Days',
day: 'Day',
all: 'All',
viewAll: 'View All',
viewLitter: 'View Litter',
city: 'City',
category: 'Category'
},
cityIndex: {
title: 'Select City',
searchPlaceholder: 'Search city',
noData: 'No cities found',
cancel: 'Cancel',
confirmCount: 'Confirm ({count})'
},
notFound: {
title: 'Oops — Page not found',
subtitle: 'Looks like you wandered off the map. The route may have been removed or not yet added. Don’t worry — we’ll get you back to safety.',
home: 'Home',
back: 'Go back',
tryOptions: 'Try these options',
viewDestinations: 'View popular destinations',
searchProducts: 'Search all products',
featuredCities: 'Featured cities',
city: {
taipei: 'Taipei',
tokyo: 'Tokyo',
osaka: 'Osaka',
hochiminh: 'Ho Chi Minh'
},
contact: 'If the problem persists, contact support:'
},
place: {
all: 'Explore all',
global:'Global',
......
......@@ -17,6 +17,8 @@
"resetPassword": "Reset Password",
"editEmail": "Bind/Edit Email",
"placeDetail": "Place Detail",
"travel": "Travel & Experiences"
"travel": "Travel & Experiences",
"tour_list":"Tour & Experience List",
"notFound": "404 Page Not Found"
}
}
\ No newline at end of file
......@@ -17,6 +17,8 @@
"resetPassword": "Đặt lại mật khẩu",
"editEmail": "Liên kết/Sửa đổi email",
"placeDetail": "Chi tiết địa điểm",
"travel": "Du lịch & Trải nghiệm"
"travel": "Du lịch & Trải nghiệm",
"tour_list":"Danh sách Du lịch & Trải nghiệm",
"notFound": "404 Trang không tìm thấy"
}
}
\ No newline at end of file
......@@ -21,6 +21,8 @@
"passengerList": "常用旅客",
"mailingAddressList": "邮寄地址",
"placeDetail": "目的地详情",
"travel": "行程与体验"
"travel": "行程与体验",
"tour_list":"行程与体验列表",
"notFound": "404 页面未找到"
}
}
\ No newline at end of file
......@@ -17,6 +17,8 @@
"resetPassword": "重設密碼",
"editEmail": "綁定/修改郵箱",
"placeDetail": "目的地詳情",
"travel": "行程與體驗"
"travel": "行程與體驗",
"tour_list":"行程與體驗列表",
"notFound": "404 頁面未找到"
}
}
\ No newline at end of file
......@@ -145,6 +145,39 @@ export default {
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'
},
tourToolBar: {
days: 'Ngày',
day: 'Ngày',
all: 'Tất cả',
viewAll: 'Xem tất cả',
viewLitter: 'Xem ít',
city: 'Thành phố',
category: 'Danh mục'
},
cityIndex: {
title: 'Chọn thành phố',
searchPlaceholder: 'Tìm thành phố',
noData: 'Không có dữ liệu thành phố',
cancel: 'Hủy',
confirmCount: 'Xác nhận ({count})'
},
notFound: {
title: 'Oops — Không tìm thấy trang',
subtitle: 'Có vẻ bạn đi lạc rồi. Hành trình có thể đã bị gỡ hoặc chưa được thêm vào bản đồ. Đừng lo, chúng tôi sẽ đưa bạn về điểm xuất phát.',
home: 'Trang chủ',
back: 'Quay lại',
tryOptions: 'Thử các lựa chọn sau',
viewDestinations: 'Xem điểm đến phổ biến',
searchProducts: 'Tìm kiếm tất cả sản phẩm',
featuredCities: 'Thành phố nổi bật',
city: {
taipei: 'Đài Bắc',
tokyo: 'Tokyo',
osaka: 'Osaka',
hochiminh: 'Hồ Chí Minh'
},
contact: 'Nếu vấn đề vẫn xảy ra, vui lòng liên hệ hỗ trợ:'
},
place: {
all: 'Khám phá tất cả',
global:'Toàn cầu',
......
......@@ -145,6 +145,40 @@ export default {
toursub: '探索全球热门目的地和精选旅行体验',
recommendTitle:'不能錯過的'
},
tourToolBar: {
city: '城市',
category: '分类',
all: '全部',
viewAll: '显示更多',
viewLitter: '显示少量',
days:'出行天数',
day:'天',
date:'出行日期'
},
cityIndex: {
title: '选择城市',
searchPlaceholder: '搜索城市',
noData: '暂无城市数据',
cancel: '取消',
confirmCount: '确认({count})'
},
notFound: {
title: 'Oops — 找不到页面',
subtitle: '看起来你走偏路了。也许这条线路已经下架,或者我们还没有把它加入地图。别担心,我们帮你返回出发点。',
home: '返回首页',
back: '返回上页',
tryOptions: '试试以下选项',
viewDestinations: '查看热门目的地',
searchProducts: '搜索所有行程',
featuredCities: '精选城市',
city: {
taipei: '台北',
tokyo: '东京',
osaka: '大阪',
hochiminh: '胡志明'
},
contact: '若问题持续存在,请联系支持:'
},
place: {
all: '探索全部',
global:'全球',
......
......@@ -145,6 +145,39 @@ export default {
toursub: '探索全球熱門目的地和精選旅行體驗',
recommendTitle:'不能錯過的'
},
tourToolBar: {
days: '出行天数',
day: '天',
all: '全部',
viewAll: '顯示更多',
viewLitter: '顯示少量',
city: '城市',
category: '分類'
},
cityIndex: {
title: '選擇城市',
searchPlaceholder: '搜尋城市',
noData: '暫無城市資料',
cancel: '取消',
confirmCount: '確認({count})'
},
notFound: {
title: 'Oops — 找不到頁面',
subtitle: '看起來你走偏路了。也許這條行程已下架,或尚未加入地圖。別擔心,我們幫你返回出發地。',
home: '返回首頁',
back: '返回上一頁',
tryOptions: '試試以下選項',
viewDestinations: '查看熱門目的地',
searchProducts: '搜尋所有行程',
featuredCities: '精選城市',
city: {
taipei: '台北',
tokyo: '東京',
osaka: '大阪',
hochiminh: '胡志明'
},
contact: '若問題持續存在,請聯繫支援:'
},
place: {
all: '探索全部',
global:'全球',
......
......@@ -102,11 +102,19 @@ const router = createRouter({
component: () => import ('../views/categories/Travel.vue')
},
{
path: '/categories/list',
path: '/categories/list/:categoryId/:city?',
name: 'tour_list',
meta: { title: 'page.tour_list' },
component: () => import ('../views/categories/List.vue')
}
,
// 404 页面(放在 children 里以保留 Header/Footer)
{
path: '/:pathMatch(.*)*',
name: 'notfound',
meta: { title: 'page.notFound' },
component: () => import('../views/NotFound.vue')
}
]
},
{
......
......@@ -59,6 +59,14 @@ class CountryService {
return items || []
}
static async GetByCode2Async(code:string): Promise<CountrySimple|null> {
if(!code || code=='' || code.length!=2 || code=='global') return null
const data = await OtaRequest.get(`sys-management/country/by-code?code=${code}`)
return data as unknown as CountrySimple
}
///sys-management/country/by-code
}
export default CountryService
......
import OtaRequest from '@/api/OtaRequest'
export interface LanguageItem {
code?: string;
name?: null | string;
nativeName?: null | string;
id?:string;
}
class LanguageService {
static async GetLanguageListAsync(): Promise<LanguageItem[]> {
const response = await OtaRequest.get(`/sys-management/language`);
return response as unknown as LanguageItem[]
}
}
export default LanguageService;
\ No newline at end of file
......@@ -184,6 +184,14 @@ class PlaceService {
return response as unknown as PlaceOutputDto
}
static async getPlaceDetailByKeyAsync(key: string): Promise<PlaceOutputDto|null> {
if(!key || key=='' || key=='global') return null
const response = await OtaRequest.get(
`sys-management/place-services/place?key=${key}`
)
return response as unknown as PlaceOutputDto
}
/**
* 获取目的地页面聚合数据
* @param id 目的地ID
......
......@@ -77,6 +77,15 @@ class ProductCategoryService {
const response = await OtaRequest.get(url);
return response as unknown as CategoryItem[]
}
static async GetCategoryDetailByKeyAsync(
key: string
): Promise<CategoryItem|null> {
if(!key || key=='') return null
const response = await OtaRequest.get(`/product-category-services/category?key=${key}`);
return response as CategoryItem
}
//
}
export default ProductCategoryService;
\ No newline at end of file
......@@ -11,7 +11,7 @@ html, body {
/* 应用根元素 */
#app {
height: 100%;
overflow: hidden;
/* overflow: hidden; */
}
/* Layout组件样式 */
......@@ -20,7 +20,7 @@ html, body {
}
.arco-layout-content {
overflow: hidden;
overflow: auto;
}
/* 页面容器基类 */
......@@ -28,7 +28,7 @@ html, body {
min-height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
/* overflow: hidden; */
}
/* 搜索区域 */
......@@ -40,7 +40,7 @@ html, body {
.page-content {
flex: 1;
min-height: 0;
overflow: hidden;
overflow: auto;
}
/* 紧凑间距 */
......
export interface FilterItem {
key: string
name: string
value: any
}
......@@ -4,6 +4,7 @@ export interface PlaceTreeNode {
value: string
label: string
type: PlaceTreeType
path: string
children?: PlaceTreeNode[]
}
// 工具:城市索引分组与首字母归一化
// 依赖:pinyin-pro(用于中文转拼音首字母),可通过 cnpm 安装:cnpm i pinyin-pro
import { pinyin } from 'pinyin-pro'
export function normalizeInitial(name: string): string {
if (!name) return '#'
const s = name.trim()
if (!s) return '#'
// 中文范围检测(CJK)
if (/[\u4E00-\u9FFF]/.test(s[0])) {
try {
const py = pinyin(s[0], { toneType: 'none' }) || ''
const ch = (py[0] || '').toUpperCase()
return /[A-Z]/.test(ch) ? ch : '#'
} catch (e) {
// 如果 pinyin 库异常,退回到 #
return '#'
}
}
// Latin-based: 去掉变音符号(NFD + 移除 diacritics)
const base = s.normalize('NFD').replace(/\p{Diacritic}/gu, '')
const first = (base[0] || '').toUpperCase()
return /[A-Z]/.test(first) ? first : '#'
}
export type CityItem = {
label: string
value: string
[k: string]: any
}
export function groupCitiesByInitial<T extends CityItem>(items: T[]) {
const buckets: Record<string, T[]> = {}
for (let i = 65; i <= 90; i++) buckets[String.fromCharCode(i)] = []
buckets['#'] = []
for (const it of items || []) {
const k = normalizeInitial(it.label || '')
;(buckets[k] ||= []).push(it)
}
// 组内排序,使用 Intl.Collator 本地化比较
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true })
for (const k of Object.keys(buckets)) {
buckets[k].sort((a, b) => collator.compare(a.label, b.label))
}
return buckets
}
import type { FilterItem } from '@/types/filter'
import type { PlaceTreeNode } from '@/types/place'
type BaseData = Record<string, any[]>
export function createEmptySelectedMap() {
return {
cities: [] as PlaceTreeNode[],
date: [] as string[],
days: [] as number[],
features: [] as string[],
categories: [] as string[],
languages: [] as string[]
}
}
export function clearSelectedMap(map: Record<string, any[]>) {
Object.keys(map).forEach(k => {
// reset array in place
// @ts-ignore
map[k] = []
})
}
export function buildFiltersFromSelected(selectedMap: Record<string, any[]>, base: BaseData): FilterItem[] {
const out: FilterItem[] = []
const days = Array.isArray(selectedMap.days) ? selectedMap.days as number[] : []
const featuresSel = Array.isArray(selectedMap.features) ? selectedMap.features as string[] : []
const categoriesSel = Array.isArray(selectedMap.categories) ? selectedMap.categories as string[] : []
const citiesSel = Array.isArray(selectedMap.cities) ? selectedMap.cities as any[] : []
const languagesSel = Array.isArray(selectedMap.languages) ? selectedMap.languages as string[] : []
const dateSel = Array.isArray(selectedMap.date) ? selectedMap.date as string[] : []
// days
for (const d of days) {
out.push({ key: `day-${d}`, name: `${d}天`, value: d })
}
// features
const baseFeatures = Array.isArray(base.features) ? base.features : []
for (const val of featuresSel) {
const f = baseFeatures.find((x: any) => x && String(x.value) === String(val))
out.push({ key: `feature-${val}`, name: f?.name ?? String(val), value: f ?? val })
}
// categories
const baseCats = Array.isArray(base.categories) ? base.categories : []
for (const id of categoriesSel) {
const c = baseCats.find((x: any) => x && String(x.id) === String(id))
out.push({ key: `category-${id}`, name: c?.name ?? String(id), value: c ?? id })
}
// cities
for (const c of citiesSel) {
out.push({ key: `city-${c?.value}`, name: c?.label ?? c?.name ?? String(c?.value), value: c })
}
// languages
const baseLangs = Array.isArray(base.languages) ? base.languages : []
for (const id of languagesSel) {
const l = baseLangs.find((x: any) => x && (String(x.id) === String(id) || x.code === id))
out.push({ key: `lang-${id}`, name: l?.name ?? String(id), value: l ?? id })
}
// date
if (dateSel && dateSel.length === 2) {
out.push({ key: `date-${dateSel.join('_')}`, name: dateSel.join(' - '), value: dateSel.slice() })
}
return out
}
export function applyFiltersToSelected(selectedMap: Record<string, any[]>, filters: FilterItem[], base: BaseData) {
// clear first
clearSelectedMap(selectedMap)
filters.forEach(f => {
if (!f || !f.key) return
if (f.key.startsWith('day-')) {
const v = Number(f.key.replace('day-', ''))
selectedMap.days = Array.from(new Set([...(selectedMap.days || []), v]))
return
}
if (f.key.startsWith('feature-')) {
const val = f.key.replace('feature-', '')
selectedMap.features = Array.from(new Set([...(selectedMap.features || []), val]))
return
}
if (f.key.startsWith('category-')) {
const val = f.key.replace('category-', '')
selectedMap.categories = Array.from(new Set([...(selectedMap.categories || []), val]))
return
}
if (f.key.startsWith('city-')) {
const val = f.key.replace('city-', '')
// try find city object in base.cities by value
const c = (base.cities || []).find(x => String(x.value) === String(val))
if (c) selectedMap.cities = Array.from(new Set([...(selectedMap.cities || []), c]))
return
}
if (f.key.startsWith('lang-')) {
const val = f.key.replace('lang-', '')
selectedMap.languages = Array.from(new Set([...(selectedMap.languages || []), val]))
return
}
if (f.key.startsWith('date-')) {
const parts = f.key.replace('date-', '').split('_')
if (parts.length === 2) selectedMap.date = parts
return
}
})
}
<template>
<div class="notfound-page min-h-[90vh] flex items-center justify-center px-4 py-12">
<div class="max-w-6xl w-full grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- left: illustration area -->
<div class="relative md:col-span-2 bg-[url('/images/notfound.jpeg')] bg-cover bg-center rounded-xl overflow-hidden shadow-lg">
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-white/70"></div>
<!-- animated clouds -->
<div class="clouds pointer-events-none">
<div class="cloud cloud-1"></div>
<div class="cloud cloud-2"></div>
<div class="cloud cloud-3"></div>
</div>
<!-- animated airplane -->
<div class="plane" aria-hidden="true">
<img src="/images/plane.png" alt="plane" class="w-8" />
</div>
<div class="p-8 md:p-12 relative z-10">
<h1 class="text-4xl md:text-5xl font-extrabold text-[var(--customColor-text-10)] mb-3">{{ t('notFound.title') }}</h1>
<p class="text-[var(--customColor-text-8)] mb-6 max-w-xl">
{{ t('notFound.subtitle') }}
</p>
<div class="flex gap-3">
<a-button type="primary" size="large" @click="$router.push('/')">{{ t('notFound.home') }}</a-button>
<a-button size="large" @click="$router.back()">{{ t('notFound.back') }}</a-button>
</div>
</div>
</div>
<!-- right: tips and search -->
<div class="bg-white rounded-xl p-6 shadow-sm flex flex-col gap-4">
<div class="text-lg font-semibold">{{ t('notFound.tryOptions') }}</div>
<div class="flex flex-col gap-2">
<a-button type="text" @click="$router.push('/categories/travel')">{{ t('notFound.viewDestinations') }}</a-button>
<a-button type="text" @click="$router.push('/products/all/all')">{{ t('notFound.searchProducts') }}</a-button>
</div>
<div class="mt-4">
<div class="text-sm text-[var(--customColor-text-8)] mb-2">{{ t('notFound.featuredCities') }}</div>
<div class="grid grid-cols-2 gap-2">
<a-button type="text" @click="$router.push('/categories/list/taipei')">{{ t('notFound.city.taipei') }}</a-button>
<a-button type="text" @click="$router.push('/categories/list/tokyo')">{{ t('notFound.city.tokyo') }}</a-button>
<a-button type="text" @click="$router.push('/categories/list/osaka')">{{ t('notFound.city.osaka') }}</a-button>
<a-button type="text" @click="$router.push('/categories/list/ho-chi-minh')">{{ t('notFound.city.hochiminh') }}</a-button>
</div>
</div>
<div class="mt-auto text-xs text-[var(--customColor-text-7)]">
{{ t('notFound.contact') }} <a class="text-blue-600" href="mailto:support@example.com">support@example.com</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.notfound-page {
background: linear-gradient(180deg, rgba(246,247,248,1) 0%, rgba(255,255,255,1) 100%);
}
.clouds {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.cloud {
position: absolute;
background: rgba(255,255,255,0.85);
filter: blur(12px);
border-radius: 999px;
opacity: 0.9;
animation: floatClouds linear infinite;
}
.cloud-1 { width: 220px; height: 70px; top: 10%; left: -20%; animation-duration: 28s; }
.cloud-2 { width: 160px; height: 50px; top: 30%; left: -40%; animation-duration: 22s; animation-delay: 4s; }
.cloud-3 { width: 260px; height: 80px; top: 55%; left: -10%; animation-duration: 34s; animation-delay: 8s; }
@keyframes floatClouds {
0% { transform: translateX(-10%) translateY(0); opacity: .9;}
50% { opacity: .7; }
100% { transform: translateX(120%) translateY(-6px); opacity: .9;}
}
.plane {
position: absolute;
right: -60px;
top: 20%;
width: 120px;
transform-origin: center;
animation: fly 9s linear infinite;
z-index: 5;
}
.plane img { width: 100%; display:block; transform: rotate(-12deg); filter: drop-shadow(0 6px 12px rgba(0,0,0,0.12)); }
@keyframes fly {
0% { transform: translateX(-20vw) translateY(0) rotate(-6deg); opacity: .8;}
50% { transform: translateX(-5vw) translateY(-6vh) rotate(6deg); opacity: 1; }
100% { transform: translateX(120vw) translateY(-10vh) rotate(12deg); opacity: .8; }
}
/* responsive */
@media (max-width: 768px) {
.plane { display: none; }
.notfound-page .max-w-6xl { padding: 12px; }
}
</style>
This diff is collapsed.
......@@ -62,7 +62,7 @@
</a-input>
</div>
<div class="recommend-container pt-12 grid xl:grid-cols-7 md:grid-cols-5 grid-cols-2 gap-4">
<div class="recommed-item" v-for="item in hotDestinations.slice(0, 7)" :key="item.id" @click="handleRecommendClick(item.id)">
<div class="recommed-item" v-for="item in hotDestinations.slice(0, 7)" :key="item.id" @click="handleRecommendClick(item)">
<div class="w-full aspect-video rounded-lg overflow-hidden">
<img :src="item.backgroundImage??''" alt="" class="w-full h-full object-cover"></img>
</div>
......@@ -379,6 +379,9 @@ const getDefaultProductSections = () => {
}
]
}
const handleRecommendClick = (item:PlaceChildrenOutputDto) => {
router.push(`/categories/list/tour/${item?.id || currentPlace.value?.id || currentCountry.value?.id ||'global' }`)
}
// 生命周期
onMounted(() => {
loadCategories();
......
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