refactor(project): business changed its name to effects, and universal-ui changed its name to common-ui

This commit is contained in:
vben
2024-07-13 17:25:15 +08:00
parent 5e0b01c725
commit 7eff46d80c
186 changed files with 110 additions and 107 deletions

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import {
IcRoundArrowDownward,
IcRoundArrowUpward,
IcRoundSearch,
IcRoundSubdirectoryArrowLeft,
MdiKeyboardEsc,
} from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@vben-core/shadcn-ui';
import { isWindowsOs } from '@vben-core/toolkit';
import { useMagicKeys, useToggle, whenever } from '@vueuse/core';
import SearchPanel from './search-panel.vue';
defineOptions({
name: 'GlobalSearch',
});
const props = withDefaults(
defineProps<{ enableShortcutKey?: boolean; menus: MenuRecordRaw[] }>(),
{
enableShortcutKey: true,
menus: () => [],
},
);
const [open, toggleOpen] = useToggle();
const keyword = ref('');
function handleClose() {
open.value = false;
keyword.value = '';
}
const keys = useMagicKeys();
const cmd = isWindowsOs() ? keys['ctrl+k'] : keys['cmd+k'];
whenever(cmd, () => {
if (props.enableShortcutKey) {
open.value = true;
}
});
const preventDefaultBrowserSearchHotKey = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
}
};
const toggleKeydownListener = () => {
if (props.enableShortcutKey) {
window.addEventListener('keydown', preventDefaultBrowserSearchHotKey);
} else {
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
}
};
watch(() => props.enableShortcutKey, toggleKeydownListener);
onMounted(() => {
toggleKeydownListener();
onUnmounted(() => {
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
});
});
</script>
<template>
<div>
<Dialog :open="open">
<DialogTrigger as-child>
<div
class="md:bg-accent group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-none"
@click="toggleOpen()"
>
<IcRoundSearch
class="text-muted-foreground group-hover:text-foreground size-4 group-hover:opacity-100"
/>
<span
class="text-muted-foreground group-hover:text-foreground hidden text-sm duration-300 md:block"
>
{{ $t('widgets.search.title') }}
</span>
<span
v-if="enableShortcutKey"
class="bg-background border-foreground/60 text-muted-foreground group-hover:text-foreground relative hidden rounded-sm rounded-r-xl px-1.5 py-1 text-xs leading-none group-hover:opacity-100 md:block"
>
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
<kbd>K</kbd>
</span>
<span v-else></span>
</div>
</DialogTrigger>
<DialogContent
class="top-0 h-full w-full -translate-y-0 border-none p-0 shadow-xl sm:top-[10%] sm:h-[unset] sm:w-[600px] sm:rounded-2xl"
@close="handleClose"
>
<DialogHeader>
<DialogTitle
class="border-border flex h-12 items-center gap-5 border-b px-5 font-normal"
>
<IcRoundSearch class="mt-1 size-4" />
<input
v-model="keyword"
:placeholder="$t('widgets.search.searchNavigate')"
class="ring-none placeholder:text-muted-foreground w-[80%] rounded-md border border-none bg-transparent p-2 text-sm outline-none ring-0 ring-offset-transparent focus-visible:ring-transparent"
/>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
<DialogFooter
class="text-muted-foreground border-border hidden flex-row rounded-b-2xl border-t px-4 py-2 text-xs sm:flex sm:justify-start sm:gap-x-4"
>
<div class="flex items-center">
<IcRoundSubdirectoryArrowLeft class="mr-1" />
{{ $t('widgets.search.select') }}
</div>
<div class="flex items-center">
<IcRoundArrowUpward class="mr-2" />
<IcRoundArrowDownward class="mr-2" />
{{ $t('widgets.search.navigate') }}
</div>
<div class="flex items-center">
<MdiKeyboardEsc class="mr-1" />
{{ $t('widgets.search.close') }}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as GlobalSearch } from './global-search.vue';

View File

