mirror of
https://github.com/continew-org/continew-admin-ui.git
synced 2025-10-26 20:57:14 +08:00
feat(header): add search component (#49)
This commit is contained in:
342
src/layout/components/HeaderRightBar/Search.vue
Normal file
342
src/layout/components/HeaderRightBar/Search.vue
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import { useRouteStore } from '@/stores'
|
||||||
|
|
||||||
|
interface SearchHistory {
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const routeStore = useRouteStore()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const searchHistory = ref<SearchHistory[]>([])
|
||||||
|
const searchResults = ref<SearchResult[]>([])
|
||||||
|
|
||||||
|
const searchRoutes = (keyword: string) => {
|
||||||
|
const result: SearchResult[] = []
|
||||||
|
const loop = (routes: RouteRecordRaw[]) => {
|
||||||
|
routes.forEach((route) => {
|
||||||
|
if (route.children && route.children.length > 0) {
|
||||||
|
loop(route.children)
|
||||||
|
} else {
|
||||||
|
if (route.meta?.title?.toLowerCase().includes(keyword.toLowerCase())) {
|
||||||
|
result.push({
|
||||||
|
title: route.meta.title,
|
||||||
|
path: route.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
loop(routeStore.routes)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = (keyword: string) => {
|
||||||
|
if (!keyword) {
|
||||||
|
searchResults.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchResults.value = searchRoutes(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResultClick = (item: SearchResult) => {
|
||||||
|
if (!searchHistory.value.some((history) => history.path === item.path)) {
|
||||||
|
searchHistory.value.unshift(item)
|
||||||
|
if (searchHistory.value.length > 5) {
|
||||||
|
searchHistory.value.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
router.push(item.path)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHistoryClick = (item: SearchHistory) => {
|
||||||
|
router.push(item.path)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
searchHistory.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener('keydown', (e) => {
|
||||||
|
if (e.ctrlKey && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
nextTick(() => {
|
||||||
|
searchInput.value?.focus()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
searchResults.value = []
|
||||||
|
searchKeyword.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(searchKeyword, (newValue) => {
|
||||||
|
handleSearch(newValue)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-trigger" @click="visible = true">
|
||||||
|
<icon-search :size="18" style="margin-right: 4px;" />
|
||||||
|
<span class="search-text">
|
||||||
|
搜索
|
||||||
|
</span>
|
||||||
|
<span class="shortcut-key">
|
||||||
|
Ctrl + K
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="search-modal">
|
||||||
|
<a-modal
|
||||||
|
:visible="visible"
|
||||||
|
:footer="false"
|
||||||
|
:mask-closable="true"
|
||||||
|
:align-center="false"
|
||||||
|
:closable="false"
|
||||||
|
:render-to-body="false"
|
||||||
|
@cancel="visible = false"
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<icon-search :size="24" />
|
||||||
|
<input
|
||||||
|
ref="searchInput"
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索页面"
|
||||||
|
class="search-input"
|
||||||
|
>
|
||||||
|
<div class="esc-tip">
|
||||||
|
ESC 退出
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="search-content">
|
||||||
|
<div v-if="searchResults.length">
|
||||||
|
<div class="result-count">
|
||||||
|
搜索到 {{ searchResults.length }} 个结果
|
||||||
|
</div>
|
||||||
|
<div class="result-list">
|
||||||
|
<div
|
||||||
|
v-for="item in searchResults"
|
||||||
|
:key="item.path"
|
||||||
|
class="result-item"
|
||||||
|
@click="handleResultClick(item)"
|
||||||
|
>
|
||||||
|
<icon-file :size="18" style="margin-right: 6px;" />
|
||||||
|
<div class="result-title">
|
||||||
|
{{ item.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="searchHistory.length" class="history-section">
|
||||||
|
<div class="history-header">
|
||||||
|
<div class="history-title">
|
||||||
|
搜索历史
|
||||||
|
</div>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
class="text-xs"
|
||||||
|
@click="clearHistory"
|
||||||
|
>
|
||||||
|
清空历史
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="history-list">
|
||||||
|
<div
|
||||||
|
v-for="item in searchHistory" :key="item.path"
|
||||||
|
class="history-item"
|
||||||
|
@click="handleHistoryClick(item)"
|
||||||
|
>
|
||||||
|
<icon-history :size="18" style="margin-right: 6px;" />
|
||||||
|
<div class="result-title">
|
||||||
|
{{ item.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.search-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background-color: var(--color-bg-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-text {
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key {
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background-color: var(--color-bg-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-modal {
|
||||||
|
:deep(.arco-modal-header) {
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-modal-body) {
|
||||||
|
.search-content {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 4rem 0.5rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.esc-tip {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background-color: var(--color-bg-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #000;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--color-bg-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-history {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #000;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--color-bg-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-row justify="end" align="center">
|
<a-row justify="end" align="center">
|
||||||
<a-space size="medium">
|
<a-space size="medium">
|
||||||
|
<!-- 搜索 -->
|
||||||
|
<Search />
|
||||||
<!-- 项目配置 -->
|
<!-- 项目配置 -->
|
||||||
<a-tooltip content="项目配置" position="bl">
|
<a-tooltip content="项目配置" position="bl">
|
||||||
<a-button size="mini" class="gi_hover_btn" @click="SettingDrawerRef?.open">
|
<a-button size="mini" class="gi_hover_btn" @click="SettingDrawerRef?.open">
|
||||||
@@ -74,6 +76,7 @@ import { useFullscreen } from '@vueuse/core'
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import Message from './Message.vue'
|
import Message from './Message.vue'
|
||||||
import SettingDrawer from './SettingDrawer.vue'
|
import SettingDrawer from './SettingDrawer.vue'
|
||||||
|
import Search from './Search.vue'
|
||||||
import { getUnreadMessageCount } from '@/apis'
|
import { getUnreadMessageCount } from '@/apis'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { getToken } from '@/utils/auth'
|
import { getToken } from '@/utils/auth'
|
||||||
|
|||||||
Reference in New Issue
Block a user