优化:搜索功能

This commit is contained in:
ivan 2026-02-13 21:01:43 +08:00
parent c1e56cc07f
commit f83f0100e0
2 changed files with 325 additions and 36 deletions

View File

@ -0,0 +1,48 @@
import { ref, readonly } from 'vue'
const STORAGE_KEY = 'polyclient_search_history'
const MAX_HISTORY = 10
const history = ref<string[]>([])
function loadHistory() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
history.value = raw ? JSON.parse(raw) : []
} catch {
history.value = []
}
}
function saveHistory() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.value))
} catch {
// ignore
}
}
export function useSearchHistory() {
const list = readonly(history)
function add(keyword: string) {
const k = keyword.trim()
if (!k) return
history.value = [k, ...history.value.filter((h) => h !== k)].slice(0, MAX_HISTORY)
saveHistory()
}
function remove(index: number) {
history.value = history.value.filter((_, i) => i !== index)
saveHistory()
}
function clearAll() {
history.value = []
saveHistory()
}
loadHistory()
return { list, add, remove, clearAll }
}

View File

@ -1,19 +1,91 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<!-- 第一层置顶全宽始终可见 --> <!-- 第一层单行紧凑布局tabs + 搜索/筛选图标 -->
<div v-if="categoryLayers.length > 0" class="home-category-layer1-wrap"> <div v-if="categoryLayers.length > 0" class="home-category-layer1-wrap">
<div class="home-category-layer home-category-layer--text"> <div class="home-category-layer1-row">
<v-tabs <v-tabs
:model-value="layerActiveValues[0]" :model-value="layerActiveValues[0]"
class="home-tab-bar" class="home-tab-bar home-tab-bar--inline"
@update:model-value="onCategorySelect(0, $event)" @update:model-value="onCategorySelect(0, $event)"
> >
<v-tab v-for="item in categoryLayers[0]" :key="item.id" :value="item.id"> <v-tab v-for="item in categoryLayers[0]" :key="item.id" :value="item.id">
{{ item.label }} {{ item.label }}
</v-tab> </v-tab>
</v-tabs> </v-tabs>
<div class="home-category-layer1-actions">
<v-btn
icon
variant="text"
size="small"
class="home-category-action-btn"
aria-label="搜索"
@click="expandSearch"
>
<v-icon size="20">mdi-magnify</v-icon>
</v-btn>
<v-btn icon variant="text" size="small" class="home-category-action-btn" aria-label="筛选">
<v-icon size="20">mdi-filter-outline</v-icon>
</v-btn>
</div> </div>
</div> </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="Search"
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="收起" @click="collapseSearch">
<v-icon size="18">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">搜索历史</span>
<v-btn
variant="text"
size="x-small"
color="primary"
class="home-search-history-clear"
@click="searchHistory.clearAll"
>
清空
</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="删除"
@click.stop="searchHistory.remove(idx)"
>
<v-icon size="16">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 >= 2" class="home-category-layers-23-scroll">
@ -34,17 +106,6 @@
</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">
<div class="home-category-third-header">
<h3 class="home-category-third-title">{{ selectedLayer0SectionTitle }}</h3>
<div class="home-category-third-actions">
<v-btn icon variant="text" size="small" class="home-category-action-btn" aria-label="搜索">
<v-icon size="20">mdi-magnify</v-icon>
</v-btn>
<v-btn icon variant="text" size="small" class="home-category-action-btn" aria-label="筛选">
<v-icon size="20">mdi-filter-outline</v-icon>
</v-btn>
</div>
</div>
<v-tabs <v-tabs
:model-value="layerActiveValues[2]" :model-value="layerActiveValues[2]"
class="home-tab-bar home-tab-bar--compact" class="home-tab-bar home-tab-bar--compact"
@ -227,14 +288,68 @@ import {
type EventCardItem, type EventCardItem,
} from '../api/event' } from '../api/event'
import { getCategoryTree, MOCK_CATEGORY_TREE, type CategoryTreeNode } from '../api/category' import { getCategoryTree, MOCK_CATEGORY_TREE, type CategoryTreeNode } from '../api/category'
import { useSearchHistory } from '../composables/useSearchHistory'
const { mobile } = useDisplay() const { mobile } = useDisplay()
const searchHistory = useSearchHistory()
const searchHistoryList = computed(() => searchHistory.list.value)
const isMobile = computed(() => mobile.value) const isMobile = computed(() => mobile.value)
/** 分类树(顶层) */ /** 分类树(顶层) */
const categoryTree = ref<CategoryTreeNode[]>([]) const categoryTree = ref<CategoryTreeNode[]>([])
/** 每层选中的 id[layer0, layer1?, layer2?] */ /** 每层选中的 id[layer0, layer1?, layer2?] */
const layerActiveValues = ref<string[]>([]) const layerActiveValues = ref<string[]>([])
/** 第三层搜索框是否展开 */
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
clearEventListCache()
eventPage.value = 1
loadEvents(1, false, keyword)
}
/** 过滤 forceHide 的节点 */ /** 过滤 forceHide 的节点 */
function filterVisible(nodes: CategoryTreeNode[] | undefined): CategoryTreeNode[] { function filterVisible(nodes: CategoryTreeNode[] | undefined): CategoryTreeNode[] {
@ -370,11 +485,15 @@ function updateGridColumns() {
gridColumns.value = Math.max(1, n) gridColumns.value = Math.max(1, n)
} }
/** 当前生效的搜索关键词(用于分页加载) */
const activeSearchKeyword = ref('')
/** 请求事件列表并追加或覆盖到 eventList公开接口无需鉴权成功后会更新内存缓存 */ /** 请求事件列表并追加或覆盖到 eventList公开接口无需鉴权成功后会更新内存缓存 */
async function loadEvents(page: number, append: boolean) { async function loadEvents(page: number, append: boolean, keyword?: string) {
const kw = keyword !== undefined ? keyword : activeSearchKeyword.value
try { try {
const res = await getPmEventPublic( const res = await getPmEventPublic(
{ page, pageSize: PAGE_SIZE } { page, pageSize: PAGE_SIZE, keyword: kw || undefined }
) )
if (res.code !== 0 && res.code !== 200) { if (res.code !== 0 && res.code !== 200) {
throw new Error(res.msg || '请求失败') throw new Error(res.msg || '请求失败')
@ -408,7 +527,7 @@ async function loadEvents(page: number, append: boolean) {
function onRefresh({ done }: { done: () => void }) { function onRefresh({ done }: { done: () => void }) {
clearEventListCache() clearEventListCache()
eventPage.value = 1 eventPage.value = 1
loadEvents(1, false).finally(() => done()) loadEvents(1, false, activeSearchKeyword.value).finally(() => done())
} }
function loadMore() { function loadMore() {
@ -623,6 +742,147 @@ onUnmounted(() => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.home-category-layer1-row {
display: flex;
align-items: center;
min-height: 48px;
}
.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;
}
.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-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 {
width: 100vw; width: 100vw;
@ -707,25 +967,6 @@ onUnmounted(() => {
padding: 12px 16px 0; padding: 12px 16px 0;
} }
.home-category-third-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.home-category-third-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #1e293b;
}
.home-category-third-actions {
display: flex;
gap: 4px;
}
.home-category-action-btn { .home-category-action-btn {
min-width: 36px; min-width: 36px;
} }