@@ -0,0 +1,280 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
import { useRouter } from 'vue-router';
import { IcRoundClose, IcRoundSearchOff } from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import { VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
import { mapTree, traverseTreeValues, uniqueByField } from '@vben-core/toolkit';
import { onKeyStroke, useLocalStorage, useThrottleFn } from '@vueuse/core';
defineOptions({
name: 'SearchPanel',
});
const props = withDefaults(
defineProps<{ keyword: string; menus: MenuRecordRaw[] }>(),
{
keyword: '',
menus: () => [],
},
);
const emit = defineEmits<{ close: [] }>();
const router = useRouter();
const searchHistory = useLocalStorage<MenuRecordRaw[]>(
`__search-history-${location.hostname}__`,
[],
);
const activeIndex = ref(-1);
const searchItems = shallowRef<MenuRecordRaw[]>([]);
const searchResults = ref<MenuRecordRaw[]>([]);
const handleSearch = useThrottleFn(search, 200);
// 搜索函数,用于根据搜索关键词查找匹配的菜单项
function search(searchKey: string) {
// 去除搜索关键词的前后空格
searchKey = searchKey.trim();
// 如果搜索关键词为空,清空搜索结果并返回
if (!searchKey) {
searchResults.value = [];
return;
}
// 使用搜索关键词创建正则表达式
const reg = createSearchReg(searchKey);
// 初始化结果数组
const results: MenuRecordRaw[] = [];
// 遍历搜索项
traverseTreeValues(searchItems.value, (item) => {
// 如果菜单项的名称匹配正则表达式,将其添加到结果数组中
if (reg.test(item.name?.toLowerCase())) {
results.push(item);
}
});
// 更新搜索结果
searchResults.value = results;
// 如果有搜索结果,设置索引为 0
if (results.length > 0) {
activeIndex.value = 0;
}
// 赋值索引为 0
activeIndex.value = 0;
}
// When the keyboard up and down keys move to an invisible place
// the scroll bar needs to scroll automatically
function scrollIntoView() {
const element = document.querySelector(
`[data-search-item="${activeIndex.value}"`,
);
if (element) {
element.scrollIntoView({ block: 'nearest' });
}
}
// enter keyboard event
async function handleEnter() {
if (searchResults.value.length === 0) {
return;
}
const result = searchResults.value;
const index = activeIndex.value;
if (result.length === 0 || index < 0) {
return;
}
const to = result[index];
searchHistory.value.push(to);
handleClose();
await nextTick();
router.push(to.path);
}
// Arrow key up
function handleUp() {
if (searchResults.value.length === 0) {
return;
}
activeIndex.value--;
if (activeIndex.value < 0) {
activeIndex.value = searchResults.value.length - 1;
}
scrollIntoView();
}
// Arrow key down
function handleDown() {
if (searchResults.value.length === 0) {
return;
}
activeIndex.value++;
if (activeIndex.value > searchResults.value.length - 1) {
activeIndex.value = 0;
}
scrollIntoView();
}
// close search modal
function handleClose() {
searchResults.value = [];
emit('close');
}
// Activate when the mouse moves to a certain line
function handleMouseenter(e: MouseEvent) {
const index = (e.target as HTMLElement)?.dataset.index;
activeIndex.value = Number(index);
}
function removeItem(index: number) {
if (props.keyword) {
searchResults.value.splice(index, 1);
} else {
searchHistory.value.splice(index, 1);
}
activeIndex.value = activeIndex.value - 1 >= 0 ? activeIndex.value - 1 : 0;
scrollIntoView();
}
// 存储所有需要转义的特殊字符
const code = new Set([
'$',
'(',
')',
'*',
'+',
'.',
'[',
']',
'?',
'\\',
'^',
'{',
'}',
'|',
]);
// 转换函数,用于转义特殊字符
function transform(c: string) {
// 如果字符在特殊字符列表中,返回转义后的字符
// 如果不在,返回字符本身
return code.has(c) ? `\\${c}` : c;
}
// 创建搜索正则表达式
function createSearchReg(key: string) {
// 将输入的字符串拆分为单个字符
// 对每个字符进行转义
// 然后用'.*'连接所有字符,创建正则表达式
const keys = [...key].map((item) => transform(item)).join('.*');
// 返回创建的正则表达式
return new RegExp(`.*${keys}.*`);
}
watch(
() => props.keyword,
(val) => {
if (val) {
handleSearch(val);
} else {
searchResults.value = [...searchHistory.value];
}
},
);
onMounted(() => {
searchItems.value = mapTree(props.menus, (item) => {
return {
...item,
name: $t(item?.name),
};
});
if (searchHistory.value.length > 0) {
searchResults.value = searchHistory.value;
}
// enter search
onKeyStroke('Enter', handleEnter);
// Monitor keyboard arrow keys
onKeyStroke('ArrowUp', handleUp);
onKeyStroke('ArrowDown', handleDown);
// esc close
onKeyStroke('Escape', handleClose);
});
</script>
<template>
<VbenScrollbar>
<div class="!flex h-full justify-center px-4 sm:max-h-[450px]">
<!-- 无搜索结果 -->
<div
v-if="keyword && searchResults.length === 0"
class="text-muted-foreground text-center"
>
<IcRoundSearchOff class="size-12" />
<p class="my-10 text-xs">
{{ $t('widgets.search.noResults') }}
<span class="text-foreground text-sm font-medium">
"{{ keyword }}"
</span>
</p>
</div>
<!-- 历史搜索记录 & 没有搜索结果 -->
<div
v-if="!keyword && searchResults.length === 0"
class="text-muted-foreground text-center"
>
<p class="my-10 text-xs">
{{ $t('widgets.search.noRecent') }}
</p>
</div>
<ul v-show="searchResults.length > 0" class="w-full">
<li
v-if="searchHistory.length > 0 && !keyword"
class="text-muted-foreground mb-2 text-xs"
>
{{ $t('widgets.search.recent') }}
</li>
<li
v-for="(item, index) in uniqueByField(searchResults, 'path')"
:key="item.path"
:class="
activeIndex === index
? 'active bg-primary text-primary-foreground'
: ''
"
:data-index="index"
:data-search-item="index"
class="bg-accent flex-center group mb-3 w-full cursor-pointer rounded-lg px-4 py-4"
@click="handleEnter"
@mouseenter="handleMouseenter"
>
<VbenIcon
:icon="item.icon"
class="mr-2 size-5 flex-shrink-0"
fallback
/>
<span class="flex-1">{{ item.name }}</span>
<div
class="flex-center dark:hover:bg-accent hover:text-primary-foreground rounded-full p-1 hover:scale-110"
@click.stop="removeItem(index)"
>
<IcRoundClose />
</div>
</li>
</ul>
</div>
</VbenScrollbar>
</template>