mirror of https://gitee.com/xiaonuobase/snowy
【新增】菜单搜索功能
parent
f094c30e46
commit
b60e86540a
|
@ -30,7 +30,10 @@
|
|||
"echarts": "5.2.2",
|
||||
"echarts-stat": "^1.2.0",
|
||||
"enquire.js": "^2.1.6",
|
||||
"fuse.js": "^6.4.6",
|
||||
"highlight.js": "^11.6.0",
|
||||
"hotkeys-js": "^3.10.0",
|
||||
"js-pinyin": "^0.1.9",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nprogress": "0.2.0",
|
||||
"screenfull": "^6.0.2",
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { mapState, mapMutations } from 'vuex'
|
||||
|
||||
import hotkeys from 'hotkeys-js'
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
// 绑定搜索功能快捷键 [ 打开 ]
|
||||
hotkeys(this.searchHotkey.open, (event) => {
|
||||
event.preventDefault()
|
||||
this.searchPanelOpen()
|
||||
})
|
||||
// 绑定搜索功能快捷键 [ 关闭 ]
|
||||
hotkeys(this.searchHotkey.close, (event) => {
|
||||
event.preventDefault()
|
||||
this.searchPanelClose()
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
hotkeys.unbind(this.searchHotkey.open)
|
||||
hotkeys.unbind(this.searchHotkey.close)
|
||||
},
|
||||
computed: {
|
||||
...mapState('search', {
|
||||
searchActive: (state) => state.active,
|
||||
searchHotkey: (state) => state.hotkey
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
searchToggle: 'search/toggle',
|
||||
searchSet: 'search/set'
|
||||
}),
|
||||
/**
|
||||
* 接收点击搜索按钮
|
||||
*/
|
||||
handleSearchClick() {
|
||||
this.searchToggle()
|
||||
if (this.searchActive) {
|
||||
setTimeout(() => {
|
||||
if (this.$refs.panelSearch) {
|
||||
this.$refs.panelSearch.focus()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
searchPanelOpen() {
|
||||
if (!this.searchActive) {
|
||||
this.searchSet(true)
|
||||
setTimeout(() => {
|
||||
if (this.$refs.panelSearch) {
|
||||
this.$refs.panelSearch.focus()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
// 关闭搜索面板
|
||||
searchPanelClose() {
|
||||
if (this.searchActive) {
|
||||
this.searchSet(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,295 @@
|
|||
<template>
|
||||
<div @keyup.up="handleKeyUp" @keyup.down="handleKeyDown" @keyup.enter="handleKeyEnter" @click.self="handlePanelClick">
|
||||
<a-input
|
||||
ref="input"
|
||||
v-model="searchText"
|
||||
class="search-box"
|
||||
style="width: 100%"
|
||||
allowClear
|
||||
placeholder="搜索页面(支持拼音检索)"
|
||||
size="large"
|
||||
@change="querySearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<search-outlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<a-card
|
||||
:body-style="{ padding: '4px 0' }"
|
||||
hoverable
|
||||
@mouseenter="onCardIn"
|
||||
@mouseleave="onCardOut"
|
||||
@keypress.up="handleKeyUp"
|
||||
@keypress.down="handleKeyDown"
|
||||
style="margin: 10px 0"
|
||||
>
|
||||
<div ref="cardList" class="search-card beauty-scroll" style="">
|
||||
<a-list size="small" :data-source="resultsList">
|
||||
<template #renderItem="{ item, index }">
|
||||
<a-list-item
|
||||
@click="handleSelect(item.fullPath)"
|
||||
@mouseover="onCardItemHover(index)"
|
||||
:class="{ active: index == cardIndex }"
|
||||
style="padding-right: 10px"
|
||||
>
|
||||
<template #actions>
|
||||
<a>
|
||||
<enter-outlined />
|
||||
</a>
|
||||
</template>
|
||||
<a-list-item-meta :description="item.fullName">
|
||||
<template #title>
|
||||
<a>{{ item.name }}</a>
|
||||
</template>
|
||||
<template #avatar>
|
||||
<a-avatar style="color: black; background-color: transparent" :type="item.icon">
|
||||
<template #icon>
|
||||
<component :is="item.icon" />
|
||||
</template>
|
||||
</a-avatar>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</div>
|
||||
</a-card>
|
||||
<div class="search-tips">
|
||||
<span class="key">S</span>
|
||||
<span class="tips">打开搜索面板</span>
|
||||
|
||||
<span class="key">
|
||||
<arrow-up-outlined />
|
||||
</span>
|
||||
<span class="key">
|
||||
<arrow-down-outlined />
|
||||
</span>
|
||||
<span class="tips">选择</span>
|
||||
|
||||
<span class="key">
|
||||
<enter-outlined />
|
||||
</span>
|
||||
<span class="tips">确认</span>
|
||||
|
||||
<span class="key left">Esc</span>
|
||||
<span class="tips">关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Fuse from 'fuse.js'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
searchText: '',
|
||||
cardIndex: 0,
|
||||
results: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('search', ['pool']),
|
||||
// 这份数据是展示在搜索面板下面的
|
||||
resultsList() {
|
||||
return this.results.length === 0 || this.searchText === '' ? this.pool : this.results
|
||||
},
|
||||
// 根据 pool 更新 fuse 实例
|
||||
fuse() {
|
||||
return new Fuse(this.pool, {
|
||||
shouldSort: true, // 按分数对结果列表进行排序
|
||||
threshold: 0.6, // 什么时候放弃
|
||||
location: 0, // 大致位置
|
||||
distance: 100, // 接近程度
|
||||
minMatchCharLength: 1, // 匹配长度
|
||||
keys: ['name', 'namePinyin', 'namePinyinFirst']
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* @description 过滤选项 这个方法在每次输入框的值发生变化时会触发
|
||||
*/
|
||||
querySearch(e) {
|
||||
let queryString = e.target.value || ''
|
||||
const results = queryString && this.fuse.search(queryString).map((e) => e.item)
|
||||
this.searchText = queryString
|
||||
this.results = results
|
||||
},
|
||||
/**
|
||||
* @description 聚焦输入框
|
||||
*/
|
||||
focus() {
|
||||
this.searchText = ''
|
||||
setTimeout(() => {
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.focus()
|
||||
}
|
||||
// 还原
|
||||
this.searchText = ''
|
||||
this.results = []
|
||||
}, 300)
|
||||
},
|
||||
handleKeyEnter() {
|
||||
let idx = this.cardIndex
|
||||
if (this.resultsList[idx]) {
|
||||
this.handleSelect(this.resultsList[idx].fullPath)
|
||||
}
|
||||
},
|
||||
handleKeyUp() {
|
||||
this.handleKeyUpOrDown(true)
|
||||
},
|
||||
handleKeyDown() {
|
||||
this.handleKeyUpOrDown(false)
|
||||
},
|
||||
handleKeyUpOrDown(up) {
|
||||
let len = this.resultsList.length - 1
|
||||
let idx = this.cardIndex
|
||||
if (up) {
|
||||
// 上
|
||||
if (idx > 0) {
|
||||
idx--
|
||||
} else {
|
||||
idx = len
|
||||
}
|
||||
} else {
|
||||
// 下
|
||||
if (idx < len) {
|
||||
idx++
|
||||
} else {
|
||||
idx = 0
|
||||
}
|
||||
}
|
||||
this.cardIndex = idx
|
||||
if (this.$refs.cardList.getElementsByClassName('ant-list-item')[idx]) {
|
||||
this.$refs.cardList.scrollTop = this.$refs.cardList.getElementsByClassName('ant-list-item')[idx].offsetTop
|
||||
} else {
|
||||
this.$refs.cardList.scrollTop = 0
|
||||
}
|
||||
},
|
||||
onCardIn() {
|
||||
this.$refs.input.activated = false
|
||||
this.$refs.input.blur()
|
||||
},
|
||||
onCardOut() {
|
||||
this.cardIndex = -1
|
||||
},
|
||||
onCardItemHover(index) {
|
||||
this.cardIndex = index
|
||||
},
|
||||
/**
|
||||
* @description 接收用户在下拉菜单中选中事件
|
||||
*/
|
||||
handleSelect(path) {
|
||||
// 如果用户选择的就是当前页面 就直接关闭搜索面板
|
||||
if (path === this.$route.path) {
|
||||
this.handleEsc()
|
||||
return
|
||||
}
|
||||
this.$router.push({ path })
|
||||
this.handleEsc()
|
||||
},
|
||||
/**
|
||||
* @augments 关闭输入框的下拉菜单
|
||||
*/
|
||||
closeSuggestion() {
|
||||
if (this.$refs.input.activated) {
|
||||
this.results = []
|
||||
this.$refs.input.activated = false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @augments 接收用户点击空白区域的关闭
|
||||
*/
|
||||
handlePanelClick(e) {
|
||||
if ('INPUT' != e.target.tagName) {
|
||||
this.handleEsc()
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @augments 接收用户触发的关闭
|
||||
*/
|
||||
async handleEsc() {
|
||||
this.closeSuggestion()
|
||||
await this.$nextTick()
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/deep/ .ant-input {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
/deep/ .ant-input:not(:first-child) {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/deep/ .ant-input-prefix {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.beauty-scroll {
|
||||
scrollbar-color: var(--primary-color) var(--primary-2);
|
||||
scrollbar-width: thin;
|
||||
-ms-overflow-style: none;
|
||||
position: relative;
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
height: 1px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
background: var(--primary-color);
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
-webkit-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0);
|
||||
border-radius: 3px;
|
||||
background: var(--primary-3);
|
||||
}
|
||||
}
|
||||
|
||||
.search-card {
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
/deep/ .ant-list-item.active {
|
||||
background-color: var(--primary-1);
|
||||
}
|
||||
|
||||
.search-tips {
|
||||
display: flex;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 6px;
|
||||
|
||||
.tips {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.key {
|
||||
//display: flex;
|
||||
//flex-direction: row;
|
||||
//align-items: center;
|
||||
//justify-content: center;
|
||||
width: 30px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
padding-bottom: 2px;
|
||||
margin: 0px 4px;
|
||||
border-radius: 2px;
|
||||
background-color: white;
|
||||
box-shadow: inset 0 -2px #cdcde6, inset 0 0 1px 1px #fff, 0 1px 2px 1px #1e235a66;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<div class="d2-panel-search-item" :class="hoverMode ? 'can-hover' : ''" flex>
|
||||
<div class="d2-panel-search-item__icon" flex-box="0">
|
||||
<div class="d2-panel-search-item__icon-box" flex="main:center cross:center">
|
||||
<a-icon v-if="item.icon" :type="item.icon" />
|
||||
<a-icon v-else type="menu" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d2-panel-search-item__info" flex-box="1" flex="dir:top">
|
||||
<div class="d2-panel-search-item__info-title" flex-box="1" flex="cross:center">
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
<div class="d2-panel-search-item__info-fullTitle" flex-box="0">
|
||||
<span>{{ item.fullTitle }}</span>
|
||||
</div>
|
||||
<div class="d2-panel-search-item__info-path" flex-box="0">
|
||||
<span>{{ item.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
item: {
|
||||
default: () => ({})
|
||||
},
|
||||
hoverMode: {
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.d2-panel-search-item {
|
||||
height: 64px;
|
||||
margin: 0px -20px;
|
||||
&.can-hover {
|
||||
margin: 0px;
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
.d2-panel-search-item__icon {
|
||||
.d2-panel-search-item__icon-box {
|
||||
i {
|
||||
font-size: 24px;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
.d2-panel-search-item__info {
|
||||
.d2-panel-search-item__info-title {
|
||||
//color: $color-text-main;
|
||||
}
|
||||
.d2-panel-search-item__info-fullTitle {
|
||||
//color: $color-text-normal;
|
||||
}
|
||||
.d2-panel-search-item__info-path {
|
||||
//color: $color-text-normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.d2-panel-search-item__icon {
|
||||
width: 64px;
|
||||
.d2-panel-search-item__icon-box {
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
border-right: 1px solid #ccc;
|
||||
i {
|
||||
font-size: 20px;
|
||||
//color: $color-text-sub;
|
||||
}
|
||||
svg {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.d2-panel-search-item__info {
|
||||
margin-left: 10px;
|
||||
.d2-panel-search-item__info-title {
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
font-weight: bold;
|
||||
//color: $color-text-normal;
|
||||
}
|
||||
.d2-panel-search-item__info-fullTitle {
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: grey;
|
||||
}
|
||||
.d2-panel-search-item__info-path {
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,5 +1,8 @@
|
|||
<template>
|
||||
<div class="user-bar">
|
||||
<div v-if="!ismobile" class="search panel-item hidden-sm-and-down" @click="handleSearchClick">
|
||||
<search-outlined />
|
||||
</div>
|
||||
<div v-if="!ismobile" class="screen panel-item hidden-sm-and-down" @click="fullscreen">
|
||||
<fullscreen-outlined />
|
||||
</div>
|
||||
|
@ -49,6 +52,20 @@
|
|||
<a-drawer v-model:visible="settingDialog" :closable="false" width="300">
|
||||
<setting></setting>
|
||||
</a-drawer>
|
||||
<!-- 搜索面板 -->
|
||||
<a-modal
|
||||
:visible="searchActive"
|
||||
:closable="false"
|
||||
:footer="null"
|
||||
width="600px"
|
||||
style="overflow: hidden"
|
||||
destroyOnClose
|
||||
dialogClass="searchModal"
|
||||
:bodyStyle="{ maxHeight: '520px', overflow: 'auto', padding: '10px' }"
|
||||
@cancel="searchPanelClose"
|
||||
>
|
||||
<panel-search ref="panelSearch" @close="searchPanelClose" />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -61,11 +78,15 @@
|
|||
import tool from '@/utils/tool'
|
||||
import loginApi from '@/api/auth/loginApi'
|
||||
import devUserMessage from './message.vue'
|
||||
import panelSearch from './panel-search/index.vue'
|
||||
import mixinSearch from './mixins/search'
|
||||
export default {
|
||||
components: {
|
||||
setting,
|
||||
devUserMessage
|
||||
devUserMessage,
|
||||
panelSearch
|
||||
},
|
||||
mixins: [mixinSearch],
|
||||
data() {
|
||||
return {
|
||||
lang: [],
|
||||
|
@ -171,12 +192,20 @@
|
|||
if (screenfull.isEnabled) {
|
||||
screenfull.toggle(element)
|
||||
}
|
||||
}
|
||||
},
|
||||
// 搜索
|
||||
fullSearch() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="less" scoped>
|
||||
:deep(.ant-modal) {
|
||||
top: 20px;
|
||||
}
|
||||
:deep(.ant-modal-content) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.user-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
@ -76,6 +76,7 @@ const handleGetRouter = (to) => {
|
|||
menuRouter.forEach((item) => {
|
||||
router.addRoute('layout', item)
|
||||
})
|
||||
store.commit('search/init', menuRouter)
|
||||
routes_404_r = router.addRoute(routes_404)
|
||||
if (to && to.matched.length === 0) {
|
||||
router.push(to.fullPath)
|
||||
|
|
|
@ -14,12 +14,14 @@ import global from './modules/global'
|
|||
import iframe from './modules/iframe'
|
||||
import keepAlive from './modules/keepAlive'
|
||||
import viewTags from './modules/viewTags'
|
||||
import search from './modules/search'
|
||||
// 自动import导入所有 vuex 模块
|
||||
export default createStore({
|
||||
modules: {
|
||||
global,
|
||||
iframe,
|
||||
keepAlive,
|
||||
viewTags
|
||||
viewTags,
|
||||
search
|
||||
}
|
||||
})
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import '@/utils/objects'
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
// 搜索面板激活状态
|
||||
active: false,
|
||||
// 快捷键
|
||||
hotkey: {
|
||||
open: 's',
|
||||
close: 'esc'
|
||||
},
|
||||
// 所有可以搜索的页面
|
||||
pool: []
|
||||
},
|
||||
mutations: {
|
||||
/**
|
||||
* @description 切换激活状态
|
||||
* @param {Object} state state
|
||||
*/
|
||||
toggle(state) {
|
||||
state.active = !state.active
|
||||
},
|
||||
/**
|
||||
* @description 设置激活模式
|
||||
* @param {Object} state state
|
||||
* @param {Boolean} active active
|
||||
*/
|
||||
set(state, active) {
|
||||
state.active = active
|
||||
},
|
||||
/**
|
||||
* @description 初始化
|
||||
* @param {Object} state state
|
||||
* @param {Array} menu menu
|
||||
*/
|
||||
init(state, menu) {
|
||||
const pool = []
|
||||
const getFullName = function (meta) {
|
||||
if (meta.breadcrumb) {
|
||||
let list = []
|
||||
meta.breadcrumb.forEach((item) => {
|
||||
list.push(item.meta.title)
|
||||
})
|
||||
return list.join(' / ')
|
||||
}
|
||||
return meta.title
|
||||
}
|
||||
const push = function (menu) {
|
||||
menu.forEach((m) => {
|
||||
if ('menu' == m.meta.type) {
|
||||
if (m.children) {
|
||||
push(m.children)
|
||||
} else {
|
||||
pool.push({
|
||||
icon: m.meta.icon,
|
||||
path: m.path,
|
||||
fullPath: m.path,
|
||||
name: m.meta.title,
|
||||
fullName: getFullName(m.meta),
|
||||
namePinyin: m.meta.title.toPinyin(),
|
||||
namePinyinFirst: m.meta.title.toPinyin(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
push(menu)
|
||||
state.pool = pool
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import pinyin from 'js-pinyin'
|
||||
import store from '@/store/index'
|
||||
|
||||
/**
|
||||
* 中文转拼音
|
||||
* @param first 仅首字母
|
||||
* @returns {String}
|
||||
*/
|
||||
Object.defineProperty(String.prototype, 'toPinyin', {
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
value: function (first) {
|
||||
let str = this
|
||||
if (first) {
|
||||
return pinyin.getCamelChars(str).replace(/\uD83C[\uDF00-\uDFFF]|\uD83D[\uDC00-\uDE4F]/g, '')
|
||||
}
|
||||
return pinyin.getFullChars(str).replace(/\uD83C[\uDF00-\uDFFF]|\uD83D[\uDC00-\uDE4F]/g, '')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 字符检索
|
||||
* @param input 检索值
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
Object.defineProperty(String.prototype, 'filter', {
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
value: function (input) {
|
||||
let str = this
|
||||
let en = str.toLowerCase().includes(input.toLowerCase())
|
||||
let zhFull = str.toPinyin().toLowerCase().includes(input.toLowerCase())
|
||||
let zhFirst = str.toPinyin(true).toLowerCase().includes(input.toLowerCase())
|
||||
return en || zhFull || zhFirst
|
||||
}
|
||||
})
|
Loading…
Reference in New Issue