search.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. <script setup lang="ts">
  2. import type { Menu } from '@/types/global'
  3. import { useFocus } from '@vueuse/core'
  4. import hotkeys from 'hotkeys-js'
  5. import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
  6. import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
  7. import { resolveRoutePath } from '@/utils'
  8. defineOptions({
  9. name: 'NavSearchModal',
  10. })
  11. const isShow = defineModel<boolean>({
  12. default: false,
  13. })
  14. const router = useRouter()
  15. const settingsStore = useSettingsStore()
  16. const routeStore = useRouteStore()
  17. const menuStore = useMenuStore()
  18. interface listTypes {
  19. path: string
  20. icon?: string
  21. title?: string | (() => string)
  22. link?: string
  23. }
  24. const searchInput = ref('')
  25. const searchInputRef = useTemplateRef('searchInputRef')
  26. const { focused: searchInputFocused } = useFocus(searchInputRef)
  27. const sourceList = ref<listTypes[]>([])
  28. const actived = ref(-1)
  29. const searchResultRef = useTemplateRef('searchResultRef')
  30. const searchResultItemRef = useTemplateRef<HTMLElement[]>('searchResultItemRef')
  31. const resultList = computed(() => {
  32. let result = []
  33. result = sourceList.value.filter((item) => {
  34. let flag = false
  35. if (searchInput.value !== '') {
  36. if (item.title) {
  37. if (typeof item.title === 'function') {
  38. if (item.title().includes(searchInput.value)) {
  39. flag = true
  40. }
  41. }
  42. else {
  43. if (item.title.includes(searchInput.value)) {
  44. flag = true
  45. }
  46. }
  47. }
  48. if (item.path.includes(searchInput.value)) {
  49. flag = true
  50. }
  51. if (routeStore.getRouteMatchedByPath(item.path).some((b) => {
  52. return typeof b.meta?.title === 'function' ? b.meta?.title().includes(searchInput.value) : b.meta?.title?.includes(searchInput.value)
  53. })) {
  54. flag = true
  55. }
  56. }
  57. return flag
  58. })
  59. return result
  60. })
  61. watch(() => isShow.value, (val) => {
  62. if (val) {
  63. searchInput.value = ''
  64. actived.value = -1
  65. // 当搜索显示的时候绑定上、下、回车快捷键,隐藏的时候再解绑。另外当 input 处于 focus 状态时,采用 vue 来绑定键盘事件
  66. hotkeys('up', keyUp)
  67. hotkeys('down', keyDown)
  68. hotkeys('enter', keyEnter)
  69. hotkeys('esc', (e) => {
  70. if (settingsStore.settings.navSearch.enableHotkeys) {
  71. e.preventDefault()
  72. isShow.value = false
  73. }
  74. })
  75. }
  76. else {
  77. hotkeys.unbind('up', keyUp)
  78. hotkeys.unbind('down', keyDown)
  79. hotkeys.unbind('enter', keyEnter)
  80. hotkeys.unbind('esc')
  81. }
  82. })
  83. watch(() => resultList.value, () => {
  84. actived.value = -1
  85. handleScroll()
  86. })
  87. onMounted(() => {
  88. initSourceList()
  89. hotkeys('command+k, ctrl+k', (e) => {
  90. if (settingsStore.settings.navSearch.enableHotkeys) {
  91. e.preventDefault()
  92. isShow.value = true
  93. }
  94. })
  95. })
  96. onUnmounted(() => {
  97. hotkeys.unbind('command+k, ctrl+k')
  98. })
  99. function initSourceList() {
  100. sourceList.value = []
  101. menuStore.allMenus.forEach((item) => {
  102. getSourceList(item.children)
  103. })
  104. }
  105. function hasChildren(item: Menu.recordRaw) {
  106. let flag = true
  107. if (item.children?.every(i => i.meta?.menu === false)) {
  108. flag = false
  109. }
  110. return flag
  111. }
  112. function getSourceList(arr: Menu.recordRaw[], basePath?: string, icon?: string) {
  113. arr.forEach((item) => {
  114. if (item.meta?.menu !== false) {
  115. if (item.children && hasChildren(item)) {
  116. getSourceList(item.children, resolveRoutePath(basePath, item.path), item.meta?.icon ?? icon)
  117. }
  118. else {
  119. sourceList.value.push({
  120. path: resolveRoutePath(basePath, item.path),
  121. icon: item.meta?.icon ?? icon,
  122. title: item.meta?.title,
  123. link: item.meta?.link,
  124. })
  125. }
  126. }
  127. })
  128. }
  129. function keyUp() {
  130. if (resultList.value.length) {
  131. actived.value -= 1
  132. if (actived.value < 0) {
  133. actived.value = resultList.value.length - 1
  134. }
  135. handleScroll()
  136. }
  137. }
  138. function keyDown() {
  139. if (resultList.value.length) {
  140. actived.value += 1
  141. if (actived.value > resultList.value.length - 1) {
  142. actived.value = 0
  143. }
  144. handleScroll()
  145. }
  146. }
  147. function keyEnter() {
  148. if (actived.value !== -1) {
  149. searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.click()
  150. }
  151. }
  152. function handleScroll() {
  153. if (searchResultRef.value?.areaRef?.ref?.el?.viewportElement) {
  154. const contentDom = searchResultRef.value.areaRef.ref.el.viewportElement
  155. let scrollTo = 0
  156. if (actived.value !== -1) {
  157. scrollTo = contentDom.scrollTop
  158. const activedOffsetTop = searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.offsetTop ?? 0
  159. const activedClientHeight = searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.clientHeight ?? 0
  160. const searchScrollTop = contentDom.scrollTop
  161. const searchClientHeight = contentDom.clientHeight
  162. if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight) {
  163. scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight
  164. }
  165. else if (activedOffsetTop <= searchScrollTop) {
  166. scrollTo = activedOffsetTop - 16
  167. }
  168. }
  169. contentDom.scrollTo({
  170. top: scrollTo,
  171. })
  172. }
  173. }
  174. function pageJump(path: listTypes['path'], link: listTypes['link']) {
  175. if (link) {
  176. window.open(link, '_blank')
  177. }
  178. else {
  179. router.push(path)
  180. }
  181. isShow.value = false
  182. }
  183. </script>
  184. <template>
  185. <FaModal ref="searchResultRef" v-model="isShow" border :footer="settingsStore.mode === 'pc'" :closable="false" class="w-full lg-max-w-2xl" content-class="flex flex-col p-0 min-h-auto" header-class="p-0" footer-class="p-0" @opened="searchInputFocused = true">
  186. <template #header>
  187. <div class="h-12 flex flex-shrink-0 items-center">
  188. <div class="h-full w-14 flex-center">
  189. <FaIcon name="i-ri:search-line" class="size-4 text-foreground/30" />
  190. </div>
  191. <input ref="searchInputRef" v-model="searchInput" placeholder="搜索页面,支持标题、URL模糊查询" class="h-full w-full border-0 rounded-md bg-transparent text-base text-foreground focus-outline-none placeholder-foreground/30" @keydown.esc.prevent="isShow = false" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.enter.prevent="keyEnter">
  192. <div v-if="settingsStore.mode === 'mobile'" class="h-full w-14 flex-center border-s">
  193. <FaIcon name="i-carbon:close" class="size-4" @click="isShow = false" />
  194. </div>
  195. </div>
  196. </template>
  197. <template #footer>
  198. <div class="w-full flex justify-between px-4 py-3">
  199. <div class="flex gap-8">
  200. <div class="inline-flex items-center gap-1 text-xs">
  201. <FaKbd>
  202. <FaIcon name="i-ion:md-return-left" class="size-4" />
  203. </FaKbd>
  204. <span>访问</span>
  205. </div>
  206. <div class="inline-flex items-center gap-1 text-xs">
  207. <FaKbd>
  208. <FaIcon name="i-ant-design:caret-up-filled" class="size-4" />
  209. </FaKbd>
  210. <FaKbd>
  211. <FaIcon name="i-ant-design:caret-down-filled" class="size-4" />
  212. </FaKbd>
  213. <span>切换</span>
  214. </div>
  215. </div>
  216. <div v-if="settingsStore.settings.navSearch.enableHotkeys" class="inline-flex items-center gap-1 text-xs">
  217. <FaKbd>
  218. ESC
  219. </FaKbd>
  220. <span>退出</span>
  221. </div>
  222. </div>
  223. </template>
  224. <div>
  225. <template v-if="resultList.length > 0">
  226. <div v-for="(item, index) in resultList" ref="searchResultItemRef" :key="item.path" class="p-4" :data-index="index" @click="pageJump(item.path, item.link)" @mouseover="actived = index">
  227. <a class="flex cursor-pointer items-center border rounded-lg" :class="{ '-mt-4': index !== 0, 'bg-accent': index === actived }">
  228. <FaIcon v-if="item.icon" :name="item.icon" class="size-5 basis-16 transition" :class="{ 'scale-120 text-primary': index === actived }" />
  229. <div class="flex flex-1 flex-col gap-1 truncate border-s px-4 py-3">
  230. <div class="truncate text-start text-base font-bold">{{ (typeof item.title === 'function' ? item.title() : item.title) ?? '[ 无标题 ]' }}</div>
  231. <Breadcrumb v-if="routeStore.getRouteMatchedByPath(item.path).length" class="truncate">
  232. <BreadcrumbItem v-for="(bc, bcIndex) in routeStore.getRouteMatchedByPath(item.path)" :key="bcIndex" class="text-xs">
  233. {{ (typeof bc.meta?.title === 'function' ? bc.meta?.title() : bc.meta?.title) ?? '[ 无标题 ]' }}
  234. </BreadcrumbItem>
  235. </Breadcrumb>
  236. </div>
  237. </a>
  238. </div>
  239. </template>
  240. <template v-else-if="searchInput === ''">
  241. <div class="h-full flex-col-center py-6 text-secondary-foreground/50">
  242. <FaIcon name="i-tabler:mood-smile" class="size-10" />
  243. <p class="m-2 text-base">
  244. 输入你要搜索的导航
  245. </p>
  246. </div>
  247. </template>
  248. <template v-else>
  249. <div class="h-full flex-col-center py-6 text-secondary-foreground/50">
  250. <FaIcon name="i-tabler:mood-empty" class="size-10" />
  251. <p class="m-2 text-base">
  252. 没有找到你想要的
  253. </p>
  254. </div>
  255. </template>
  256. </div>
  257. </FaModal>
  258. </template>