【新增】菜单搜索功能

pull/56/head
Cc-Mac 2022-11-02 16:30:22 +08:00 committed by 小诺
parent f094c30e46
commit b60e86540a
9 changed files with 609 additions and 4 deletions

View File

@ -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",

View File

@ -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)
}
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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)

View File

@ -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
}
})

View File

@ -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
}
}
}

View File

@ -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
}
})