菜单搜索支持文本高亮&数量提示

This commit is contained in:
RuoYi
2026-03-22 19:10:21 +08:00
parent a2f1bf6372
commit f8bf7a26b3

View File

@@ -5,6 +5,7 @@
v-model="show" v-model="show"
width="600" width="600"
@close="close" @close="close"
@opened="onDialogOpened"
:show-close="false" :show-close="false"
append-to-body append-to-body
> >
@@ -22,24 +23,55 @@
> >
</el-input> </el-input>
<div class="result-count" v-if="search && options.length > 0">
找到 <strong>{{ options.length }}</strong> 个结果
</div>
<div class="result-wrap"> <div class="result-wrap">
<el-scrollbar> <el-scrollbar>
<div class="search-item" tabindex="1" v-for="(item, index) in options" :key="item.path" :style="activeStyle(index)" @mouseenter="activeIndex = index" @mouseleave="activeIndex = -1">
<div class="left"> <template v-if="options.length > 0">
<svg-icon class="menu-icon" :icon-class="item.icon" /> <div
</div> class="search-item"
<div class="search-info" @click="change(item)"> tabindex="1"
<div class="menu-title"> v-for="(item, index) in options"
{{ item.title.join(" / ") }} :key="item.path"
:class="{ 'is-active': index === activeIndex }"
:style="activeStyle(index)"
@mouseenter="activeIndex = index"
@mouseleave="activeIndex = -1"
>
<div class="left">
<svg-icon class="menu-icon" :icon-class="item.icon" />
</div> </div>
<div class="menu-path"> <div class="search-info" @click="change(item)">
{{ item.path }} <div class="menu-title" v-html="highlightText(item.title.join(' / '))"></div>
<div class="menu-path" v-html="highlightText(item.path)"></div>
</div> </div>
<svg-icon icon-class="enter" v-show="index === activeIndex" />
</div> </div>
<svg-icon icon-class="enter" v-show="index === activeIndex"/> </template>
<div class="empty-state" v-else-if="search && options.length === 0">
<el-icon class="empty-icon"><Search /></el-icon>
<p class="empty-text">未找到 "<strong>{{ search }}</strong>" 相关菜单</p>
<p class="empty-tip">试试其他关键词或路径</p>
</div> </div>
</el-scrollbar> </el-scrollbar>
</div> </div>
<div class="search-footer">
<span class="shortcut-item">
<kbd></kbd><kbd></kbd> 切换
</span>
<span class="shortcut-item">
<kbd></kbd> 选择
</span>
<span class="shortcut-item">
<kbd>Esc</kbd> 关闭
</span>
</div>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
@@ -65,36 +97,40 @@ const routes = computed(() => usePermissionStore().defaultRoutes)
function click() { function click() {
show.value = !show.value show.value = !show.value
if (show.value) { if (show.value) {
headerSearchSelectRef.value && headerSearchSelectRef.value.focus()
options.value = searchPool.value options.value = searchPool.value
} }
} }
function onDialogOpened() {
nextTick(() => {
headerSearchSelectRef.value && headerSearchSelectRef.value.focus()
})
}
function close() { function close() {
headerSearchSelectRef.value && headerSearchSelectRef.value.blur() headerSearchSelectRef.value && headerSearchSelectRef.value.blur()
search.value = '' search.value = ''
options.value = [] options.value = searchPool.value
show.value = false show.value = false
activeIndex.value = -1 activeIndex.value = -1
} }
function change(val) { function change(val) {
const path = val.path const p = val.path
const query = val.query const query = val.query
if (isHttp(path)) { if (isHttp(p)) {
// http(s):// 路径新窗口打开 // http(s):// 路径新窗口打开
const pindex = path.indexOf("http") const pindex = p.indexOf("http")
window.open(path.substr(pindex, path.length), "_blank") window.open(p.substr(pindex, p.length), "_blank")
} else { } else {
if (query) { if (query) {
router.push({ path: path, query: JSON.parse(query) }) router.push({ path: p, query: JSON.parse(query) })
} else { } else {
router.push(path) router.push(p)
} }
} }
search.value = '' search.value = ''
options.value = [] options.value = searchPool.value
nextTick(() => { nextTick(() => {
show.value = false show.value = false
}) })
@@ -103,8 +139,7 @@ function change(val) {
function initFuse(list) { function initFuse(list) {
fuse.value = new Fuse(list, { fuse.value = new Fuse(list, {
shouldSort: true, shouldSort: true,
threshold: 0.4, threshold: 0.2,
location: 0,
distance: 100, distance: 100,
minMatchCharLength: 1, minMatchCharLength: 1,
keys: [{ keys: [{
@@ -117,13 +152,9 @@ function initFuse(list) {
}) })
} }
// Filter out the routes that can be displayed in the sidebar
// And generate the internationalized title
function generateRoutes(routes, basePath = '', prefixTitle = []) { function generateRoutes(routes, basePath = '', prefixTitle = []) {
let res = [] let res = []
for (const r of routes) { for (const r of routes) {
// skip hidden router
if (r.hidden) { continue } if (r.hidden) { continue }
const p = r.path.length > 0 && r.path[0] === '/' ? r.path : '/' + r.path const p = r.path.length > 0 && r.path[0] === '/' ? r.path : '/' + r.path
const data = { const data = {
@@ -131,21 +162,16 @@ function generateRoutes(routes, basePath = '', prefixTitle = []) {
title: [...prefixTitle], title: [...prefixTitle],
icon: '' icon: ''
} }
if (r.meta && r.meta.title) { if (r.meta && r.meta.title) {
data.title = [...data.title, r.meta.title] data.title = [...data.title, r.meta.title]
data.icon = r.meta.icon data.icon = r.meta.icon
if (r.redirect !== "noRedirect") { if (r.redirect !== "noRedirect") {
// only push the routes with title
// special case: need to exclude parent router without redirect
res.push(data) res.push(data)
} }
} }
if (r.query) { if (r.query) {
data.query = r.query data.query = r.query
} }
// recursive child routes
if (r.children) { if (r.children) {
const tempRoutes = generateRoutes(r.children, data.path, data.title) const tempRoutes = generateRoutes(r.children, data.path, data.title)
if (tempRoutes.length >= 1) { if (tempRoutes.length >= 1) {
@@ -159,7 +185,18 @@ function generateRoutes(routes, basePath = '', prefixTitle = []) {
function querySearch(query) { function querySearch(query) {
activeIndex.value = -1 activeIndex.value = -1
if (query !== '') { if (query !== '') {
options.value = fuse.value.search(query).map((item) => item.item) ?? searchPool.value const q = query.toLowerCase()
const pathMatches = searchPool.value.filter(item =>
item.path.toLowerCase().includes(q)
)
const fuseMatches = fuse.value.search(query).map(item => item.item)
const merged = [...pathMatches]
fuseMatches.forEach(item => {
if (!merged.find(m => m.path === item.path)) {
merged.push(item)
}
})
options.value = merged
} else { } else {
options.value = searchPool.value options.value = searchPool.value
} }
@@ -187,6 +224,18 @@ function selectActiveResult() {
} }
} }
function highlightText(text) {
if (!text) return ''
if (!search.value) return text
const keyword = escapeRegExp(search.value)
const reg = new RegExp(`(${keyword})`, 'gi')
return text.replace(reg, '<span class="highlight">$1</span>')
}
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
onMounted(() => { onMounted(() => {
searchPool.value = generateRoutes(routes.value) searchPool.value = generateRoutes(routes.value)
}) })
@@ -197,6 +246,20 @@ watch(searchPool, (list) => {
</script> </script>
<style lang='scss' scoped> <style lang='scss' scoped>
:deep(.el-dialog__header) {
padding: 6px !important;
}
:deep(.highlight) {
color: red;
font-weight: 600;
}
:deep(.is-active .highlight) {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.header-search { .header-search {
.search-icon { .search-icon {
cursor: pointer; cursor: pointer;
@@ -205,19 +268,33 @@ watch(searchPool, (list) => {
} }
} }
.result-wrap { .result-count {
padding: 6px 16px 0;
font-size: 12px;
color: #aaa;
strong {
color: red;
font-weight: 600;
}
}
.result-wrap {
height: 280px; height: 280px;
margin: 6px 0; margin: 4px 0;
.search-item { .search-item {
display: flex; display: flex;
height: 48px; height: 48px;
align-items: center; align-items: center;
padding-right: 10px; padding-right: 10px;
border-radius: 4px;
transition: background 0.15s;
.left { .left {
width: 60px; width: 60px;
text-align: center; text-align: center;
flex-shrink: 0;
.menu-icon { .menu-icon {
width: 18px; width: 18px;
@@ -233,11 +310,16 @@ watch(searchPool, (list) => {
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
flex: 1; flex: 1;
overflow: hidden;
.menu-title, .menu-title,
.menu-path { .menu-path {
height: 20px; height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.menu-path { .menu-path {
color: #ccc; color: #ccc;
font-size: 10px; font-size: 10px;
@@ -248,5 +330,68 @@ watch(searchPool, (list) => {
.search-item:hover { .search-item:hover {
cursor: pointer; cursor: pointer;
} }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
.empty-icon {
font-size: 42px;
color: #e0e0e0;
margin-bottom: 14px;
}
.empty-text {
font-size: 14px;
color: #999;
margin: 0 0 6px;
strong {
color: #666;
}
}
.empty-tip {
font-size: 12px;
color: #bbb;
margin: 0;
}
}
}
.search-footer {
display: flex;
align-items: center;
gap: 28px;
padding: 10px 20px;
border-top: 1px solid #f0f0f0;
color: #999;
font-size: 12px;
.shortcut-item {
display: flex;
align-items: center;
gap: 5px;
}
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f7f7f7;
color: #555;
font-size: 11px;
font-family: inherit;
line-height: 1;
box-shadow: 0 1px 0 #ccc;
}
} }
</style> </style>