优化:一级菜单移到app
This commit is contained in:
parent
59edb13f53
commit
67d9c204dd
245
src/App.vue
245
src/App.vue
@ -5,6 +5,9 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useUserStore } from './stores/user'
|
import { useUserStore } from './stores/user'
|
||||||
import { useLocaleStore } from './stores/locale'
|
import { useLocaleStore } from './stores/locale'
|
||||||
|
import { useMenuStore } from './stores/menu'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useSearchHistory } from './composables/useSearchHistory'
|
||||||
import type { LocaleCode } from './plugins/i18n'
|
import type { LocaleCode } from './plugins/i18n'
|
||||||
import Toast from './components/Toast.vue'
|
import Toast from './components/Toast.vue'
|
||||||
|
|
||||||
@ -13,8 +16,16 @@ const { t } = useI18n()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const localeStore = useLocaleStore()
|
const localeStore = useLocaleStore()
|
||||||
|
const menuStore = useMenuStore()
|
||||||
const localeMenuOpen = ref(false)
|
const localeMenuOpen = ref(false)
|
||||||
|
|
||||||
|
const { categoryLayers, layerActiveValues } = storeToRefs(menuStore)
|
||||||
|
const searchHistory = useSearchHistory()
|
||||||
|
const searchHistoryList = computed(() => searchHistory.list.value)
|
||||||
|
const searchExpanded = ref(false)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const searchInputRef = ref<{ focus: () => void } | null>(null)
|
||||||
|
|
||||||
function chooseLocale(loc: LocaleCode) {
|
function chooseLocale(loc: LocaleCode) {
|
||||||
localeStore.setLocale(loc)
|
localeStore.setLocale(loc)
|
||||||
localeMenuOpen.value = false
|
localeMenuOpen.value = false
|
||||||
@ -61,6 +72,39 @@ async function refreshUserData() {
|
|||||||
await userStore.fetchPositionsValue()
|
await userStore.fetchPositionsValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expandSearch() {
|
||||||
|
searchExpanded.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
searchInputRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseSearch() {
|
||||||
|
searchExpanded.value = false
|
||||||
|
searchKeyword.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchBlur() {
|
||||||
|
setTimeout(() => {
|
||||||
|
collapseSearch()
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchSubmit() {
|
||||||
|
const kw = searchKeyword.value.trim()
|
||||||
|
if (kw) {
|
||||||
|
searchHistory.add(kw)
|
||||||
|
// 触发搜索事件的逻辑,可以利用 store 或者 router 跳转
|
||||||
|
router.push({ path: '/', query: { q: kw } })
|
||||||
|
}
|
||||||
|
collapseSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectHistoryItem(item: string) {
|
||||||
|
searchKeyword.value = item
|
||||||
|
onSearchSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshUserData()
|
refreshUserData()
|
||||||
})
|
})
|
||||||
@ -75,7 +119,8 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app>
|
||||||
<v-app-bar color="surface" elevation="0">
|
<v-app-bar color="surface" elevation="0" height="112">
|
||||||
|
<div class="flex-column header-content">
|
||||||
<div class="app-bar-inner">
|
<div class="app-bar-inner">
|
||||||
<v-btn v-if="currentRoute !== '/'" icon variant="text" class="back-btn" :aria-label="t('common.back')"
|
<v-btn v-if="currentRoute !== '/'" icon variant="text" class="back-btn" :aria-label="t('common.back')"
|
||||||
@click="onBackClick">
|
@click="onBackClick">
|
||||||
@ -92,7 +137,9 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-app-bar-title>
|
</v-app-bar-title>
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
<template v-if="!userStore.isLoggedIn">
|
<template v-if="!userStore.isLoggedIn">
|
||||||
<v-menu v-model="localeMenuOpen" :close-on-content-click="true" location="bottom"
|
<v-menu v-model="localeMenuOpen" :close-on-content-click="true" location="bottom"
|
||||||
transition="scale-transition">
|
transition="scale-transition">
|
||||||
@ -125,6 +172,57 @@ watch(
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 提取的顶部菜单栏与搜索功能 -->
|
||||||
|
<div v-if="currentRoute === '/' && categoryLayers.length > 0" class="home-category-layer1-wrap">
|
||||||
|
<div class="home-category-layer1-row">
|
||||||
|
<v-tabs :model-value="layerActiveValues[0]" class="home-tab-bar home-tab-bar--inline" height="48"
|
||||||
|
@update:model-value="menuStore.onCategorySelect(0, $event)">
|
||||||
|
<v-tab v-for="item in categoryLayers[0]" :key="item.id" :value="item.id" :ripple="false" >
|
||||||
|
{{ item.label }}
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
<v-btn icon variant="text" class="home-search-btn" :aria-label="t('common.search')" @click="expandSearch">
|
||||||
|
<v-icon size="24">mdi-magnify</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
||||||
|
<transition name="home-search-overlay">
|
||||||
|
<div v-if="searchExpanded" class="home-search-overlay">
|
||||||
|
<div class="home-search-overlay-inner">
|
||||||
|
<v-text-field ref="searchInputRef" v-model="searchKeyword" density="compact" hide-details
|
||||||
|
:placeholder="t('home.searchPlaceholder')" prepend-inner-icon="mdi-magnify" variant="outlined"
|
||||||
|
class="home-search-overlay-field" @blur="onSearchBlur" @keydown.enter="onSearchSubmit" />
|
||||||
|
<v-btn icon variant="text" size="small" class="home-search-close-btn" :aria-label="t('common.collapse')"
|
||||||
|
@click="collapseSearch">
|
||||||
|
<v-icon size="22">mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
<div v-if="searchHistoryList.length > 0" class="home-search-history">
|
||||||
|
<div class="home-search-history-header">
|
||||||
|
<span class="home-search-history-title">{{ t('home.searchHistory') }}</span>
|
||||||
|
<v-btn variant="text" size="x-small" color="primary" class="home-search-history-clear"
|
||||||
|
@click="searchHistory.clearAll">
|
||||||
|
{{ t('common.clear') }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
<ul class="home-search-history-list">
|
||||||
|
<li v-for="(item, idx) in searchHistoryList" :key="`${item}-${idx}`" class="home-search-history-item">
|
||||||
|
<button type="button" class="home-search-history-text" @click="selectHistoryItem(item)">
|
||||||
|
{{ item }}
|
||||||
|
</button>
|
||||||
|
<v-btn icon variant="text" size="x-small" class="home-search-history-delete"
|
||||||
|
:aria-label="t('common.delete')" @click.stop="searchHistory.remove(idx)">
|
||||||
|
<v-icon size="20">mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-main class="app-main">
|
<v-main class="app-main">
|
||||||
<div class="app-main-scroll" data-main-scroll>
|
<div class="app-main-scroll" data-main-scroll>
|
||||||
@ -161,9 +259,15 @@ watch(
|
|||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.app-bar-inner {
|
.header-content {
|
||||||
|
width: 100%;
|
||||||
max-width: 1440px;
|
max-width: 1440px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
height: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar-inner {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -346,4 +450,139 @@ watch(
|
|||||||
:deep(.v-bottom-navigation__content .v-ripple__container) {
|
:deep(.v-bottom-navigation__content .v-ripple__container) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 第一层:置顶、全宽;sticky 参照 app-main-scroll,top:0 贴容器顶(即 app-bar 下方) */
|
||||||
|
.home-category-layer1-wrap {
|
||||||
|
width: 100vw;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-category-layer1-row {
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-tab-bar--inline {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-tab-bar--inline :deep(.v-tabs) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-tab-bar--inline :deep(.v-tab) {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 8px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-category-layer1-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-overlay-enter-active,
|
||||||
|
.home-search-overlay-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-overlay-enter-from,
|
||||||
|
.home-search-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tabs-height {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #fff;
|
||||||
|
z-index: 2000;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-overlay-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-overlay-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-history {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-history-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-history-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-history-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-history-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-history-text {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(0, 0, 0, 0.87);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.home-category-layer1-wrap {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -30,3 +30,8 @@ html, body {
|
|||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
@ -35,10 +35,59 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
return layers
|
return layers
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 分类切换时的防抖状态,防止重复请求 */
|
||||||
|
let categorySelectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
/** 触发事件加载的回调函数(由 Home 页面注册) */
|
||||||
|
const loadEventsCallback = ref<(() => void) | null>(null)
|
||||||
|
|
||||||
|
/** 触发清除事件列表缓存的回调函数(由 Home 页面注册) */
|
||||||
|
const clearCacheCallback = ref<(() => void) | null>(null)
|
||||||
|
|
||||||
|
/** 分类选中时:若有 children 则展开下一层并默认选中第一个,并重新加载列表 */
|
||||||
|
function onCategorySelect(layerIndex: number, selectedId: string) {
|
||||||
|
if (!selectedId || layerActiveValues.value[layerIndex] === selectedId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categorySelectTimer) {
|
||||||
|
clearTimeout(categorySelectTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValues = [...layerActiveValues.value]
|
||||||
|
nextValues[layerIndex] = selectedId
|
||||||
|
nextValues.length = layerIndex + 1
|
||||||
|
|
||||||
|
const layers = categoryLayers.value
|
||||||
|
const layer = layers[layerIndex]
|
||||||
|
const node = layer?.find((n) => n.id === selectedId)
|
||||||
|
const children = filterVisible(node?.children)
|
||||||
|
|
||||||
|
const firstChild = children[0]
|
||||||
|
if (firstChild && layerIndex < 2) {
|
||||||
|
nextValues.push(firstChild.id)
|
||||||
|
}
|
||||||
|
layerActiveValues.value = nextValues
|
||||||
|
|
||||||
|
if (clearCacheCallback.value) {
|
||||||
|
clearCacheCallback.value()
|
||||||
|
}
|
||||||
|
|
||||||
|
categorySelectTimer = setTimeout(() => {
|
||||||
|
categorySelectTimer = null
|
||||||
|
if (loadEventsCallback.value) {
|
||||||
|
loadEventsCallback.value()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categoryTree,
|
categoryTree,
|
||||||
layerActiveValues,
|
layerActiveValues,
|
||||||
categoryLayers,
|
categoryLayers,
|
||||||
filterVisible
|
filterVisible,
|
||||||
|
onCategorySelect,
|
||||||
|
loadEventsCallback,
|
||||||
|
clearCacheCallback
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,66 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<!-- 第一层:单行紧凑布局,tabs + 搜索/筛选图标 -->
|
|
||||||
<div v-if="categoryLayers.length > 0" class="home-category-layer1-wrap">
|
|
||||||
<div class="home-category-layer1-row">
|
|
||||||
<v-tabs :model-value="layerActiveValues[0]" class="home-tab-bar home-tab-bar--inline"
|
|
||||||
@update:model-value="onCategorySelect(0, $event)">
|
|
||||||
<v-tab v-for="item in categoryLayers[0]" :key="item.id" :value="item.id" :ripple="false">
|
|
||||||
{{ item.label }}
|
|
||||||
</v-tab>
|
|
||||||
</v-tabs>
|
|
||||||
</div>
|
|
||||||
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
|
||||||
<transition name="home-search-overlay">
|
|
||||||
<div v-if="searchExpanded" class="home-search-overlay">
|
|
||||||
<div class="home-search-overlay-inner">
|
|
||||||
<v-text-field ref="searchInputRef" v-model="searchKeyword" density="compact" hide-details
|
|
||||||
:placeholder="t('home.searchPlaceholder')" prepend-inner-icon="mdi-magnify" variant="outlined"
|
|
||||||
class="home-search-overlay-field" @blur="onSearchBlur" @keydown.enter="onSearchSubmit" />
|
|
||||||
<v-btn icon variant="text" size="small" class="home-search-close-btn" :aria-label="t('common.collapse')"
|
|
||||||
@click="collapseSearch">
|
|
||||||
<v-icon size="22">mdi-close</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
<div v-if="searchHistoryList.length > 0" class="home-search-history">
|
|
||||||
<div class="home-search-history-header">
|
|
||||||
<span class="home-search-history-title">{{ t('home.searchHistory') }}</span>
|
|
||||||
<v-btn variant="text" size="x-small" color="primary" class="home-search-history-clear"
|
|
||||||
@click="searchHistory.clearAll">
|
|
||||||
{{ t('common.clear') }}
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
<ul class="home-search-history-list">
|
|
||||||
<li v-for="(item, idx) in searchHistoryList" :key="`${item}-${idx}`" class="home-search-history-item">
|
|
||||||
<button type="button" class="home-search-history-text" @click="selectHistoryItem(item)">
|
|
||||||
{{ item }}
|
|
||||||
</button>
|
|
||||||
<v-btn icon variant="text" size="x-small" class="home-search-history-delete"
|
|
||||||
:aria-label="t('common.delete')" @click.stop="searchHistory.remove(idx)">
|
|
||||||
<v-icon size="20">mdi-close</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
<v-container fluid class="home-container">
|
<v-container fluid class="home-container">
|
||||||
<!-- 第二三层:随内容滚动,回顶部时与列表第一行一起出现 -->
|
<!-- 第二三层:随内容滚动,回顶部时与列表第一行一起出现 -->
|
||||||
<div v-if="categoryLayers.length >= 2" class="home-category-layers-23-scroll">
|
<div v-if="categoryLayers.length > 1" class="home-category-layers-23-scroll">
|
||||||
<div class="home-category-layer home-category-layer--icon">
|
<div class="home-category-layer home-category-layer--icon">
|
||||||
<div class="home-category-icon-row">
|
<div class="home-category-icon-row">
|
||||||
<v-chip v-for="item in categoryLayers[1]" :key="item.id" class="home-category-icon-item"
|
<v-chip v-for="item in categoryLayers[1]" :key="item.id" class="home-category-icon-item"
|
||||||
:color="layerActiveValues[1] === item.id ? 'primary' : undefined"
|
:color="layerActiveValues[1] === item.id ? 'primary' : undefined"
|
||||||
:variant="layerActiveValues[1] === item.id ? 'tonal' : 'outlined'" size="small"
|
:variant="layerActiveValues[1] === item.id ? 'tonal' : 'outlined'" size="small"
|
||||||
@click="onCategorySelect(1, item.id)">
|
@click="menuStore.onCategorySelect(1, item.id)">
|
||||||
<span class="home-category-icon-label">{{ item.label }}</span>
|
<span class="home-category-icon-label">{{ item.label }}</span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="categoryLayers.length >= 3" class="home-category-layer home-category-layer--third">
|
<div v-if="categoryLayers.length >= 3" class="home-category-layer home-category-layer--third">
|
||||||
<v-tabs :model-value="layerActiveValues[2]" class="home-tab-bar home-tab-bar--compact"
|
<v-tabs :model-value="layerActiveValues[2]" class="home-tab-bar home-tab-bar--compact"
|
||||||
@update:model-value="onCategorySelect(2, $event)">
|
@update:model-value="menuStore.onCategorySelect(2, $event)">
|
||||||
<v-tab v-for="item in categoryLayers[2]" :key="item.id" :value="item.id" :ripple="false">
|
<v-tab v-for="item in categoryLayers[2]" :key="item.id" :value="item.id" :ripple="false">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</v-tab>
|
</v-tab>
|
||||||
@ -119,13 +74,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = withDefaults(
|
// const props = withDefaults(
|
||||||
defineProps<{
|
// defineProps<{
|
||||||
/** 进入页面时是否自动展开搜索(供 /search 路由使用) */
|
// /** 进入页面时是否自动展开搜索(供 /search 路由使用) */
|
||||||
initialSearchExpanded?: boolean
|
// initialSearchExpanded?: boolean
|
||||||
}>(),
|
// }>(),
|
||||||
{ initialSearchExpanded: false },
|
// { initialSearchExpanded: false },
|
||||||
)
|
// )
|
||||||
|
|
||||||
defineOptions({ name: 'HomePage' })
|
defineOptions({ name: 'HomePage' })
|
||||||
import {
|
import {
|
||||||
@ -158,7 +113,6 @@ import {
|
|||||||
} from '../api/category'
|
} from '../api/category'
|
||||||
import { USE_MOCK_CATEGORY } from '../config/mock'
|
import { USE_MOCK_CATEGORY } from '../config/mock'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useSearchHistory } from '../composables/useSearchHistory'
|
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import { useLocaleStore } from '../stores/locale'
|
import { useLocaleStore } from '../stores/locale'
|
||||||
import { useMenuStore } from '../stores/menu'
|
import { useMenuStore } from '../stores/menu'
|
||||||
@ -166,58 +120,13 @@ import { storeToRefs } from 'pinia'
|
|||||||
|
|
||||||
const { mobile } = useDisplay()
|
const { mobile } = useDisplay()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const searchHistory = useSearchHistory()
|
|
||||||
const searchHistoryList = computed(() => searchHistory.list.value)
|
|
||||||
const isMobile = computed(() => mobile.value)
|
const isMobile = computed(() => mobile.value)
|
||||||
|
|
||||||
const menuStore = useMenuStore()
|
const menuStore = useMenuStore()
|
||||||
const { categoryTree, layerActiveValues, categoryLayers } = storeToRefs(menuStore)
|
const { categoryTree, layerActiveValues, categoryLayers } = storeToRefs(menuStore)
|
||||||
const { filterVisible } = menuStore
|
const { filterVisible } = menuStore
|
||||||
|
|
||||||
/** 第三层搜索框是否展开 */
|
|
||||||
const searchExpanded = ref(false)
|
|
||||||
/** 搜索关键词 */
|
|
||||||
const searchKeyword = ref('')
|
|
||||||
const searchInputRef = ref<{ focus: () => void } | null>(null)
|
|
||||||
|
|
||||||
function expandSearch() {
|
|
||||||
searchExpanded.value = true
|
|
||||||
nextTick(() => {
|
|
||||||
const el = searchInputRef.value as { focus?: () => void } | null
|
|
||||||
el?.focus?.()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseSearch() {
|
|
||||||
searchExpanded.value = false
|
|
||||||
searchKeyword.value = ''
|
|
||||||
if (activeSearchKeyword.value) {
|
|
||||||
activeSearchKeyword.value = ''
|
|
||||||
clearEventListCache()
|
|
||||||
eventPage.value = 1
|
|
||||||
loadEvents(1, false, '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearchBlur() {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!searchKeyword.value.trim()) searchExpanded.value = false
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearchSubmit() {
|
|
||||||
const k = searchKeyword.value.trim()
|
|
||||||
if (k) {
|
|
||||||
searchHistory.add(k)
|
|
||||||
doSearch(k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectHistoryItem(item: string) {
|
|
||||||
searchKeyword.value = item
|
|
||||||
searchHistory.add(item)
|
|
||||||
doSearch(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
function doSearch(keyword: string) {
|
function doSearch(keyword: string) {
|
||||||
activeSearchKeyword.value = keyword
|
activeSearchKeyword.value = keyword
|
||||||
@ -250,39 +159,6 @@ const activeTagIds = computed(() => {
|
|||||||
return Array.from(tagIdSet)
|
return Array.from(tagIdSet)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 分类选中时:若有 children 则展开下一层并默认选中第一个,并重新加载列表 */
|
|
||||||
function onCategorySelect(layerIndex: number, selectedId: string) {
|
|
||||||
if (!selectedId || layerActiveValues.value[layerIndex] === selectedId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (categorySelectTimer) {
|
|
||||||
clearTimeout(categorySelectTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextValues = [...layerActiveValues.value]
|
|
||||||
nextValues[layerIndex] = selectedId
|
|
||||||
nextValues.length = layerIndex + 1
|
|
||||||
|
|
||||||
const layers = categoryLayers.value
|
|
||||||
const layer = layers[layerIndex]
|
|
||||||
const node = layer?.find((n) => n.id === selectedId)
|
|
||||||
const children = filterVisible(node?.children)
|
|
||||||
|
|
||||||
const firstChild = children[0]
|
|
||||||
if (firstChild && layerIndex < 2) {
|
|
||||||
nextValues.push(firstChild.id)
|
|
||||||
}
|
|
||||||
layerActiveValues.value = nextValues
|
|
||||||
|
|
||||||
clearEventListCache()
|
|
||||||
eventPage.value = 1
|
|
||||||
|
|
||||||
categorySelectTimer = setTimeout(() => {
|
|
||||||
categorySelectTimer = null
|
|
||||||
loadEvents(1, false)
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 初始化分类选中:默认选中第一个,若有 children 则递归展开 */
|
/** 初始化分类选中:默认选中第一个,若有 children 则递归展开 */
|
||||||
function initCategorySelection() {
|
function initCategorySelection() {
|
||||||
@ -305,7 +181,7 @@ function initCategorySelection() {
|
|||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
/** 分类切换时的防抖状态,防止重复请求 */
|
/** 分类切换时的防抖状态,防止重复请求 */
|
||||||
let categorySelectTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
/** 接口返回的列表(已映射为卡片所需结构) */
|
/** 接口返回的列表(已映射为卡片所需结构) */
|
||||||
const eventList = ref<EventCardItem[]>([])
|
const eventList = ref<EventCardItem[]>([])
|
||||||
@ -457,8 +333,21 @@ function updateGridColumns() {
|
|||||||
gridColumns.value = Math.max(1, n)
|
gridColumns.value = Math.max(1, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
/** 当前生效的搜索关键词(用于分页加载) */
|
/** 当前生效的搜索关键词(用于分页加载) */
|
||||||
const activeSearchKeyword = ref('')
|
const activeSearchKeyword = ref((route.query.q as string) || '')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.q,
|
||||||
|
(newQ) => {
|
||||||
|
if (newQ !== undefined) {
|
||||||
|
doSearch(newQ as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/** 请求事件列表并追加或覆盖到 eventList(公开接口,无需鉴权);成功后会更新内存缓存 */
|
/** 请求事件列表并追加或覆盖到 eventList(公开接口,无需鉴权);成功后会更新内存缓存 */
|
||||||
async function loadEvents(page: number, append: boolean, keyword?: string) {
|
async function loadEvents(page: number, append: boolean, keyword?: string) {
|
||||||
@ -538,7 +427,8 @@ onBeforeRouteLeave(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.initialSearchExpanded) expandSearch()
|
// 如果有初始化要求,可以调用相关方法或 emit 事件
|
||||||
|
// if (props.initialSearchExpanded) expandSearch()
|
||||||
loadCategory()
|
loadCategory()
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const scrollEl = getMainScrollEl()
|
const scrollEl = getMainScrollEl()
|
||||||
@ -605,17 +495,13 @@ onActivated(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
/* fluid 后无断点 max-width,用自定义 max-width 让列表在 2137px 等宽屏下能算到 6 列 */
|
|
||||||
.home-container {
|
.home-container {
|
||||||
min-height: 100vh;
|
flex: 1 1 0;
|
||||||
max-width: 2560px;
|
display: flex;
|
||||||
margin-left: auto;
|
flex-direction: column;
|
||||||
margin-right: auto;
|
min-height: 0;
|
||||||
padding: 0 8px !important;
|
padding: 0;
|
||||||
}
|
margin: 112px 0 0 0;
|
||||||
|
|
||||||
.home-header {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-title {
|
.home-title {
|
||||||
@ -732,165 +618,6 @@ onActivated(() => {
|
|||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 第一层:置顶、全宽;sticky 参照 app-main-scroll,top:0 贴容器顶(即 app-bar 下方) */
|
|
||||||
.home-category-layer1-wrap {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 10;
|
|
||||||
top: 64px;
|
|
||||||
width: 100vw;
|
|
||||||
background-color: white;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-category-layer1-row {
|
|
||||||
max-width: 1440px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
@include auto-space(padding-left);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-tab-bar--inline {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-tab-bar--inline :deep(.v-tabs) {
|
|
||||||
min-height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-tab-bar--inline :deep(.v-tab) {
|
|
||||||
min-height: 48px;
|
|
||||||
padding: 0 8px 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-category-layer1-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索浮层:绝对定位,遮挡第二三层,不顶开布局 */
|
|
||||||
.home-search-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 11;
|
|
||||||
padding: 8px 12px 12px;
|
|
||||||
background-color: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-overlay-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-overlay-field {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-close-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-overlay-field :deep(.v-field) {
|
|
||||||
background-color: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-overlay-field :deep(.v-field__prepend-inner .v-icon) {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-overlay-enter-active,
|
|
||||||
.home-search-overlay-leave-active {
|
|
||||||
transition:
|
|
||||||
opacity 0.15s ease,
|
|
||||||
transform 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-overlay-enter-from,
|
|
||||||
.home-search-overlay-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-title {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-clear {
|
|
||||||
min-width: auto;
|
|
||||||
padding: 0 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
max-height: 160px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 6px 0;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #1e293b;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-text:hover {
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-delete {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 28px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-search-history-delete:hover {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 第二三层:随内容滚动,全宽 */
|
/* 第二三层:随内容滚动,全宽 */
|
||||||
.home-category-layers-23-scroll {
|
.home-category-layers-23-scroll {
|
||||||
@ -1060,11 +787,9 @@ onActivated(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 页面布局:flex 列 */
|
|
||||||
.home-page {
|
.home-page {
|
||||||
margin-top: 112px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user