mirror of
				https://github.com/continew-org/continew-admin-ui.git
				synced 2025-11-04 20:59:23 +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>
 | 
			
		||||
  <a-row justify="end" align="center">
 | 
			
		||||
    <a-space size="medium">
 | 
			
		||||
      <!-- 搜索 -->
 | 
			
		||||
      <Search />
 | 
			
		||||
      <!-- 项目配置 -->
 | 
			
		||||
      <a-tooltip content="项目配置" position="bl">
 | 
			
		||||
        <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 Message from './Message.vue'
 | 
			
		||||
import SettingDrawer from './SettingDrawer.vue'
 | 
			
		||||
import Search from './Search.vue'
 | 
			
		||||
import { getUnreadMessageCount } from '@/apis'
 | 
			
		||||
import { useUserStore } from '@/stores'
 | 
			
		||||
import { getToken } from '@/utils/auth'
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user