Selaa lähdekoodia

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

RuoYi 2 kuukautta sitten
vanhempi
sitoutus
f8bf7a26b3
1 muutettua tiedostoa jossa 179 lisäystä ja 34 poistoa
  1. 179 34
      src/components/HeaderSearch/index.vue

+ 179 - 34
src/components/HeaderSearch/index.vue

@@ -5,6 +5,7 @@
       v-model="show"
       width="600"
       @close="close"
+      @opened="onDialogOpened"
       :show-close="false"
       append-to-body
     >
@@ -22,24 +23,55 @@
       >
       </el-input>
 
+      <div class="result-count" v-if="search && options.length > 0">
+        找到 <strong>{{ options.length }}</strong> 个结果
+      </div>
+
       <div class="result-wrap">
         <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">
-              <svg-icon class="menu-icon" :icon-class="item.icon" />
-            </div>
-            <div class="search-info" @click="change(item)">
-              <div class="menu-title">
-                {{ item.title.join(" / ") }}
+
+          <template v-if="options.length > 0">
+            <div
+              class="search-item"
+              tabindex="1"
+              v-for="(item, index) in options"
+              :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 class="menu-path">
-                {{ item.path }}
+              <div class="search-info" @click="change(item)">
+                <div class="menu-title" v-html="highlightText(item.title.join(' / '))"></div>
+                <div class="menu-path" v-html="highlightText(item.path)"></div>
               </div>
+              <svg-icon icon-class="enter" v-show="index === activeIndex" />
             </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>
+
         </el-scrollbar>
       </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>
   </div>
 </template>
@@ -65,36 +97,40 @@ const routes = computed(() => usePermissionStore().defaultRoutes)
 function click() {
   show.value = !show.value
   if (show.value) {
-    headerSearchSelectRef.value && headerSearchSelectRef.value.focus()
     options.value = searchPool.value
   }
 }
 
+function onDialogOpened() {
+  nextTick(() => {
+    headerSearchSelectRef.value && headerSearchSelectRef.value.focus()
+  })
+}
+
 function close() {
   headerSearchSelectRef.value && headerSearchSelectRef.value.blur()
   search.value = ''
-  options.value = []
+  options.value = searchPool.value
   show.value = false
   activeIndex.value = -1
 }
 
 function change(val) {
-  const path = val.path
+  const p = val.path
   const query = val.query
-  if (isHttp(path)) {
+  if (isHttp(p)) {
     // http(s):// 路径新窗口打开
-    const pindex = path.indexOf("http")
-    window.open(path.substr(pindex, path.length), "_blank")
+    const pindex = p.indexOf("http")
+    window.open(p.substr(pindex, p.length), "_blank")
   } else {
     if (query) {
-      router.push({ path: path, query: JSON.parse(query) })
+      router.push({ path: p, query: JSON.parse(query) })
     } else {
-      router.push(path)
+      router.push(p)
     }
   }
-
   search.value = ''
-  options.value = []
+  options.value = searchPool.value
   nextTick(() => {
     show.value = false
   })
@@ -103,8 +139,7 @@ function change(val) {
 function initFuse(list) {
   fuse.value = new Fuse(list, {
     shouldSort: true,
-    threshold: 0.4,
-    location: 0,
+    threshold: 0.2,
     distance: 100,
     minMatchCharLength: 1,
     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 = []) {
   let res = []
-
   for (const r of routes) {
-    // skip hidden router
     if (r.hidden) { continue }
     const p = r.path.length > 0 && r.path[0] === '/' ? r.path : '/' + r.path
     const data = {
@@ -131,21 +162,16 @@ function generateRoutes(routes, basePath = '', prefixTitle = []) {
       title: [...prefixTitle],
       icon: ''
     }
-
     if (r.meta && r.meta.title) {
       data.title = [...data.title, r.meta.title]
       data.icon = r.meta.icon
       if (r.redirect !== "noRedirect") {
-        // only push the routes with title
-        // special case: need to exclude parent router without redirect
         res.push(data)
       }
     }
     if (r.query) {
       data.query = r.query
     }
-
-    // recursive child routes
     if (r.children) {
       const tempRoutes = generateRoutes(r.children, data.path, data.title)
       if (tempRoutes.length >= 1) {
@@ -159,7 +185,18 @@ function generateRoutes(routes, basePath = '', prefixTitle = []) {
 function querySearch(query) {
   activeIndex.value = -1
   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 {
     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(() => {
   searchPool.value = generateRoutes(routes.value)
 })
@@ -197,6 +246,20 @@ watch(searchPool, (list) => {
 </script>
 
 <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 {
   .search-icon {
     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;
-  margin: 6px 0;
+  margin: 4px 0;
 
   .search-item {
     display: flex;
     height: 48px;
     align-items: center;
     padding-right: 10px;
+    border-radius: 4px;
+    transition: background 0.15s;
 
     .left {
       width: 60px;
       text-align: center;
+      flex-shrink: 0;
 
       .menu-icon {
         width: 18px;
@@ -233,11 +310,16 @@ watch(searchPool, (list) => {
       flex-direction: column;
       justify-content: flex-start;
       flex: 1;
+      overflow: hidden;
 
       .menu-title,
       .menu-path {
         height: 20px;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
       }
+
       .menu-path {
         color: #ccc;
         font-size: 10px;
@@ -248,5 +330,68 @@ watch(searchPool, (list) => {
   .search-item:hover {
     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>