优化:搜索功能
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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user