优化:一级菜单移到app
This commit is contained in:
parent
59edb13f53
commit
67d9c204dd
337
src/App.vue
337
src/App.vue
@ -5,6 +5,9 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useUserStore } from './stores/user'
|
||||
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 Toast from './components/Toast.vue'
|
||||
|
||||
@ -13,8 +16,16 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const localeStore = useLocaleStore()
|
||||
const menuStore = useMenuStore()
|
||||
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) {
|
||||
localeStore.setLocale(loc)
|
||||
localeMenuOpen.value = false
|
||||
@ -61,6 +72,39 @@ async function refreshUserData() {
|
||||
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(() => {
|
||||
refreshUserData()
|
||||
})
|
||||
@ -75,55 +119,109 @@ watch(
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<v-app-bar color="surface" elevation="0">
|
||||
<div class="app-bar-inner">
|
||||
<v-btn v-if="currentRoute !== '/'" icon variant="text" class="back-btn" :aria-label="t('common.back')"
|
||||
@click="onBackClick">
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
<v-app-bar-title v-if="currentRoute === '/'" class="brand-title">
|
||||
<div class="brand-lockup">
|
||||
<div class="brand-mark-wrap">
|
||||
<img src="/brand-logo.svg?v=2" alt="Alpha Market logo" class="brand-logo" />
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<span class="brand-kicker">Prediction Markets</span>
|
||||
<span class="brand-name">Alpha Market</span>
|
||||
<v-app-bar color="surface" elevation="0" height="112">
|
||||
<div class="flex-column header-content">
|
||||
<div class="app-bar-inner">
|
||||
<v-btn v-if="currentRoute !== '/'" icon variant="text" class="back-btn" :aria-label="t('common.back')"
|
||||
@click="onBackClick">
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
<v-app-bar-title v-if="currentRoute === '/'" class="brand-title">
|
||||
<div class="brand-lockup">
|
||||
<div class="brand-mark-wrap">
|
||||
<img src="/brand-logo.svg?v=2" alt="Alpha Market logo" class="brand-logo" />
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<span class="brand-kicker">Prediction Markets</span>
|
||||
<span class="brand-name">Alpha Market</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-app-bar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<template v-if="!userStore.isLoggedIn">
|
||||
<v-menu v-model="localeMenuOpen" :close-on-content-click="true" location="bottom"
|
||||
transition="scale-transition">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon variant="text" class="locale-btn" :aria-label="t('profile.selectLanguage')" v-bind="props">
|
||||
<v-icon>mdi-earth</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="opt in localeStore.localeOptions" :key="opt.value"
|
||||
:active="localeStore.currentLocale === opt.value" @click="chooseLocale(opt.value)">
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn text to="/login" :class="{ active: currentRoute === '/login' }">
|
||||
{{ t('common.login') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn class="balance-btn" variant="text" min-width="auto" padding="4 12" @click="$router.push('/wallet')">
|
||||
<span class="balance-text">${{ userStore.totalAssetValue }}</span>
|
||||
</v-btn>
|
||||
<v-btn icon variant="text" class="avatar-btn" :aria-label="t('common.user')"
|
||||
@click="$router.push('/profile')">
|
||||
<v-avatar size="36" color="primary">
|
||||
<v-img v-if="userStore.avatarUrl" :src="userStore.avatarUrl" cover alt="avatar" />
|
||||
<v-icon v-else>mdi-account</v-icon>
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
</template>
|
||||
</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>
|
||||
</v-app-bar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<template v-if="!userStore.isLoggedIn">
|
||||
<v-menu v-model="localeMenuOpen" :close-on-content-click="true" location="bottom"
|
||||
transition="scale-transition">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon variant="text" class="locale-btn" :aria-label="t('profile.selectLanguage')" v-bind="props">
|
||||
<v-icon>mdi-earth</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="opt in localeStore.localeOptions" :key="opt.value"
|
||||
:active="localeStore.currentLocale === opt.value" @click="chooseLocale(opt.value)">
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn text to="/login" :class="{ active: currentRoute === '/login' }">
|
||||
{{ t('common.login') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn class="balance-btn" variant="text" min-width="auto" padding="4 12" @click="$router.push('/wallet')">
|
||||
<span class="balance-text">${{ userStore.totalAssetValue }}</span>
|
||||
</v-btn>
|
||||
<v-btn icon variant="text" class="avatar-btn" :aria-label="t('common.user')"
|
||||
@click="$router.push('/profile')">
|
||||
<v-avatar size="36" color="primary">
|
||||
<v-img v-if="userStore.avatarUrl" :src="userStore.avatarUrl" cover alt="avatar" />
|
||||
<v-icon v-else>mdi-account</v-icon>
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
||||
<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-main class="app-main">
|
||||
@ -161,9 +259,15 @@ watch(
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-bar-inner {
|
||||
<style scoped lang="scss">
|
||||
.header-content {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
height: 112px;
|
||||
}
|
||||
|
||||
.app-bar-inner {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@ -346,4 +450,139 @@ watch(
|
||||
:deep(.v-bottom-navigation__content .v-ripple__container) {
|
||||
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>
|
||||
|
||||
@ -30,3 +30,8 @@ html, body {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -35,10 +35,59 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
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 {
|
||||
categoryTree,
|
||||
layerActiveValues,
|
||||
categoryLayers,
|
||||
filterVisible
|
||||
filterVisible,
|
||||
onCategorySelect,
|
||||
loadEventsCallback,
|
||||
clearCacheCallback
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,66 +1,21 @@
|
||||
<template>
|
||||
<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">
|
||||
<!-- 第二三层:随内容滚动,回顶部时与列表第一行一起出现 -->
|
||||
<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-icon-row">
|
||||
<v-chip v-for="item in categoryLayers[1]" :key="item.id" class="home-category-icon-item"
|
||||
:color="layerActiveValues[1] === item.id ? 'primary' : undefined"
|
||||
: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>
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
@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">
|
||||
{{ item.label }}
|
||||
</v-tab>
|
||||
@ -119,13 +74,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 进入页面时是否自动展开搜索(供 /search 路由使用) */
|
||||
initialSearchExpanded?: boolean
|
||||
}>(),
|
||||
{ initialSearchExpanded: false },
|
||||
)
|
||||
// const props = withDefaults(
|
||||
// defineProps<{
|
||||
// /** 进入页面时是否自动展开搜索(供 /search 路由使用) */
|
||||
// initialSearchExpanded?: boolean
|
||||
// }>(),
|
||||
// { initialSearchExpanded: false },
|
||||
// )
|
||||
|
||||
defineOptions({ name: 'HomePage' })
|
||||
import {
|
||||
@ -158,7 +113,6 @@ import {
|
||||
} from '../api/category'
|
||||
import { USE_MOCK_CATEGORY } from '../config/mock'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSearchHistory } from '../composables/useSearchHistory'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { useLocaleStore } from '../stores/locale'
|
||||
import { useMenuStore } from '../stores/menu'
|
||||
@ -166,58 +120,13 @@ import { storeToRefs } from 'pinia'
|
||||
|
||||
const { mobile } = useDisplay()
|
||||
const { t } = useI18n()
|
||||
const searchHistory = useSearchHistory()
|
||||
const searchHistoryList = computed(() => searchHistory.list.value)
|
||||
const isMobile = computed(() => mobile.value)
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { categoryTree, layerActiveValues, categoryLayers } = storeToRefs(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) {
|
||||
activeSearchKeyword.value = keyword
|
||||
@ -250,39 +159,6 @@ const activeTagIds = computed(() => {
|
||||
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 则递归展开 */
|
||||
function initCategorySelection() {
|
||||
@ -305,7 +181,7 @@ function initCategorySelection() {
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
/** 分类切换时的防抖状态,防止重复请求 */
|
||||
let categorySelectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
/** 接口返回的列表(已映射为卡片所需结构) */
|
||||
const eventList = ref<EventCardItem[]>([])
|
||||
@ -457,8 +333,21 @@ function updateGridColumns() {
|
||||
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(公开接口,无需鉴权);成功后会更新内存缓存 */
|
||||
async function loadEvents(page: number, append: boolean, keyword?: string) {
|
||||
@ -538,7 +427,8 @@ onBeforeRouteLeave(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.initialSearchExpanded) expandSearch()
|
||||
// 如果有初始化要求,可以调用相关方法或 emit 事件
|
||||
// if (props.initialSearchExpanded) expandSearch()
|
||||
loadCategory()
|
||||
nextTick(() => {
|
||||
const scrollEl = getMainScrollEl()
|
||||
@ -605,17 +495,13 @@ onActivated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* fluid 后无断点 max-width,用自定义 max-width 让列表在 2137px 等宽屏下能算到 6 列 */
|
||||
.home-container {
|
||||
min-height: 100vh;
|
||||
max-width: 2560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
|
||||
.home-header {
|
||||
margin-bottom: 20px;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
margin: 112px 0 0 0;
|
||||
}
|
||||
|
||||
.home-title {
|
||||
@ -732,165 +618,6 @@ onActivated(() => {
|
||||
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 {
|
||||
@ -1060,11 +787,9 @@ onActivated(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面布局:flex 列 */
|
||||
.home-page {
|
||||
margin-top: 112px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user