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
c61848ff
Commit
c61848ff
authored
Dec 22, 2025
by
罗超
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
目的地与分类二级页面开发
parent
0ae117a8
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
27 changed files
with
1389 additions
and
919 deletions
+1389
-919
CategoryCard.vue
src/components/categories/CategoryCard.vue
+309
-0
DestinationCard.vue
src/components/categories/DestinationCard.vue
+99
-0
VDialog.vue
src/components/global/VDialog.vue
+1
-1
PlaceFlat.vue
src/components/place/PlaceFlat.vue
+181
-0
ProductCard.vue
src/components/place/ProductCard.vue
+1
-1
en.ts
src/i18n/locales/en.ts
+30
-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
+30
-0
zh-CN.ts
src/i18n/locales/zh-CN.ts
+30
-0
zh-TW.ts
src/i18n/locales/zh-TW.ts
+30
-0
Footer.vue
src/layouts/components/Footer.vue
+1
-1
HeaderNavBar.vue
src/layouts/components/HeaderNavBar.vue
+42
-8
HeaderTopBar.vue
src/layouts/components/HeaderTopBar.vue
+1
-1
index.ts
src/router/index.ts
+12
-0
PlaceService.ts
src/services/PlaceService.ts
+10
-1
ProductCategoryService.ts
src/services/ProductCategoryService.ts
+59
-1
List.vue
src/views/categories/List.vue
+11
-0
Travel.vue
src/views/categories/Travel.vue
+526
-0
editEmail.vue
src/views/personalCenter/accountPage/editEmail.vue
+1
-1
perForgePassword.vue
src/views/personalCenter/accountPage/perForgePassword.vue
+1
-1
resetPassword.vue
src/views/personalCenter/accountPage/resetPassword.vue
+1
-1
index.vue
src/views/personalCenter/index.vue
+1
-1
Place copy.vue
src/views/place/Place copy.vue
+0
-893
Place.vue
src/views/place/Place.vue
+4
-4
No files found.
src/components/categories/CategoryCard.vue
0 → 100644
View file @
c61848ff
<
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
>
src/components/categories/DestinationCard.vue
0 → 100644
View file @
c61848ff
<
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
>
src/components/global/VDialog.vue
View file @
c61848ff
...
...
@@ -13,7 +13,7 @@
<!-- 对话框主体 -->
<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
>
<div
class=
"bg-white rounded-2xl shadow-xl overflow-hidden"
>
...
...
src/components/place/PlaceFlat.vue
0 → 100644
View file @
c61848ff
<
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
>
src/components/place/ProductCard.vue
View file @
c61848ff
...
...
@@ -62,7 +62,7 @@
</template>
<
script
setup
lang=
"ts"
>
interface
ProductCardItem
{
export
interface
ProductCardItem
{
title
:
string
image
:
string
tag
?:
string
...
...
src/i18n/locales/en.ts
View file @
c61848ff
...
...
@@ -10,6 +10,25 @@ export default {
hotSearch
:
'Hot Searches'
,
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
:
{
// Page titles
title
:
'Tten Joy'
,
...
...
@@ -121,8 +140,19 @@ export default {
recommond
:
'Recommend'
,
hotDestinations
:
'Hot Destinations'
,
},
caterogy
:
{
tour
:
'Tour & Experience'
,
toursub
:
'Explore global hot destinations and selected travel experiences'
,
recommendTitle
:
'Must-see'
},
place
:
{
all
:
'Explore all'
,
global
:
'Global'
,
current
:
{
welcome
:
'You are currently viewing'
,
more
:
'activities, explore more destinations or'
,
activity
:
'activities.'
,
},
activitiesCount
:
'{count} activities'
,
top10Title
:
'{city} Top 10 experiences'
,
hotAttractionsTitle
:
'Top attractions in {city}'
,
...
...
src/i18n/locales/page/page-title-en.json
View file @
c61848ff
...
...
@@ -16,6 +16,7 @@
"distributionCenter"
:
"Distribution Center"
,
"resetPassword"
:
"Reset Password"
,
"editEmail"
:
"Bind/Edit Email"
,
"placeDetail"
:
"Place Detail"
"placeDetail"
:
"Place Detail"
,
"travel"
:
"Travel & Experiences"
}
}
\ No newline at end of file
src/i18n/locales/page/page-title-vi.json
View file @
c61848ff
...
...
@@ -16,6 +16,7 @@
"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"
,
"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
src/i18n/locales/page/page-title-zh-CN.json
View file @
c61848ff
...
...
@@ -20,6 +20,7 @@
"account"
:
"账户信息"
,
"passengerList"
:
"常用旅客"
,
"mailingAddressList"
:
"邮寄地址"
,
"placeDetail"
:
"目的地详情"
"placeDetail"
:
"目的地详情"
,
"travel"
:
"行程与体验"
}
}
\ No newline at end of file
src/i18n/locales/page/page-title-zh-TW.json
View file @
c61848ff
...
...
@@ -16,6 +16,7 @@
"distributionCenter"
:
"分銷中心"
,
"resetPassword"
:
"重設密碼"
,
"editEmail"
:
"綁定/修改郵箱"
,
"placeDetail"
:
"目的地詳情"
"placeDetail"
:
"目的地詳情"
,
"travel"
:
"行程與體驗"
}
}
\ No newline at end of file
src/i18n/locales/vi.ts
View file @
c61848ff
...
...
@@ -10,6 +10,25 @@ export default {
hotSearch
:
'Tìm kiếm 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
:
{
// Tiêu đề trang
title
:
'Tten Joy'
,
...
...
@@ -121,8 +140,19 @@ export default {
recommond
:
'Giới thiệu'
,
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
:
{
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'
,
top10Title
:
'Top 10 trải nghiệm tại {city}'
,
hotAttractionsTitle
:
'Điểm tham quan nổi bật ở {city}'
,
...
...
src/i18n/locales/zh-CN.ts
View file @
c61848ff
...
...
@@ -10,6 +10,25 @@ export default {
hotSearch
:
'热门搜索'
,
hotDestination
:
'热门目的地'
,
},
travel
:
{
categories
:
{
title
:
'行程分类'
,
},
destinations
:
{
title
:
'热门目的地'
,
viewAll
:
'查看全部'
,
},
products
:
{
title
:
'热门行程推荐'
,
viewAll
:
'查看全部'
,
},
explore
:
{
title
:
'探索更多'
,
description
:
'发现更多精彩目的地和行程体验'
,
allDestinations
:
'查看全部目的地'
,
allProducts
:
'查看全部行程'
,
},
},
login
:
{
// 页面标题
title
:
'Tten Joy'
,
...
...
@@ -121,8 +140,19 @@ export default {
recommond
:
'推荐'
,
hotDestinations
:
'热门目的地'
,
},
caterogy
:
{
tour
:
'游览 & 体验'
,
toursub
:
'探索全球热门目的地和精选旅行体验'
,
recommendTitle
:
'不能錯過的'
},
place
:
{
all
:
'探索全部'
,
global
:
'全球'
,
current
:
{
welcome
:
'您正在查看'
,
more
:
'的活動, 探索以下更多目的地或'
,
activity
:
'的活動。'
,
},
activitiesCount
:
'{count} 个活动'
,
top10Title
:
'{city} Top 10 旅游资讯'
,
hotAttractionsTitle
:
'{city} 热门景点'
,
...
...
src/i18n/locales/zh-TW.ts
View file @
c61848ff
...
...
@@ -10,6 +10,25 @@ export default {
hotSearch
:
'熱門搜尋'
,
hotDestination
:
'熱門目的地'
,
},
travel
:
{
categories
:
{
title
:
'行程分類'
,
},
destinations
:
{
title
:
'熱門目的地'
,
viewAll
:
'查看全部'
,
},
products
:
{
title
:
'熱門行程推薦'
,
viewAll
:
'查看全部'
,
},
explore
:
{
title
:
'探索更多'
,
description
:
'發現更多精彩目的地和行程體驗'
,
allDestinations
:
'查看全部目的地'
,
allProducts
:
'查看全部行程'
,
},
},
login
:
{
// 頁面標題
title
:
'Tten Joy'
,
...
...
@@ -121,8 +140,19 @@ export default {
recommond
:
'推薦'
,
hotDestinations
:
'熱門目的地'
,
},
caterogy
:
{
tour
:
'游览 & 体验'
,
toursub
:
'探索全球熱門目的地和精選旅行體驗'
,
recommendTitle
:
'不能錯過的'
},
place
:
{
all
:
'探索全部'
,
global
:
'全球'
,
current
:
{
welcome
:
'您正在查看'
,
more
:
'的活動, 探索以下更多目的地或'
,
activity
:
'的活動。'
,
},
activitiesCount
:
'{count} 個活動'
,
top10Title
:
'{city} Top 10 旅遊資訊'
,
hotAttractionsTitle
:
'{city} 熱門景點'
,
...
...
src/layouts/components/Footer.vue
View file @
c61848ff
...
...
@@ -250,7 +250,7 @@ const scrollToTop = () => {
}
.footer-container
{
max-width
:
12
0
0px
;
max-width
:
12
8
0px
;
margin
:
0
auto
;
padding
:
0
20px
;
}
...
...
src/layouts/components/HeaderNavBar.vue
View file @
c61848ff
<
template
>
<div
class=
"bg-white w-full flex justify-center"
>
<div
class=
"w-[12
0
0px] flex items-center"
>
<div
class=
"w-[12
8
0px] flex items-center"
>
<HeaderDestinationMenu
/>
<a-menu
mode=
"horizontal"
...
...
@@ -34,6 +34,7 @@
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
}
from
'vue'
import
{
useRouter
}
from
'vue-router'
import
{
useI18n
}
from
'vue-i18n'
import
{
useSystemConfigStore
}
from
'@/stores/index'
import
type
{
NavItemDto
}
from
'@/types/systemConfig'
...
...
@@ -43,7 +44,40 @@ const { t } = useI18n()
const
systemConfigStore
=
useSystemConfigStore
()
const
activeKey
=
ref
<
string
[]
>
([])
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
=>
{
for
(
const
item
of
items
)
{
...
...
@@ -58,13 +92,13 @@ const findMatch = (items: NavItemDto[] = [], path: string): NavItemDto | null =>
const
handleMenuClick
=
(
key
:
string
)
=>
{
activeKey
.
value
=
[
key
]
// const target = navMap.value.get
(key)
//
if (!target || !target.navUrl) return
// if (target
.isNewOpen) {
//
window.open(target.navUrl, '_blank')
//
return
//
}
//
router.push(target.navUrl)
const
target
=
findByKey
(
key
)
if
(
!
target
||
!
target
.
navUrl
)
return
if
((
target
as
any
)
.
isNewOpen
)
{
window
.
open
(
target
.
navUrl
,
'_blank'
)
return
}
router
.
push
(
target
.
navUrl
)
}
</
script
>
...
...
src/layouts/components/HeaderTopBar.vue
View file @
c61848ff
<
template
>
<div
class=
"w-[12
0
0px] mx-2 py-2 flex items-center justify-between h-[60px] text-xs text-[var(--customColor-text-10)]"
>
<div
class=
"w-[12
8
0px] 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/router/index.ts
View file @
c61848ff
...
...
@@ -95,6 +95,18 @@ const router = createRouter({
meta
:
{
title
:
'page.placeDetail'
},
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'
)
}
]
},
{
...
...
src/services/PlaceService.ts
View file @
c61848ff
...
...
@@ -134,7 +134,16 @@ class PlaceService {
}
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
[]
}
...
...
src/services/ProductCategoryService.ts
View file @
c61848ff
...
...
@@ -9,7 +9,54 @@ export interface ProductCategoryListOutDto {
name
?:
null
|
string
;
parentId
?:
null
|
string
;
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
{
static
async
GetCategoryListAsync
(
...
...
@@ -19,6 +66,17 @@ class ProductCategoryService {
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
;
\ No newline at end of file
src/views/categories/List.vue
0 → 100644
View file @
c61848ff
<
template
>
</
template
>
<
script
setup
lang=
"ts"
>
</
script
>
<
style
scoped
>
</
style
>
\ No newline at end of file
src/views/categories/Travel.vue
0 → 100644
View file @
c61848ff
This diff is collapsed.
Click to expand it.
src/views/personalCenter/accountPage/editEmail.vue
View file @
c61848ff
...
...
@@ -2,7 +2,7 @@
<div
class=
"h-screen overflow-hidden"
>
<a-spin
:loading=
"loading"
style=
"height: 100%;width: 100%;"
>
<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=
"flex flex-col justify-center"
>
...
...
src/views/personalCenter/accountPage/perForgePassword.vue
View file @
c61848ff
...
...
@@ -2,7 +2,7 @@
<div
class=
"h-screen overflow-hidden"
>
<a-spin
:loading=
"loading"
style=
"height: 100%;width: 100%;"
>
<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=
"flex flex-col justify-center"
>
...
...
src/views/personalCenter/accountPage/resetPassword.vue
View file @
c61848ff
...
...
@@ -2,7 +2,7 @@
<div
class=
"h-screen overflow-hidden"
>
<a-spin
:loading=
"loading"
style=
"height: 100%;width: 100%;"
>
<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=
"flex flex-col justify-center"
>
...
...
src/views/personalCenter/index.vue
View file @
c61848ff
<
template
>
<!-- h-screen -->
<div
class=
"flex justify-center pb-[12px] h-[797px]"
>
<div
class=
"h-full flex justify-between w-[12
0
0px]"
>
<div
class=
"h-full flex justify-between w-[12
8
0px]"
>
<!-- 左侧导航栏 -->
<LeftView
class=
"pt-[25px]"
:menu-list=
"menuList"
...
...
src/views/place/Place copy.vue
deleted
100644 → 0
View file @
0ae117a8
This diff is collapsed.
Click to expand it.
src/views/place/Place.vue
View file @
c61848ff
<
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"
>
<!-- Banner 区域骨架 -->
<a-skeleton-shape
shape=
"square"
size=
"large"
class=
"mb-4"
style=
"width: 100%; height: 400px;"
/>
...
...
@@ -38,7 +38,7 @@
</div>
<template
v-else
>
<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"
>
<template
#
item-render=
"
{ route }">
...
...
@@ -193,7 +193,7 @@
<div
v-if=
"heroList.length > 0"
ref=
"cityIntroRef"
class=
"w-full relative bg-cover bg-center"
:style=
"{ backgroundImage: `url(${heroList[0]})` }"
>
<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=
"leading-7 text-sm"
>
<p
v-for=
"value in description.split('\n')"
>
{{ value }}
</p>
...
...
@@ -204,7 +204,7 @@
<!-- 城市FAQ -->
<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>
<div
class=
"text-xl font-bold text-[var(--customColor-text-10)] mb-1"
>
...
...
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