feat(search): 筛选/排序改为图标下拉 + 结果数左对齐 + 最近搜索历史

1) 快捷筛选与排序由横向标签改为 🎚️/↕️ 图标按钮(表格右上角靠右),
点击弹出下拉菜单,选中项高亮带 ✓;2) 「共找到 N 个包」移入表格工具栏
最左侧,与表格左对齐;3) 搜索框下方展示最近 5 条搜索历史
(localStorage 持久化),回车/点搜索按钮记录,点历史 chip 直接检索,
可一键清除。点击外部自动关闭下拉。
This commit is contained in:
陈银军
2026-09-19 22:11:55 +08:00
parent a7dc1b7a17
commit dd05764806
+178 -42
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import PackageDrawer from '../components/PackageDrawer.vue' import PackageDrawer from '../components/PackageDrawer.vue'
import CleanDialog from '../components/CleanDialog.vue' import CleanDialog from '../components/CleanDialog.vue'
@@ -15,12 +15,62 @@ const statusFilter = ref('all') // all | warn | ok | ignored | broken
const sortMode = ref('default') // default | versions-desc | size-desc const sortMode = ref('default') // default | versions-desc | size-desc
let debounceTimer = null let debounceTimer = null
// —— 搜索历史(localStorage 最近 5 条,提交时记录) ——
const HISTORY_KEY = 'vcm-search-history'
const searchHistory = ref(JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'))
function addHistory(kw) {
const k = (kw || '').trim()
if (!k) return
const h = [k, ...searchHistory.value.filter(x => x !== k)].slice(0, 5)
searchHistory.value = h
localStorage.setItem(HISTORY_KEY, JSON.stringify(h))
}
function submitSearch() {
addHistory(keyword.value)
}
function useHistory(kw) {
keyword.value = kw
refreshPackages(kw)
}
function clearHistory() {
searchHistory.value = []
localStorage.removeItem(HISTORY_KEY)
}
// —— 表格右上角:筛选/排序下拉 ——
const filterOpen = ref(false)
const sortOpen = ref(false)
function toggleFilter() {
filterOpen.value = !filterOpen.value
sortOpen.value = false
}
function toggleSort() {
sortOpen.value = !sortOpen.value
filterOpen.value = false
}
function pickFilter(key) {
statusFilter.value = key
filterOpen.value = false
}
function pickSort(mode) {
sortMode.value = mode
sortOpen.value = false
}
function closeMenus() {
filterOpen.value = false
sortOpen.value = false
}
onMounted(() => { onMounted(() => {
document.addEventListener('click', closeMenus)
// 数据由 App 初始化时统一加载,切换菜单不再重复拉取全量列表(响应大,避免卡顿) // 数据由 App 初始化时统一加载,切换菜单不再重复拉取全量列表(响应大,避免卡顿)
if (route.query.status && ['all', 'warn', 'ok', 'ignored', 'broken'].includes(route.query.status)) { if (route.query.status && ['all', 'warn', 'ok', 'ignored', 'broken'].includes(route.query.status)) {
statusFilter.value = route.query.status statusFilter.value = route.query.status
} }
}) })
onUnmounted(() => {
document.removeEventListener('click', closeMenus)
})
watch(keyword, () => { watch(keyword, () => {
clearTimeout(debounceTimer) clearTimeout(debounceTimer)
@@ -88,32 +138,56 @@ async function onCleanConfirmed(res) {
<input <input
v-model="keyword" v-model="keyword"
type="text" type="text"
placeholder="搜索包名…(实时过滤 · 防抖 300ms)" placeholder="搜索包名…(回车搜索 · 实时过滤 · 防抖 300ms)"
@keyup.esc="keyword = ''" @keyup.esc="keyword = ''"
@keyup.enter="submitSearch()"
/> />
<span v-if="keyword" class="s-clear" @click="keyword = ''">✕</span> <span v-if="keyword" class="s-clear" @click="keyword = ''">✕</span>
<button class="s-go">搜索</button> <button class="s-go" @click="submitSearch()">搜索</button>
</div> </div>
<div class="filter-row"> <!-- 最近搜索(最近 5 条,localStorage 持久化) -->
<span class="pill split">快捷筛选</span> <div v-if="searchHistory.length" class="history-row">
<span <span class="h-label">🕘 最近搜索</span>
v-for="p in pills" <span v-for="h in searchHistory" :key="h" class="h-chip" @click="useHistory(h)">{{ h }}</span>
:key="p.key" <span class="h-clear" @click="clearHistory">清除</span>
class="pill"
:class="{ on: statusFilter === p.key }"
@click="statusFilter = p.key"
>{{ p.label }}</span>
<span class="pill split">排序</span>
<span class="pill" :class="{ on: sortMode === 'default' }" @click="sortMode = 'default'">默认</span>
<span class="pill" :class="{ on: sortMode === 'versions-desc' }" @click="sortMode = 'versions-desc'">版本数 ↓</span>
<span class="pill" :class="{ on: sortMode === 'size-desc' }" @click="sortMode = 'size-desc'">占用大小 ↓</span>
</div> </div>
</div> </div>
<div class="result-note"> <!-- 表格工具栏:左侧结果数 + 右侧筛选/排序下拉 -->
共找到 <b>{{ filtered.length }}</b> 个包 <div class="toolbar">
<template v-if="keyword">(关键词「{{ keyword }}」)</template> <div class="tb-count">
共找到 <b>{{ filtered.length }}</b> 个包
<template v-if="keyword">(关键词「{{ keyword }}」)</template>
</div>
<div class="tb-actions">
<div class="dd">
<button class="dd-btn" :class="{ on: filterOpen }" title="快捷筛选" @click.stop="toggleFilter">🎚️</button>
<Transition name="dd">
<div v-if="filterOpen" class="dd-menu">
<div class="dd-title">快捷筛选</div>
<div
v-for="p in pills"
:key="p.key"
class="dd-item"
:class="{ on: statusFilter === p.key }"
@click="pickFilter(p.key)"
>{{ p.label }}<span v-if="statusFilter === p.key" class="dd-check">✓</span></div>
</div>
</Transition>
</div>
<div class="dd">
<button class="dd-btn" :class="{ on: sortOpen }" title="排序" @click.stop="toggleSort">↕️</button>
<Transition name="dd">
<div v-if="sortOpen" class="dd-menu">
<div class="dd-title">排序方式</div>
<div class="dd-item" :class="{ on: sortMode === 'default' }" @click="pickSort('default')">默认<span v-if="sortMode === 'default'" class="dd-check">✓</span></div>
<div class="dd-item" :class="{ on: sortMode === 'versions-desc' }" @click="pickSort('versions-desc')">版本数 ↓<span v-if="sortMode === 'versions-desc'" class="dd-check">✓</span></div>
<div class="dd-item" :class="{ on: sortMode === 'size-desc' }" @click="pickSort('size-desc')">占用大小 ↓<span v-if="sortMode === 'size-desc'" class="dd-check">✓</span></div>
</div>
</Transition>
</div>
</div>
</div> </div>
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div> <div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
@@ -240,38 +314,101 @@ async function onCleanConfirmed(res) {
flex-shrink: 0; flex-shrink: 0;
} }
.s-go:hover { transform: translateY(-1px); } .s-go:hover { transform: translateY(-1px); }
.filter-row { /* 最近搜索(搜索框下方,最近 5 条) */
.history-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; margin-top: 14px;
margin-top: 18px; width: min(680px, 100%);
} }
.pill { .h-label { font-size: 12px; color: var(--ink-faint); font-weight: 600; }
font-size: 12.5px; .h-chip {
font-weight: 600; font-size: 12px;
padding: 8px 17px; color: var(--ink-dim);
border-radius: 99px;
cursor: pointer;
background: var(--card); background: var(--card);
border: 1.5px solid var(--line); border: 1.5px solid var(--line);
color: var(--ink-dim); padding: 5px 13px;
transition: .16s; border-radius: 99px;
font-family: var(--sans); cursor: pointer;
transition: .15s;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.pill:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); color: var(--orange); } .h-chip:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); color: var(--orange); }
.pill.on { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 6px 14px -4px rgba(50, 205, 50, .5); } .h-clear { font-size: 11.5px; color: var(--ink-faint); cursor: pointer; margin-left: 2px; }
.pill.split { background: transparent; border: none; color: var(--ink-faint); cursor: default; } .h-clear:hover { color: var(--red); }
.result-note {
text-align: center; /* 表格工具栏:左结果数 + 右筛选/排序下拉 */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 20px 0 12px;
}
.tb-count {
font-family: var(--num); font-family: var(--num);
font-size: 12.5px; font-size: 12.5px;
color: var(--ink-faint); color: var(--ink-faint);
margin: 22px 0 14px;
font-weight: 600; font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.result-note b { color: var(--orange); } .tb-count b { color: var(--orange); }
.tb-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.dd { position: relative; }
.dd-btn {
width: 36px;
height: 36px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 16px;
cursor: pointer;
background: var(--card);
border: 1.5px solid var(--line);
transition: .15s;
}
.dd-btn:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); }
.dd-btn.on { background: var(--green); border-color: var(--green); box-shadow: 0 6px 14px -4px rgba(50, 205, 50, .5); }
.dd-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 168px;
background: var(--card);
border: 1.5px solid var(--line);
border-radius: 14px;
box-shadow: 0 18px 44px -14px rgba(0, 0, 0, .5);
padding: 7px;
z-index: 60;
}
.dd-title { font-size: 11px; color: var(--ink-faint); font-weight: 700; padding: 6px 10px 4px; letter-spacing: .3px; }
.dd-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12.5px;
font-weight: 600;
color: var(--ink-dim);
padding: 8px 10px;
border-radius: 9px;
cursor: pointer;
transition: .13s;
}
.dd-item:hover { background: var(--num-bg); color: var(--ink); }
.dd-item.on { color: var(--green-deep); background: var(--green-soft); }
.dd-check { font-weight: 800; }
.dd-enter-active { transition: transform .16s cubic-bezier(.2, .8, .25, 1), opacity .16s; }
.dd-leave-active { transition: transform .12s ease-in, opacity .12s; }
.dd-enter-from, .dd-leave-to { transform: translateY(-6px) scale(.97); opacity: 0; transform-origin: top right; }
.pkg-name { display: inline-flex; align-items: center; gap: 9px; min-width: 0; } .pkg-name { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }
.pkg-name .pkg-cell { min-width: 0; } .pkg-name .pkg-cell { min-width: 0; }
.pkg-cell { font-weight: 600; word-break: break-all; } .pkg-cell { font-weight: 600; word-break: break-all; }
@@ -286,9 +423,8 @@ async function onCleanConfirmed(res) {
@media (max-width: 767px) { @media (max-width: 767px) {
.search-box { height: 48px; } .search-box { height: 48px; }
.search-box .s-go { display: none; } .search-box .s-go { display: none; }
.filter-row { gap: 7px; } .history-row { width: 100%; }
.pill { padding: 7px 13px; font-size: 12px; } .toolbar { margin: 16px 0 10px; }
.pill.split { display: none; }
.vtable { overflow-x: auto; } .vtable { overflow-x: auto; }
.vtable table { min-width: 640px; } .vtable table { min-width: 640px; }
} }