优化:搜索功能
This commit is contained in:
parent
c1e56cc07f
commit
f83f0100e0
48
src/composables/useSearchHistory.ts
Normal file
48
src/composables/useSearchHistory.ts
Normal 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 }
|
||||
}
|
||||
@ -1,18 +1,90 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- 第一层:置顶、全宽,始终可见 -->
|
||||
<!-- 第一层:单行紧凑布局,tabs + 搜索/筛选图标 -->
|
||||
<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
|
||||
:model-value="layerActiveValues[0]"
|
||||
class="home-tab-bar"
|
||||
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">
|
||||
{{ item.label }}
|
||||
</v-tab>
|
||||
</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>
|
||||
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
||||
<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">
|
||||
<!-- 第二三层:随内容滚动,回顶部时与列表第一行一起出现 -->
|
||||
@ -34,17 +106,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
:model-value="layerActiveValues[2]"
|
||||
class="home-tab-bar home-tab-bar--compact"
|
||||
@ -227,14 +288,68 @@ import {
|
||||
type EventCardItem,
|
||||
} from '../api/event'
|
||||
import { getCategoryTree, MOCK_CATEGORY_TREE, type CategoryTreeNode } from '../api/category'
|
||||
import { useSearchHistory } from '../composables/useSearchHistory'
|
||||
|
||||
const { mobile } = useDisplay()
|
||||
const searchHistory = useSearchHistory()
|
||||
const searchHistoryList = computed(() => searchHistory.list.value)
|
||||
const isMobile = computed(() => mobile.value)
|
||||
|
||||
/** 分类树(顶层) */
|
||||
const categoryTree = ref<CategoryTreeNode[]>([])
|
||||
/** 每层选中的 id,[layer0, layer1?, layer2?] */
|
||||
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 的节点 */
|
||||
function filterVisible(nodes: CategoryTreeNode[] | undefined): CategoryTreeNode[] {
|
||||
@ -370,11 +485,15 @@ function updateGridColumns() {
|
||||
gridColumns.value = Math.max(1, n)
|
||||
}
|
||||
|
||||
/** 当前生效的搜索关键词(用于分页加载) */
|
||||
const activeSearchKeyword = ref('')
|
||||
|
||||
/** 请求事件列表并追加或覆盖到 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 {
|
||||
const res = await getPmEventPublic(
|
||||
{ page, pageSize: PAGE_SIZE }
|
||||
{ page, pageSize: PAGE_SIZE, keyword: kw || undefined }
|
||||
)
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
throw new Error(res.msg || '请求失败')
|
||||
@ -408,7 +527,7 @@ async function loadEvents(page: number, append: boolean) {
|
||||
function onRefresh({ done }: { done: () => void }) {
|
||||
clearEventListCache()
|
||||
eventPage.value = 1
|
||||
loadEvents(1, false).finally(() => done())
|
||||
loadEvents(1, false, activeSearchKeyword.value).finally(() => done())
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
@ -623,6 +742,147 @@ onUnmounted(() => {
|
||||
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 {
|
||||
width: 100vw;
|
||||
@ -707,25 +967,6 @@ onUnmounted(() => {
|
||||
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 {
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user