Skip to content

Commit 21d0b1e

Browse files
authored
feat(header): add search component (#49)
1 parent a0c36ab commit 21d0b1e

File tree

2 files changed

+345
-0
lines changed

2 files changed

+345
-0
lines changed
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
<script setup lang="ts">
2+
import { useEventListener } from '@vueuse/core'
3+
import { useRouter } from 'vue-router'
4+
import type { RouteRecordRaw } from 'vue-router'
5+
import { useRouteStore } from '@/stores'
6+
7+
interface SearchHistory {
8+
title: string
9+
path: string
10+
}
11+
12+
interface SearchResult {
13+
title: string
14+
path: string
15+
}
16+
17+
const router = useRouter()
18+
const routeStore = useRouteStore()
19+
20+
const visible = ref(false)
21+
const searchInput = ref<HTMLInputElement | null>(null)
22+
const searchKeyword = ref('')
23+
const searchHistory = ref<SearchHistory[]>([])
24+
const searchResults = ref<SearchResult[]>([])
25+
26+
const searchRoutes = (keyword: string) => {
27+
const result: SearchResult[] = []
28+
const loop = (routes: RouteRecordRaw[]) => {
29+
routes.forEach((route) => {
30+
if (route.children && route.children.length > 0) {
31+
loop(route.children)
32+
} else {
33+
if (route.meta?.title?.toLowerCase().includes(keyword.toLowerCase())) {
34+
result.push({
35+
title: route.meta.title,
36+
path: route.path,
37+
})
38+
}
39+
}
40+
})
41+
}
42+
loop(routeStore.routes)
43+
return result
44+
}
45+
46+
const handleSearch = (keyword: string) => {
47+
if (!keyword) {
48+
searchResults.value = []
49+
return
50+
}
51+
searchResults.value = searchRoutes(keyword)
52+
}
53+
54+
const handleResultClick = (item: SearchResult) => {
55+
if (!searchHistory.value.some((history) => history.path === item.path)) {
56+
searchHistory.value.unshift(item)
57+
if (searchHistory.value.length > 5) {
58+
searchHistory.value.pop()
59+
}
60+
}
61+
router.push(item.path)
62+
visible.value = false
63+
}
64+
65+
const handleHistoryClick = (item: SearchHistory) => {
66+
router.push(item.path)
67+
visible.value = false
68+
}
69+
70+
const clearHistory = () => {
71+
searchHistory.value = []
72+
}
73+
74+
useEventListener('keydown', (e) => {
75+
if (e.ctrlKey && e.key.toLowerCase() === 'k') {
76+
e.preventDefault()
77+
visible.value = true
78+
}
79+
})
80+
81+
const handleKeyDown = (e: KeyboardEvent) => {
82+
if (e.key === 'Escape') {
83+
visible.value = false
84+
}
85+
}
86+
87+
watch(visible, (newValue) => {
88+
if (newValue) {
89+
nextTick(() => {
90+
searchInput.value?.focus()
91+
})
92+
} else {
93+
searchResults.value = []
94+
searchKeyword.value = ''
95+
}
96+
})
97+
98+
watch(searchKeyword, (newValue) => {
99+
handleSearch(newValue)
100+
})
101+
</script>
102+
103+
<template>
104+
<div class="search-trigger" @click="visible = true">
105+
<icon-search :size="18" style="margin-right: 4px;" />
106+
<span class="search-text">
107+
搜索
108+
</span>
109+
<span class="shortcut-key">
110+
Ctrl + K
111+
</span>
112+
</div>
113+
<div class="search-modal">
114+
<a-modal
115+
:visible="visible"
116+
:footer="false"
117+
:mask-closable="true"
118+
:align-center="false"
119+
:closable="false"
120+
:render-to-body="false"
121+
@cancel="visible = false"
122+
@keydown="handleKeyDown"
123+
>
124+
<template #title>
125+
<div class="search-input-wrapper">
126+
<icon-search :size="24" />
127+
<input
128+
ref="searchInput"
129+
v-model="searchKeyword"
130+
placeholder="搜索页面"
131+
class="search-input"
132+
>
133+
<div class="esc-tip">
134+
ESC 退出
135+
</div>
136+
</div>
137+
</template>
138+
<div class="search-content">
139+
<div v-if="searchResults.length">
140+
<div class="result-count">
141+
搜索到 {{ searchResults.length }} 个结果
142+
</div>
143+
<div class="result-list">
144+
<div
145+
v-for="item in searchResults"
146+
:key="item.path"
147+
class="result-item"
148+
@click="handleResultClick(item)"
149+
>
150+
<icon-file :size="18" style="margin-right: 6px;" />
151+
<div class="result-title">
152+
{{ item.title }}
153+
</div>
154+
</div>
155+
</div>
156+
</div>
157+
<div v-if="searchHistory.length" class="history-section">
158+
<div class="history-header">
159+
<div class="history-title">
160+
搜索历史
161+
</div>
162+
<a-button
163+
type="text"
164+
size="small"
165+
class="text-xs"
166+
@click="clearHistory"
167+
>
168+
清空历史
169+
</a-button>
170+
</div>
171+
<div class="history-list">
172+
<div
173+
v-for="item in searchHistory" :key="item.path"
174+
class="history-item"
175+
@click="handleHistoryClick(item)"
176+
>
177+
<icon-history :size="18" style="margin-right: 6px;" />
178+
<div class="result-title">
179+
{{ item.title }}
180+
</div>
181+
</div>
182+
</div>
183+
</div>
184+
</div>
185+
</a-modal>
186+
</div>
187+
</template>
188+
189+
<style scoped lang="scss">
190+
.search-trigger {
191+
display: flex;
192+
align-items: center;
193+
padding: 0.5rem 1rem;
194+
margin-right: 1rem;
195+
border-radius: 0.5rem;
196+
cursor: pointer;
197+
background-color: #f3f4f6;
198+
199+
.dark & {
200+
background-color: var(--color-bg-2);
201+
}
202+
}
203+
204+
.search-text {
205+
line-height: 1;
206+
color: var(--color-text-3);
207+
}
208+
209+
.shortcut-key {
210+
margin-left: 1rem;
211+
padding: 2px 6px;
212+
font-size: 10px;
213+
border-radius: 4px;
214+
background-color: #e5e7eb;
215+
color: var(--color-text-3);
216+
217+
.dark & {
218+
background-color: var(--color-bg-4);
219+
}
220+
}
221+
222+
.search-modal {
223+
:deep(.arco-modal-header) {
224+
height: 64px;
225+
}
226+
227+
:deep(.arco-modal-body) {
228+
.search-content {
229+
max-height: 50vh;
230+
overflow-y: auto;
231+
}
232+
}
233+
}
234+
235+
.search-input-wrapper {
236+
width: 100%;
237+
display: flex;
238+
align-items: center;
239+
position: relative;
240+
}
241+
242+
.search-input {
243+
width: 100%;
244+
padding: 0.5rem 4rem 0.5rem 0.5rem;
245+
border: none;
246+
outline: none;
247+
background-color: transparent;
248+
color: var(--color-text-3);
249+
}
250+
251+
.esc-tip {
252+
position: absolute;
253+
right: 0.75rem;
254+
display: flex;
255+
align-items: center;
256+
padding: 0.25rem 0.375rem;
257+
font-size: 0.75rem;
258+
border-radius: 0.375rem;
259+
background-color: #e5e7eb;
260+
color: var(--color-text-3);
261+
262+
.dark & {
263+
background-color: var(--color-bg-4);
264+
}
265+
}
266+
267+
.result-count {
268+
font-size: 0.875rem;
269+
color: var(--color-text-3);
270+
margin-bottom: 0.5rem;
271+
}
272+
273+
.result-list {
274+
display: flex;
275+
flex-direction: column;
276+
gap: 0.25rem;
277+
}
278+
279+
.result-item {
280+
display: flex;
281+
align-items: center;
282+
padding: 0.5rem;
283+
border-radius: 0.5rem;
284+
cursor: pointer;
285+
color: var(--color-text-3);
286+
287+
&:hover {
288+
color: #000;
289+
background-color: #f3f4f6;
290+
291+
.dark & {
292+
color: #fff;
293+
background-color: var(--color-bg-4);
294+
}
295+
}
296+
}
297+
298+
.history-section {
299+
margin-top: 1rem;
300+
}
301+
302+
.history-header {
303+
display: flex;
304+
justify-content: space-between;
305+
align-items: center;
306+
margin-bottom: 0.5rem;
307+
}
308+
309+
.history-title {
310+
font-size: 0.875rem;
311+
color: var(--color-text-3);
312+
}
313+
314+
.clear-history {
315+
font-size: 0.75rem;
316+
}
317+
318+
.history-list {
319+
display: flex;
320+
flex-direction: column;
321+
gap: 1rem;
322+
}
323+
324+
.history-item {
325+
display: flex;
326+
align-items: center;
327+
padding: 0.5rem;
328+
border-radius: 0.5rem;
329+
cursor: pointer;
330+
color: var(--color-text-3);
331+
332+
&:hover {
333+
color: #000;
334+
background-color: #f3f4f6;
335+
336+
.dark & {
337+
color: #fff;
338+
background-color: var(--color-bg-4);
339+
}
340+
}
341+
}
342+
</style>

src/layout/components/HeaderRightBar/index.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<template>
22
<a-row justify="end" align="center">
33
<a-space size="medium">
4+
<!-- 搜索 -->
5+
<Search />
46
<!-- 项目配置 -->
57
<a-tooltip content="项目配置" position="bl">
68
<a-button size="mini" class="gi_hover_btn" @click="SettingDrawerRef?.open">
@@ -74,6 +76,7 @@ import { useFullscreen } from '@vueuse/core'
7476
import { onMounted, ref } from 'vue'
7577
import Message from './Message.vue'
7678
import SettingDrawer from './SettingDrawer.vue'
79+
import Search from './Search.vue'
7780
import { getUnreadMessageCount } from '@/apis'
7881
import { useUserStore } from '@/stores'
7982
import { getToken } from '@/utils/auth'

0 commit comments

Comments
 (0)