Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
B
boyueCEnd
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
游洁
boyueCEnd
Commits
287dcb71
Commit
287dcb71
authored
Dec 16, 2025
by
罗超
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
优化页面,新增搜索组件
parent
2b6adfbb
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
21 changed files
with
1601 additions
and
62 deletions
+1601
-62
base.css
src/assets/base.css
+12
-1
LanguageSwitcher.vue
src/components/common/LanguageSwitcher.vue
+2
-0
Carousel.vue
src/components/page-builder/Carousel.vue
+5
-0
SearchBox.vue
src/components/page-builder/SearchBox.vue
+53
-16
en.ts
src/i18n/locales/en.ts
+5
-0
page-title-en.json
src/i18n/locales/page/page-title-en.json
+2
-1
page-title-vi.json
src/i18n/locales/page/page-title-vi.json
+2
-1
page-title-zh-CN.json
src/i18n/locales/page/page-title-zh-CN.json
+2
-1
page-title-zh-TW.json
src/i18n/locales/page/page-title-zh-TW.json
+2
-1
vi.ts
src/i18n/locales/vi.ts
+5
-0
zh-CN.ts
src/i18n/locales/zh-CN.ts
+5
-0
zh-TW.ts
src/i18n/locales/zh-TW.ts
+5
-0
HeaderDestinationMenu.vue
src/layouts/components/HeaderDestinationMenu.vue
+10
-3
HeaderTopBar.vue
src/layouts/components/HeaderTopBar.vue
+56
-36
Headers.vue
src/layouts/components/Headers.vue
+13
-2
Search.vue
src/layouts/components/Search.vue
+183
-0
index.ts
src/router/index.ts
+6
-0
HotKeywordService.ts
src/services/HotKeywordService.ts
+29
-0
PlaceService.ts
src/services/PlaceService.ts
+118
-0
searchBox.ts
src/types/searchBox.ts
+2
-0
Detail.vue
src/views/place/Detail.vue
+1084
-0
No files found.
src/assets/base.css
View file @
287dcb71
...
...
@@ -25,10 +25,21 @@ body {
-moz-osx-font-smoothing
:
grayscale
;
}
.arco-dropdown-list-wrapper
{
max-height
:
50vh
!important
;
max-height
:
unset
!important
;
overflow
:
unset
!important
;
}
.arco-dropdown
{
padding
:
0
!important
;
background-color
:
transparent
!important
;
box-shadow
:
unset
!important
;
border
:
none
!important
;
}
.arco-dropdown
.arco-dropdown-list
>
div
{
background
:
#fff
;
border
:
1px
solid
var
(
--color-fill-3
);
box-shadow
:
0
4px
10px
rgba
(
0
,
0
,
0
,
0.1
);
border-radius
:
8px
;
overflow
:
hidden
;
}
.overlap
::before
{
display
:
block
;
...
...
src/components/common/LanguageSwitcher.vue
View file @
287dcb71
...
...
@@ -5,6 +5,7 @@
<icon-down
class=
"ml-[4px] text-[12px] text-primary-500"
/>
</a-button>
<template
#
content
>
<div>
<a-doption
v-for=
"option in LOCALE_OPTIONS"
:key=
"option.value"
...
...
@@ -16,6 +17,7 @@
<span
class=
"font-medium"
>
{{
option
.
label
}}
</span>
</div>
</a-doption>
</div>
</
template
>
</a-dropdown>
</template>
...
...
src/components/page-builder/Carousel.vue
View file @
287dcb71
...
...
@@ -7,6 +7,7 @@
}"
ref="wrapperRef"
:style="wrapperStyle"
>
<!-- 外部左箭头 -->
<template
v-if=
"isOutsideNav && props.navigation.enabled && showPrevArrow"
>
...
...
@@ -637,6 +638,10 @@ const wrapperStyle = computed(() => {
style
.
marginLeft
=
`
${
horizontal
}
px`
style
.
marginRight
=
`
${
horizontal
}
px`
}
if
(
props
.
searchBox
?.
enabled
){
style
.
zIndex
=
'10'
}
// 有 maxWidth 限制时,保持 auto 居中,不应用 horizontal 边距
return
style
...
...
src/components/page-builder/SearchBox.vue
View file @
287dcb71
...
...
@@ -124,6 +124,10 @@ import type { SearchBoxProps } from '@/types/searchBox'
import
{
useAdaptiveStyle
}
from
'@/composables/useAdaptiveStyle'
import
{
useDataSource
}
from
'@/composables/useDataSource'
import
type
{
DataSourceOption
}
from
'@/composables/useDataSource'
import
HotKeywordService
from
'@/services/HotKeywordService'
import
PlaceService
from
'@/services/PlaceService'
import
{
useSystemConfigStore
,
useUserStore
}
from
'@/stores'
import
router
from
'@/router'
const
adaptive
=
useAdaptiveStyle
()
...
...
@@ -135,10 +139,14 @@ const searchValue = ref('')
// 下拉面板显示状态
const
showDropdown
=
ref
(
false
)
const
systemConfigStore
=
useSystemConfigStore
()
// 数据源
const
{
options
,
loadDataSource
}
=
useDataSource
()
const
hotSearchesData
=
ref
<
DataSourceOption
[]
>
([])
const
hotDestinationsData
=
ref
<
DataSourceOption
[]
>
([])
const
hotSearchesDataMode
=
computed
(()
=>
props
.
hotSearches
.
dataMode
||
'custom'
)
const
hotDestinationsDataMode
=
computed
(()
=>
props
.
hotDestinations
.
dataMode
||
'custom'
)
// ==================== 计算属性 ====================
...
...
@@ -313,9 +321,21 @@ const shouldShowDropdown = computed(() => {
// 加载数据
const
loadData
=
async
()
=>
{
// 加载热门搜索
if
(
props
.
hotSearches
.
enabled
)
{
if
(
props
.
hotSearches
.
useDataSource
&&
props
.
hotSearches
.
dataSourceKey
)
{
if
(
hotSearchesDataMode
.
value
===
'global'
)
{
try
{
const
resp
=
await
HotKeywordService
.
getListAsync
()
const
list
=
(
resp
as
any
).
data
??
resp
??
[]
hotSearchesData
.
value
=
(
list
||
[]).
map
((
item
:
any
)
=>
({
label
:
item
.
keyword
,
value
:
item
.
id
||
item
.
keyword
}))
}
catch
(
error
)
{
hotSearchesData
.
value
=
[]
}
}
else
if
(
props
.
hotSearches
.
useDataSource
&&
props
.
hotSearches
.
dataSourceKey
)
{
// 使用数据源
await
loadDataSource
(
props
.
hotSearches
.
dataSourceKey
)
hotSearchesData
.
value
=
options
.
value
[
props
.
hotSearches
.
dataSourceKey
]
||
[]
...
...
@@ -326,24 +346,40 @@ const loadData = async () => {
value
:
tag
}))
}
}
else
{
hotSearchesData
.
value
=
[]
}
// 加载热门目的地
- 固定使用 hot_destinations 数据源
// 加载热门目的地
if
(
props
.
hotDestinations
.
enabled
)
{
await
loadDataSource
(
'hot_destinations'
)
const
allDestinations
=
options
.
value
[
'hot_destinations'
]
||
[]
// 只显示手动选中的目的地,并按照 selectedIds 的顺序排列
const
selectedIds
=
props
.
hotDestinations
.
selectedDestinations
||
[]
if
(
selectedIds
.
length
>
0
)
{
// 按照 selectedIds 的顺序映射数据
hotDestinationsData
.
value
=
selectedIds
.
map
(
id
=>
allDestinations
.
find
(
dest
=>
dest
.
value
===
id
))
.
filter
(
dest
=>
dest
!==
undefined
)
as
DataSourceOption
[]
if
(
hotDestinationsDataMode
.
value
===
'global'
)
{
try
{
const
resp
=
await
PlaceService
.
GetGlobalPlaceAsync
(
systemConfigStore
.
distributorId
||
0
)
const
group
=
(
resp
as
any
).
data
??
resp
const
reList
=
group
?.
reList
||
[]
hotDestinationsData
.
value
=
reList
.
map
((
item
:
any
)
=>
({
label
:
item
.
placeDetail
?.
name
||
item
.
placeId
,
value
:
item
.
placeId
,
image
:
item
.
placeDetail
?.
backgroundImage
}))
}
catch
(
error
)
{
}
}
else
{
hotDestinationsData
.
value
=
[]
await
loadDataSource
(
'hot_destinations'
)
const
allDestinations
=
options
.
value
[
'hot_destinations'
]
||
[]
// 只显示手动选中的目的地,并按照 selectedIds 的顺序排列
const
selectedIds
=
props
.
hotDestinations
.
selectedDestinations
||
[]
if
(
selectedIds
.
length
>
0
)
{
hotDestinationsData
.
value
=
selectedIds
.
map
(
id
=>
allDestinations
.
find
(
dest
=>
dest
.
value
===
id
))
.
filter
(
dest
=>
dest
!==
undefined
)
as
DataSourceOption
[]
}
else
{
hotDestinationsData
.
value
=
[]
}
}
}
else
{
hotDestinationsData
.
value
=
[]
}
}
...
...
@@ -356,12 +392,13 @@ const selectTag = (tag: DataSourceOption) => {
// 选择目的地
const
selectDestination
=
(
dest
:
DataSourceOption
)
=>
{
searchValue
.
value
=
dest
.
label
console
.
log
(
dest
)
// 如果有链接,可以跳转
if
(
dest
.
link
)
{
window
.
location
.
href
=
dest
.
link
}
else
{
handleSearch
()
//handleSearch()
router
.
push
(
`/place/
${
dest
.
value
}
`
)
}
}
...
...
src/i18n/locales/en.ts
View file @
287dcb71
...
...
@@ -5,6 +5,11 @@ import pageTitleEn from './page/page-title-en.json'
export
default
{
// 国家名称(基于ISO2代码)
countries
:
enCountries
,
search
:
{
placeholder
:
'Search destinations, products or activities'
,
hotSearch
:
'Hot Searches'
,
hotDestination
:
'Popular Destinations'
,
},
login
:
{
// Page titles
title
:
'Tten Joy'
,
...
...
src/i18n/locales/page/page-title-en.json
View file @
287dcb71
...
...
@@ -15,6 +15,7 @@
"commonPassengerInfo"
:
"Common Passenger Info"
,
"distributionCenter"
:
"Distribution Center"
,
"resetPassword"
:
"Reset Password"
,
"editEmail"
:
"Bind/Edit Email"
"editEmail"
:
"Bind/Edit Email"
,
"placeDetail"
:
"Place Detail"
}
}
\ No newline at end of file
src/i18n/locales/page/page-title-vi.json
View file @
287dcb71
...
...
@@ -15,6 +15,7 @@
"commonPassengerInfo"
:
"Thông tin hành khách thường dùng"
,
"distributionCenter"
:
"Trung tâm phân phối"
,
"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"
}
}
\ No newline at end of file
src/i18n/locales/page/page-title-zh-CN.json
View file @
287dcb71
...
...
@@ -19,6 +19,7 @@
"basicInfor"
:
"基础资料"
,
"account"
:
"账户信息"
,
"passengerList"
:
"常用旅客"
,
"mailingAddressList"
:
"邮寄地址"
"mailingAddressList"
:
"邮寄地址"
,
"placeDetail"
:
"目的地详情"
}
}
\ No newline at end of file
src/i18n/locales/page/page-title-zh-TW.json
View file @
287dcb71
...
...
@@ -15,6 +15,7 @@
"commonPassengerInfo"
:
"常用旅客資訊"
,
"distributionCenter"
:
"分銷中心"
,
"resetPassword"
:
"重設密碼"
,
"editEmail"
:
"綁定/修改郵箱"
"editEmail"
:
"綁定/修改郵箱"
,
"placeDetail"
:
"目的地詳情"
}
}
\ No newline at end of file
src/i18n/locales/vi.ts
View file @
287dcb71
...
...
@@ -5,6 +5,11 @@ import pageTitleVi from './page/page-title-vi.json'
export
default
{
// 国家名称(基于ISO2代码)
countries
:
viCountries
,
search
:
{
placeholder
:
'Tìm kiếm điểm đến, sản phẩm hoặc hoạt động'
,
hotSearch
:
'Tìm kiếm nổi bật'
,
hotDestination
:
'Điểm đến nổi bật'
,
},
login
:
{
// Tiêu đề trang
title
:
'Tten Joy'
,
...
...
src/i18n/locales/zh-CN.ts
View file @
287dcb71
...
...
@@ -5,6 +5,11 @@ import pageTitleZhCN from './page/page-title-zh-CN.json'
export
default
{
// 国家名称(基于ISO2代码)
countries
:
zhCNCountries
,
search
:
{
placeholder
:
'搜索目的地、产品或活动'
,
hotSearch
:
'热门搜索'
,
hotDestination
:
'热门目的地'
,
},
login
:
{
// 页面标题
title
:
'Tten Joy'
,
...
...
src/i18n/locales/zh-TW.ts
View file @
287dcb71
...
...
@@ -5,6 +5,11 @@ import pageTitleZhTW from './page/page-title-zh-TW.json'
export
default
{
// 国家名称(基于ISO2代码)
countries
:
zhTWCountries
,
search
:
{
placeholder
:
'搜尋目的地、產品或活動'
,
hotSearch
:
'熱門搜尋'
,
hotDestination
:
'熱門目的地'
,
},
login
:
{
// 頁面標題
title
:
'Tten Joy'
,
...
...
src/layouts/components/HeaderDestinationMenu.vue
View file @
287dcb71
...
...
@@ -25,10 +25,10 @@
{{
t
(
'header.hotDestinations'
)
}}
</div>
<div
class=
"pt-4 mb-2"
v-for=
"(val,index) in hotDestinations"
:key=
"index"
>
<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
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"
>
<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"
>
...
...
@@ -53,6 +53,7 @@
<span
v-for=
"place in country.children"
:key=
"place.value"
@
click=
"handleHotDestinationClick(place.value)"
class=
"place-item destination-place-item rounded-md"
>
{{
place
.
label
}}
...
...
@@ -72,19 +73,25 @@
<
script
setup
lang=
"ts"
>
import
{
onMounted
,
ref
,
computed
}
from
'vue'
import
{
useI18n
}
from
'vue-i18n'
import
PlaceService
,
{
type
PlaceGroupOutputDto
}
from
'@/services/PlaceService'
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
()
...
...
src/layouts/components/HeaderTopBar.vue
View file @
287dcb71
...
...
@@ -2,12 +2,13 @@
<div
class=
"w-[1200px] mx-2 py-2 flex items-center justify-between h-[60px] text-xs text-gray-700"
>
<div
class=
"flex items-center gap-4"
>
<img
v-if=
"logo"
:src=
"logo"
alt=
"logo"
class=
"h-[30px] cursor-pointer
"
@
click=
"handleGoHome"
v-if=
"logo"
:src=
"logo"
alt=
"logo"
class=
"h-[40px] cursor-pointer mr-3
"
@
click=
"handleGoHome"
/>
<Search
v-if=
"shouldShowSearch"
/>
</div>
<div
class=
"flex items-center gap-[8px]"
>
<LanguageSwitcher
/>
...
...
@@ -73,15 +74,16 @@
<
/template
>
<
script
setup
lang
=
"ts"
>
import
{
computed
,
ref
}
from
'vue'
import
{
computed
,
ref
,
onMounted
,
onBeforeUnmount
,
watch
}
from
'vue'
import
{
useUserStore
}
from
'@/stores/user'
import
{
useRouter
}
from
'vue-router'
import
CartDropdown
,
{
type
CartItem
}
from
'./CartDropdown.vue'
import
{
useRouter
,
useRoute
}
from
'vue-router'
import
CartDropdown
from
'./CartDropdown.vue'
import
{
useI18n
}
from
'vue-i18n'
import
{
useSystemConfigStore
}
from
'@/stores/index'
import
LanguageSwitcher
from
'@/components/common/LanguageSwitcher.vue'
import
Search
from
'./Search.vue'
const
router
=
useRouter
()
const
route
=
useRoute
()
const
userStore
=
useUserStore
()
const
{
t
}
=
useI18n
()
const
systemConfigStore
=
useSystemConfigStore
()
...
...
@@ -108,38 +110,56 @@ const currencyOptions = [
type
CurrencyCode
=
typeof
currencyOptions
[
number
]
const
mockCartItems
:
CartItem
[]
=
[
{
id
:
'1'
,
title
:
'【KKday獨家】香港 Chikawa Ramen Buta(吉伊卡哇)拉麵|預約證含飲品券'
,
subtitle
:
'Chikawa Ramen Buta 預約證含飲品券'
,
currency
:
'TWD'
,
price
:
153
,
quantity
:
1
,
date
:
'2025/12/20 12:30'
,
}
,
{
id
:
'2'
,
title
:
'永東巴士|香港來往澳門、大灣區各線路車票現金兌換券|全新線路'
,
subtitle
:
'永東巴士線路 HK$50 現金兌換券|適用於永東高線單程票'
,
currency
:
'TWD'
,
price
:
226
,
quantity
:
1
,
date
:
'2025/12/19'
,
const
selectedCurrencyCode
=
ref
<
CurrencyCode
>
(
'TWD'
)
const
isLoggedIn
=
computed
(()
=>
Boolean
(
userStore
.
userInfo
))
const
userPhoto
=
computed
(()
=>
userStore
.
memberData
?.
photo
||
''
)
const
userInitial
=
computed
(()
=>
userStore
.
userInfo
?.
nick
?.[
0
]
||
''
)
// 首页滚动控制搜索框显示
const
scrollY
=
ref
(
0
)
const
isHome
=
computed
(()
=>
route
.
path
===
'/'
||
route
.
path
===
'/home'
)
const
shouldShowSearch
=
computed
(()
=>
{
if
(
!
isHome
.
value
)
return
true
return
scrollY
.
value
>
200
}
)
const
handleScroll
=
()
=>
{
scrollY
.
value
=
window
.
scrollY
||
document
.
documentElement
.
scrollTop
||
0
}
const
setupScrollListener
=
()
=>
{
if
(
typeof
window
===
'undefined'
)
return
window
.
addEventListener
(
'scroll'
,
handleScroll
,
{
passive
:
true
}
)
handleScroll
()
}
const
removeScrollListener
=
()
=>
{
if
(
typeof
window
===
'undefined'
)
return
window
.
removeEventListener
(
'scroll'
,
handleScroll
)
}
watch
(
()
=>
route
.
path
,
()
=>
{
if
(
isHome
.
value
)
{
setupScrollListener
()
}
else
{
removeScrollListener
()
scrollY
.
value
=
0
}
}
,
]
{
immediate
:
true
}
)
const
cartItems
=
computed
<
CartItem
[]
>
(()
=>
{
if
(
!
userStore
.
userInfo
)
{
return
[]
onMounted
(()
=>
{
if
(
isHome
.
value
)
{
setupScrollListener
()
}
return
mockCartItems
}
)
const
selectedCurrencyCode
=
ref
<
CurrencyCode
>
(
'TWD'
)
const
isLoggedIn
=
computed
(()
=>
Boolean
(
userStore
.
userInfo
))
const
userPhoto
=
computed
(()
=>
userStore
.
memberData
?.
photo
||
''
)
const
userInitial
=
computed
(()
=>
userStore
.
userInfo
?.
nick
?.[
0
]
||
''
)
onBeforeUnmount
(()
=>
{
removeScrollListener
()
}
)
const
goPage
=
(
path
:
string
)
=>
{
...
...
src/layouts/components/Headers.vue
View file @
287dcb71
<
template
>
<header
class=
"app-header flex flex-col items-center pt-[60px]"
>
<div
class=
"fixed top-0 left-0 right-0 z-
1
0 shadow-sm customPrimary-bg-7 flex justify-center"
>
<div
class=
"fixed top-0 left-0 right-0 z-
2
0 shadow-sm customPrimary-bg-7 flex justify-center"
>
<HeaderTopBar
/>
</div>
<div
class=
"header-divider"
></div>
<HeaderNavBar
/>
<HeaderNavBar
v-if=
"showNavBar"
/>
</header>
</
template
>
<
script
setup
lang=
"ts"
>
import
HeaderTopBar
from
'./HeaderTopBar.vue'
import
HeaderNavBar
from
'./HeaderNavBar.vue'
import
{
useRoute
}
from
'vue-router'
;
import
{
ref
,
watch
}
from
'vue'
;
const
route
=
useRoute
()
const
showNavBar
=
ref
(
false
)
const
needNavs
=
ref
<
string
[]
>
([
'/'
,
'/home'
])
watch
(()
=>
route
.
path
,
()
=>
{
console
.
log
(
route
.
path
)
showNavBar
.
value
=
needNavs
.
value
.
includes
(
route
.
path
)
})
showNavBar
.
value
=
needNavs
.
value
.
includes
(
route
.
path
)
</
script
>
<
style
scoped
lang=
"scss"
>
...
...
src/layouts/components/Search.vue
0 → 100644
View file @
287dcb71
<
template
>
<div
class=
"w-80 h-10"
>
<a-dropdown
trigger=
"click"
position=
"bl"
class=
"!rounded-2xl"
>
<a-input-search
v-model=
"searchValue"
@
blur=
"handleBlur"
@
focus=
"handleFocus"
:class=
"hoverClass"
class=
"!w-full !h-full !border !rounded-full !text-[var(--customColor-text-10)]"
:placeholder=
"t('search.placeholder')"
@
search=
"handleSearch"
/>
<!-- 下拉内容:参考 SearchBox 的布局,做轻量版;热门目的地固定两列 -->
<template
#
content
>
<div
class=
"w-[640px] max-h-[540px] bg-white !rounded-2xl shadow-xl p-4 flex flex-col gap-4"
>
<!-- 热门搜索 -->
<div
v-if=
"hotSearches.length"
class=
"border-b pb-3"
>
<div
class=
"text-xs font-semibold mb-3 text-[var(--customColor-text-10)]"
>
{{
t
(
'search.hotSearch'
)
}}
</div>
<div
class=
"flex flex-wrap gap-2"
>
<button
v-for=
"item in limitedHotSearches"
:key=
"item.id || item.keyword"
class=
"px-3 py-1 rounded-full text-xs border border-[var(--customPrimary-2)] text-[var(--customColor-text-9)] hover:bg-[var(--customPrimary-7)] hover:text-[var(--customPrimary-6)] transition-colors"
@
click=
"handleSelectHotKeyword(item)"
>
{{
item
.
keyword
}}
</button>
</div>
</div>
<!-- 热门目的地:固定两列 -->
<div
v-if=
"mappedHotDestinations.length"
class=
"flex-1 overflow-y-auto"
>
<div
class=
"text-xs font-semibold mb-3 text-[var(--customColor-text-10)]"
>
{{
t
(
'search.hotDestination'
)
}}
</div>
<div
class=
"grid grid-cols-2 gap-3"
>
<div
v-for=
"dest in mappedHotDestinations"
:key=
"dest.placeId"
class=
"flex items-center gap-3 p-2 rounded-xl cursor-pointer hover:bg-gray-100 transition-colors"
@
click=
"handleSelectDestination(dest)"
>
<div
class=
"w-12 h-12 rounded-lg overflow-hidden bg-[var(--customPrimary-2)] flex-shrink-0"
>
<img
v-if=
"dest.image"
:src=
"dest.image"
:alt=
"dest.name"
loading=
"lazy"
decoding=
"async"
class=
"w-full h-full object-cover"
/>
<div
v-else
class=
"w-full h-full flex items-center justify-center text-[var(--customColor-text-6)] text-xs"
>
{{
dest
.
name
.
charAt
(
0
)
}}
</div>
</div>
<div
class=
"min-w-0"
>
<div
class=
"text-sm font-medium text-[var(--customColor-text-10)] truncate"
>
{{
dest
.
name
}}
</div>
<div
class=
"text-xs text-[var(--customColor-text-7)] truncate"
>
{{
dest
.
subtitle
}}
</div>
</div>
</div>
</div>
</div>
</div>
</
template
>
</a-dropdown>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
onMounted
,
ref
,
computed
}
from
'vue'
import
HotKeywordService
from
'@/services/HotKeywordService'
import
PlaceService
from
'@/services/PlaceService'
import
{
useSystemConfigStore
}
from
'@/stores'
import
{
useRouter
}
from
'vue-router'
import
{
useI18n
}
from
'vue-i18n'
const
router
=
useRouter
()
const
systemConfigStore
=
useSystemConfigStore
()
const
{
t
}
=
useI18n
()
const
hotSearches
=
ref
<
any
[]
>
([])
const
hotDestinations
=
ref
<
any
[]
>
([])
const
searchValue
=
ref
(
''
)
// 输入框 hover / focus 样式(尽量复用灰阶和白色)
const
hoverClass
=
ref
([
'!bg-gray-950/5'
,
'!border-gray-950/10'
])
const
handleFocus
=
()
=>
{
hoverClass
.
value
=
[
'!bg-white'
]
}
const
handleBlur
=
()
=>
{
hoverClass
.
value
=
[
'!bg-gray-950/5'
,
'!border-gray-950/10'
]
}
const
handleSearch
=
()
=>
{
const
keyword
=
searchValue
.
value
.
trim
()
if
(
!
keyword
)
return
// 这里暂时只打印,后续可接入实际搜索页
console
.
log
(
'header search:'
,
keyword
)
}
// 热门搜索最多展示 10 个
const
limitedHotSearches
=
computed
(()
=>
hotSearches
.
value
.
slice
(
0
,
10
))
// 将 PlaceService 返回的数据映射为下拉展示用的结构
const
mappedHotDestinations
=
computed
(()
=>
{
return
(
hotDestinations
.
value
||
[]).
map
((
item
:
any
)
=>
{
const
placeDetail
=
item
.
placeDetail
||
{}
const
name
=
placeDetail
.
name
||
''
const
rawBg
=
placeDetail
.
backgroundImage
as
string
|
null
|
undefined
let
image
=
''
if
(
rawBg
)
{
try
{
const
parsed
=
JSON
.
parse
(
rawBg
)
if
(
Array
.
isArray
(
parsed
)
&&
parsed
.
length
>
0
)
{
image
=
parsed
[
0
]
}
}
catch
{
const
parts
=
rawBg
.
split
(
','
).
filter
(
Boolean
)
image
=
parts
[
0
]
||
''
}
}
return
{
placeId
:
item
.
placeId
,
name
,
subtitle
:
systemConfigStore
.
platformName
||
''
,
image
,
}
})
})
const
handleSelectHotKeyword
=
(
item
:
any
)
=>
{
searchValue
.
value
=
item
.
keyword
handleSearch
()
}
const
handleSelectDestination
=
(
dest
:
{
placeId
:
string
;
name
:
string
})
=>
{
// 跳转到目的地详情页
router
.
push
(
`/place/
${
dest
.
placeId
}
`
)
}
const
getHotSearches
=
async
()
=>
{
try
{
const
resp
=
await
HotKeywordService
.
getListAsync
()
hotSearches
.
value
=
(
resp
as
any
).
data
??
resp
??
[]
}
catch
{
hotSearches
.
value
=
[]
}
}
const
getHotDestinations
=
async
()
=>
{
try
{
const
resp
=
await
PlaceService
.
GetGlobalPlaceAsync
(
systemConfigStore
.
distributorId
||
0
)
// 后端结构:{ reList: GroupReDto[] }
hotDestinations
.
value
=
(
resp
as
any
).
reList
||
[]
}
catch
{
hotDestinations
.
value
=
[]
}
}
onMounted
(()
=>
{
getHotSearches
()
getHotDestinations
()
})
</
script
>
<
style
scoped
lang=
"scss"
>
</
style
>
\ No newline at end of file
src/router/index.ts
View file @
287dcb71
...
...
@@ -89,6 +89,12 @@ const router = createRouter({
meta
:
{
title
:
"page.editEmail"
},
component
:
()
=>
import
(
'../views/personalCenter/accountPage/perForgePassword.vue'
)
},
{
path
:
'/place/:id'
,
name
:
'placeDetail'
,
meta
:
{
title
:
'page.placeDetail'
},
component
:
()
=>
import
(
'../views/place/Detail.vue'
)
},
]
},
{
...
...
src/services/HotKeywordService.ts
0 → 100644
View file @
287dcb71
import
Api
,
{
type
HttpResponse
}
from
'@/api/OtaRequest'
;
export
interface
HotKeywordOutputDto
{
id
:
string
;
// ID(UUID)
keyword
:
string
|
null
;
// 关键词
language
:
string
|
null
;
// 语言
enable
:
boolean
;
// 是否启用
orderNum
:
number
;
// 排序
creationTime
:
string
;
// 创建时间(ISO 8601格式)
lastModificationTime
?:
string
|
null
;
// 最后修改时间
}
/**
* 热门关键词服务类
*/
class
HotKeywordService
{
/**
* 获取列表(不分页)
* @param params 查询参数
* @returns 列表结果
*/
static
async
getListAsync
():
Promise
<
HttpResponse
<
HotKeywordOutputDto
[]
>>
{
return
Api
.
get
(
'/hot-keyword'
,
{
SkipCount
:
0
,
MaxResultCount
:
100
,
Enable
:
true
});
}
}
export
default
HotKeywordService
;
src/services/PlaceService.ts
View file @
287dcb71
...
...
@@ -58,6 +58,18 @@ export interface PlaceInputDto {
export
interface
PlaceOutputDto
extends
PlaceInputDto
{
/** 主键 */
id
:
string
/** 国家名称 */
countryName
?:
string
|
null
/** 标签(JSON 或列表) */
tags
?:
string
|
null
/** 描述 */
description
?:
string
|
null
/** 英雄图(JSON 或列表) */
heroImages
?:
string
|
null
/** 天气提示 */
weatherHint
?:
string
|
null
/** 配置 JSON(运营钩子) */
config
?:
string
|
null
}
/**
...
...
@@ -121,7 +133,113 @@ class PlaceService {
)
return
response
as
unknown
as
PlaceGroupOutputDto
[]
}
/**
* 获取目的地详情
* @param id 目的地ID
* @returns 目的地详情
*/
static
async
getPlaceDetailAsync
(
id
:
string
):
Promise
<
PlaceOutputDto
>
{
const
response
=
await
OtaRequest
.
get
(
`/sys-management/place-services/
${
id
}
/detail`
)
return
response
as
unknown
as
PlaceOutputDto
}
/**
* 获取目的地页面聚合数据
* @param id 目的地ID
* @returns 目的地页面聚合数据
*/
static
async
getPlacePageDataAsync
(
id
:
string
):
Promise
<
PlacePageDataOutputDto
>
{
const
response
=
await
OtaRequest
.
get
(
`/sys-management/place-services/
${
id
}
/page-data`
)
return
response
as
unknown
as
PlacePageDataOutputDto
}
/**
* 获取全局推荐的的目的地分组
* @param distributorId
* @returns
*/
static
async
GetGlobalPlaceAsync
(
distributorId
:
number
):
Promise
<
PlaceGroupOutputDto
>
{
const
r
=
OtaRequest
.
get
(
`/sys-management/place-services/global-group/
${
distributorId
}
`
);
return
r
as
unknown
as
PlaceGroupOutputDto
;
}
}
/**
* FAQ 相关接口
*/
export
interface
PlaceFaqDto
{
id
:
string
placeId
:
string
question
:
string
|
null
answer
:
string
|
null
state
:
boolean
orderNum
:
number
}
/**
* 目的地页面聚合数据
*/
export
interface
PlacePageDataOutputDto
{
destination
:
any
quickEntries
?:
QuickEntryDto
[]
|
null
sections
?:
SectionDto
[]
|
null
recommendations
?:
any
[]
|
null
featuredReviews
?:
string
[]
|
null
faqList
?:
string
[]
|
null
weather
?:
any
|
null
}
export
interface
QuickEntryDto
{
categoryId
?:
string
|
null
name
?:
string
|
null
count
:
number
link
?:
string
|
null
}
export
interface
SectionDto
{
key
?:
string
|
null
title
?:
string
|
null
limit
:
number
items
?:
SectionItemDto
[]
|
null
}
export
interface
SectionItemDto
{
title
?:
string
|
null
price
?:
number
|
null
currency
?:
string
|
null
originPrice
?:
number
|
null
discount
?:
number
|
null
rating
?:
number
|
null
reviewsCount
?:
number
|
null
image
?:
string
|
null
link
?:
string
|
null
tags
?:
string
[]
|
null
}
/**
* FAQ 服务
*/
class
PlaceFaqService
{
/**
* 根据目的地ID获取FAQ列表
* @param placeId 目的地ID
* @returns FAQ列表
*/
static
async
getFaqListByPlaceIdAsync
(
placeId
:
string
):
Promise
<
PlaceFaqDto
[]
>
{
const
response
=
await
OtaRequest
.
get
(
`/sys-management/place-faq-services/by-place/
${
placeId
}
`
)
return
response
as
unknown
as
PlaceFaqDto
[]
}
}
export
{
PlaceFaqService
}
export
default
PlaceService
src/types/searchBox.ts
View file @
287dcb71
...
...
@@ -27,6 +27,7 @@ export interface SearchBoxProps {
* 热门搜索配置
*/
hotSearches
:
{
dataMode
:
string
enabled
:
boolean
title
:
string
// 标题,如 "KKday 熱門搜尋"
useDataSource
:
boolean
// 是否使用数据源(false 则使用手动标签)
...
...
@@ -40,6 +41,7 @@ export interface SearchBoxProps {
* 热门目的地配置
*/
hotDestinations
:
{
dataMode
:
string
enabled
:
boolean
title
:
string
// 标题,如 "海外热门目的地"
selectedDestinations
:
string
[]
// 手动选择的目的地ID列表
...
...
src/views/place/Detail.vue
0 → 100644
View file @
287dcb71
This diff is collapsed.
Click to expand it.
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment