Skip to content

Commit

Permalink
feat: add search anywhere
Browse files Browse the repository at this point in the history
  • Loading branch information
kirklin committed Jan 7, 2024
1 parent 3f0cf58 commit b180cd8
Show file tree
Hide file tree
Showing 19 changed files with 508 additions and 17 deletions.
3 changes: 3 additions & 0 deletions apps/admin/autoResolver/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ declare global {
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSearchDialog: typeof import('../src/composables/useSearchDialog')['useSearchDialog']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
Expand Down Expand Up @@ -558,6 +559,7 @@ declare module 'vue' {
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSearchDialog: UnwrapRef<typeof import('../src/composables/useSearchDialog')['useSearchDialog']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
Expand Down Expand Up @@ -861,6 +863,7 @@ declare module '@vue/runtime-core' {
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSearchDialog: UnwrapRef<typeof import('../src/composables/useSearchDialog')['useSearchDialog']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
Expand Down
5 changes: 2 additions & 3 deletions apps/admin/autoResolver/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,25 @@ declare module 'vue' {
NGrid: typeof import('@celeris/ca-components')['NGrid']
NGridItem: typeof import('@celeris/ca-components')['NGridItem']
NInput: typeof import('@celeris/ca-components')['NInput']
NInputGroup: typeof import('@celeris/ca-components')['NInputGroup']
NLayout: typeof import('@celeris/ca-components')['NLayout']
NLayoutContent: typeof import('@celeris/ca-components')['NLayoutContent']
NLayoutSider: typeof import('@celeris/ca-components')['NLayoutSider']
NLoadingBarProvider: typeof import('@celeris/ca-components')['NLoadingBarProvider']
NMenu: typeof import('@celeris/ca-components')['NMenu']
NMessageProvider: typeof import('@celeris/ca-components')['NMessageProvider']
NModal: typeof import('@celeris/ca-components')['NModal']
NNotificationProvider: typeof import('@celeris/ca-components')['NNotificationProvider']
NPopconfirm: typeof import('@celeris/ca-components')['NPopconfirm']
NPopover: typeof import('@celeris/ca-components')['NPopover']
NResult: typeof import('@celeris/ca-components')['NResult']
NScrollbar: typeof import('@celeris/ca-components')['NScrollbar']
NSelect: typeof import('@celeris/ca-components')['NSelect']
NSpace: typeof import('@celeris/ca-components')['NSpace']
NSpin: typeof import('@celeris/ca-components')['NSpin']
NSplit: typeof import('@celeris/ca-components')['NSplit']
NStep: typeof import('@celeris/ca-components')['NStep']
NSteps: typeof import('@celeris/ca-components')['NSteps']
NSwitch: typeof import('@celeris/ca-components')['NSwitch']
NTable: typeof import('@celeris/ca-components')['NTable']
NText: typeof import('@celeris/ca-components')['NText']
NTooltip: typeof import('@celeris/ca-components')['NTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Expand Down
1 change: 1 addition & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"pinia-plugin-persistedstate": "^3.2.1",
"pkg-types": "^1.0.3",
"vue": "^3.4.5",
"vue-highlight-words": "^3.0.1",
"vue-i18n": "^9.8.0",
"vue-router": "^4.2.5"
},
Expand Down
4 changes: 4 additions & 0 deletions apps/admin/src/component/SearchDialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { withInstall } from "@celeris/utils";
import searchDialog from "./src/SearchDialog.vue";

export const SearchDialog = withInstall(searchDialog);
304 changes: 304 additions & 0 deletions apps/admin/src/component/SearchDialog/src/SearchDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
<script lang="ts" setup>
import type { ScrollbarInst } from "naive-ui";
import { isWindows } from "@celeris/utils";
import Highlighter from "vue-highlight-words";
import { useSearchDialog } from "~/composables/useSearchDialog";
import type { SearchGroupItem, SearchGroups } from "~/component/SearchDialog/src/types";
const SearchIcon = "tabler:search";
const ArrowEnterIcon = "fluent:arrow-enter-left-24-regular";
const ArrowSortIcon = "fluent:arrow-sort-24-regular";
const FullScreenIcon = "tabler:maximize";
const CloseIcon = "tabler:x";
const { toggle: toggleFullScreen } = useFullscreen();
const router = useRouter();
const { t } = useI18n();
const isDialogVisible = ref(false);
const search = ref("");
const activeItem = ref<null | string | number>(null);
const scrollContent = ref<(ScrollbarInst & { $el: any }) | null>(null);
// TODO 根据路由、ChatBot 的角色和系统行为自动生成数据。
const groups = ref<SearchGroups>([
{
name: t("searchDialog.applications"),
items: [
{
iconName: "tabler:home",
iconImage: null,
key: 1,
title: "回到首页",
label: t("searchDialog.shortcut"),
action() {
router.push({ path: "/" });
},
},
],
},
{
name: t("searchDialog.chatBot"),
items: [
{
iconName: null,
iconImage: "https://avatars.githubusercontent.com/u/17453452",
key: 4,
title: "产品经理",
label: "负责产品规划和功能开发的专业人员",
action() {
router.push({ path: "/chat" });
},
},
],
},
{
name: t("searchDialog.actions"),
items: [
{
iconName: FullScreenIcon,
iconImage: null,
key: 7,
title: "Toggle fullscreen",
label: t("searchDialog.action"),
action() {
toggleFullScreen();
},
},
],
},
]);
const keywords = computed<string[]>(() => {
return search.value.length > 1 ? search.value.split(" ").filter(k => k) : [];
});
const filteredGroups = computed<SearchGroups>(() => {
if (keywords.value.length === 0) {
return groups.value;
}
const newGroups: SearchGroups = groups.value.map((group) => {
const items = group.items.filter((item) => {
const titleMatch = keywords.value.some(k => item.title.toLowerCase().includes(k.toLowerCase()));
const tagsMatch = item.tags && keywords.value.some(k => item.tags?.toLowerCase().includes(k.toLowerCase()));
return titleMatch || tagsMatch;
});
return items.length ? { ...group, items } : null;
}).filter(Boolean) as SearchGroups;
return newGroups;
});
const filteredFlattenItems = computed<SearchGroupItem[]>(() => {
return filteredGroups.value.flatMap(group => group.items);
});
function resetValues() {
search.value = "";
activeItem.value = null;
}
function openDialog(e?: MouseEvent) {
if (!isDialogVisible.value) {
isDialogVisible.value = true;
setTimeout(resetValues, 100);
}
return e;
}
function closeDialog() {
isDialogVisible.value = false;
resetValues();
}
function executeAction(action: () => void) {
action();
closeDialog();
}
function updateActiveItem(increment: number) {
const currentIndex = filteredFlattenItems.value.findIndex(item => item.key === activeItem.value);
const lastIndex = filteredFlattenItems.value.length - 1;
if (activeItem.value === null) {
activeItem.value = filteredFlattenItems.value[0].key;
} else if (currentIndex === 0 && increment === -1) {
activeItem.value = filteredFlattenItems.value[lastIndex].key;
} else if (currentIndex === lastIndex && increment === 1) {
activeItem.value = filteredFlattenItems.value[0].key;
} else {
activeItem.value = filteredFlattenItems.value[currentIndex + increment].key;
}
centerActiveItem();
}
function moveNextItem() {
updateActiveItem(1);
}
function movePrevItem() {
updateActiveItem(-1);
}
function performAction() {
const item = filteredFlattenItems.value.find(item => item.key === activeItem.value);
if (item) {
executeAction(item.action);
}
}
function centerActiveItem() {
const element = document.getElementById(activeItem.value?.toString() || "");
if (element && scrollContent.value) {
element.scrollIntoView({ block: "nearest" });
}
}
onMounted(() => {
const keys = useMagicKeys();
const ActiveCMD = isWindows() ? keys["ctrl+k"] : keys["cmd+k"];
const Enter = keys.enter;
useSearchDialog().trigger(openDialog);
whenever(ActiveCMD, () => {
openDialog();
});
whenever(Enter, () => {
if (isDialogVisible.value) {
performAction();
}
});
});
</script>

<template>
<NModal v-model:show="isDialogVisible" class="search-dialog">
<NCard
class="w-1/4"
content-style="padding: 0;"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<div class="search-dialog-action-bar rounded-2xl" @keydown.up="movePrevItem()" @keydown.down="moveNextItem()">
<div class="search-input flex items-center gap-5 px-5 h-12">
<CAIcon :name="SearchIcon" :size="16" />
<input v-model="search" :placeholder="t('searchDialog.searchPlaceholder')" class="grow bg-transparent outline-none border-none">
<NText code>
ESC
</NText>
<CAIcon :name="CloseIcon" :size="20" class="cursor-pointer" @click="closeDialog()" />
</div>
<NDivider />
<NScrollbar ref="scrollContent" style="height: 400px">
<div class="content-wrap">
<div v-for="group of filteredGroups" :key="group.name" class="group">
<div class="group-title">
{{ group.name }}
</div>
<NEl class="group-list">
<NEl
v-for="item of group.items"
:id="item.key.toString()"
:key="item.key"
tag="button"
class="item flex items-center bg-[var(--modal-color)] my-2"
:class="{ active: item.key === activeItem }"
@click="executeAction(item.action)"
>
<NEl class="icon">
<NAvatar v-if="item.iconImage" round :size="28" :src="item.iconImage" />
<CAIcon v-if="item.iconName" :name="item.iconName" :size="18" />
</NEl>
<div class="title grow">
<Highlighter
highlight-class-name="highlight"
:search-words="keywords"
:auto-escape="true"
:text-to-highlight="item.title"
/>
</div>
<div class="label">
{{ item.label }}
</div>
</NEl>
</NEl>
</div>
<div v-if="!filteredGroups.length" class="group-empty">
{{ t('searchDialog.noResultsFound', { search }) }}
</div>
</div>
</NScrollbar>
<NDivider />
<NEl class="flex items-center justify-center space-x-4 py-2 text-xs">
<div class="flex items-center space-x-1">
<NEl class="w-4 h-4 bg-[var(--code-color)] rounded flex-center">
<CAIcon :name="ArrowEnterIcon" :size="12" />
</NEl>
<span class="opacity-70">{{ t('searchDialog.toSelectTooltip') }}</span>
</div>
<div class="flex items-center space-x-1">
<NEl class="w-4 h-4 bg-[var(--code-color)] rounded flex-center">
<CAIcon :name="ArrowSortIcon" :size="12" />
</NEl>
<span class="opacity-70">{{ t('searchDialog.toNavigateTooltip') }}</span>
</div>
</NEl>
</div>
</NCard>
</NModal>
</template>

<style scoped>
.search-dialog .search-dialog-action-bar .search-input .ca-text--code {
white-space: nowrap;
}
.search-dialog .search-dialog-action-bar .ca-divider {
margin-top: 0;
margin-bottom: 0;
}
.search-dialog .search-dialog-action-bar .content-wrap {
padding-bottom: 30px;
}
.search-dialog .search-dialog-action-bar .content-wrap .group-empty {
text-align: center;
padding: 30px 0 40px 0;
}
.search-dialog .search-dialog-action-bar .content-wrap .group {
padding: 0 10px;
}
.search-dialog .search-dialog-action-bar .content-wrap .group .group-title {
opacity: 0.6;
margin-bottom: 5px;
padding: 20px 10px 5px;
}
.search-dialog .search-dialog-action-bar .content-wrap .group .group-list .item {
padding: 7px 10px;
gap: 10px;
cursor: pointer;
border-radius: 10px;
width: 100%;
text-align: left;
}
.search-dialog .search-dialog-action-bar .content-wrap .group .group-list .item .icon {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--primary-color5);
display: flex;
justify-content: center;
align-items: center;
}
.search-dialog .search-dialog-action-bar .content-wrap .group .group-list .item .title {
font-weight: bold;
}
.search-dialog .search-dialog-action-bar .content-wrap .group .group-list .item .label {
opacity: 0.8;
font-size: 0.9em;
}
.search-dialog .search-dialog-action-bar .content-wrap .group .group-list .item.active {
background-color: var(--primary-color-hover);
}
.search-dialog .search-dialog-action-bar .content-wrap .group .group-list .item:hover {
box-shadow: 0 0 0 1px var(--primary-color-hover) inset;
}
</style>
15 changes: 15 additions & 0 deletions apps/admin/src/component/SearchDialog/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface SearchGroupItem {
iconName: string | null;
iconImage: string | null;
key: number | string;
title: string;
label: string;
tags?: string;
action: () => void;
}

export interface SearchGroup {
name: string;
items: SearchGroupItem[];
}
export type SearchGroups = SearchGroup[];
Loading

2 comments on commit b180cd8

@vercel
Copy link

@vercel vercel bot commented on b180cd8 Jan 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

celeris-web-api – ./services/admin

celeris-web-api-kirklin.vercel.app
celeris-web-api-git-master-kirklin.vercel.app
celeris-web-api.vercel.app

@vercel
Copy link

@vercel vercel bot commented on b180cd8 Jan 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

celeris-web – ./apps/admin

celeris-web.vercel.app
celeris-web-kirklin.vercel.app
celeris-web-git-master-kirklin.vercel.app

Please sign in to comment.