feat: 统一管理端弹窗与内容区交互样式
- 收敛管理端公共 Modal 链路,新增表单弹窗与普通内容弹窗包装\n- 迁移 Bot、知识库、插件、工作流、资源、MCP、数据中枢与系统管理页面级弹窗\n- 统一内容区工具栏、列表容器、导航与顶部按钮的视觉密度和交互节奏
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@easyflow/types';
|
||||
|
||||
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { SearchX, X } from '@easyflow/icons';
|
||||
import { SearchX } from '@easyflow/icons';
|
||||
import { $t } from '@easyflow/locales';
|
||||
import { mapTree, traverseTreeValues, uniqueByField } from '@easyflow/utils';
|
||||
|
||||
import { EasyFlowIcon, EasyFlowScrollbar } from '@easyflow-core/shadcn-ui';
|
||||
import { isHttpUrl } from '@easyflow-core/shared/utils';
|
||||
|
||||
import { onKeyStroke, useLocalStorage, useThrottleFn } from '@vueuse/core';
|
||||
import { onKeyStroke, useThrottleFn } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
name: 'SearchPanel',
|
||||
@@ -24,58 +24,39 @@ const props = withDefaults(
|
||||
menus: () => [],
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const emit = defineEmits<{ close: []; resultsChange: [count: number] }>();
|
||||
|
||||
const router = useRouter();
|
||||
const searchHistory = useLocalStorage<MenuRecordRaw[]>(
|
||||
`__search-history-${location.hostname}__`,
|
||||
[],
|
||||
);
|
||||
const activeIndex = ref(-1);
|
||||
const searchItems = shallowRef<MenuRecordRaw[]>([]);
|
||||
const searchResults = ref<MenuRecordRaw[]>([]);
|
||||
const displayResults = computed(() =>
|
||||
uniqueByField(searchResults.value, 'path'),
|
||||
);
|
||||
|
||||
const handleSearch = useThrottleFn(search, 200);
|
||||
|
||||
// 搜索函数,用于根据搜索关键词查找匹配的菜单项
|
||||
function search(searchKey: string) {
|
||||
// 去除搜索关键词的前后空格
|
||||
searchKey = searchKey.trim();
|
||||
|
||||
// 如果搜索关键词为空,清空搜索结果并返回
|
||||
if (!searchKey) {
|
||||
const normalizedKey = searchKey.trim();
|
||||
if (!normalizedKey) {
|
||||
searchResults.value = [];
|
||||
activeIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用搜索关键词创建正则表达式
|
||||
const reg = createSearchReg(searchKey);
|
||||
|
||||
// 初始化结果数组
|
||||
const reg = createSearchReg(normalizedKey);
|
||||
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;
|
||||
searchResults.value = uniqueByField(results, 'path');
|
||||
activeIndex.value = searchResults.value.length > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
// 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}"]`,
|
||||
@@ -86,76 +67,87 @@ function scrollIntoView() {
|
||||
}
|
||||
}
|
||||
|
||||
// enter keyboard event
|
||||
async function handleEnter() {
|
||||
if (searchResults.value.length === 0) {
|
||||
if (displayResults.value.length === 0 || activeIndex.value < 0) {
|
||||
return;
|
||||
}
|
||||
const result = searchResults.value;
|
||||
const index = activeIndex.value;
|
||||
if (result.length === 0 || index < 0) {
|
||||
await handleSelect(activeIndex.value);
|
||||
}
|
||||
|
||||
async function handleSelect(index: number) {
|
||||
const to = displayResults.value[index];
|
||||
if (!to) {
|
||||
return;
|
||||
}
|
||||
const to = result[index];
|
||||
if (to) {
|
||||
searchHistory.value = uniqueByField([...searchHistory.value, to], 'path');
|
||||
handleClose();
|
||||
await nextTick();
|
||||
if (isHttpUrl(to.path)) {
|
||||
window.open(to.path, '_blank');
|
||||
} else {
|
||||
router.push({ path: to.path, replace: true });
|
||||
}
|
||||
emit('close');
|
||||
await nextTick();
|
||||
if (isHttpUrl(to.path)) {
|
||||
window.open(to.path, '_blank');
|
||||
} else {
|
||||
router.push({ path: to.path, replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow key up
|
||||
function handleUp() {
|
||||
if (searchResults.value.length === 0) {
|
||||
if (displayResults.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
activeIndex.value--;
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = searchResults.value.length - 1;
|
||||
activeIndex.value = displayResults.value.length - 1;
|
||||
}
|
||||
scrollIntoView();
|
||||
}
|
||||
|
||||
// Arrow key down
|
||||
function handleDown() {
|
||||
if (searchResults.value.length === 0) {
|
||||
if (displayResults.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
activeIndex.value++;
|
||||
if (activeIndex.value > searchResults.value.length - 1) {
|
||||
if (activeIndex.value > displayResults.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 handleMouseenter(index: number) {
|
||||
activeIndex.value = index;
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
if (props.keyword) {
|
||||
searchResults.value.splice(index, 1);
|
||||
} else {
|
||||
searchHistory.value.splice(index, 1);
|
||||
function getHighlightedChunks(text: string, keyword: string) {
|
||||
const sourceChars = [...text];
|
||||
const lowerChars = [...text.toLowerCase()];
|
||||
const queryChars = [...keyword.trim().toLowerCase()];
|
||||
|
||||
if (queryChars.length === 0) {
|
||||
return [{ text, matched: false }];
|
||||
}
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||||
scrollIntoView();
|
||||
|
||||
const chunks: Array<{ matched: boolean; text: string }> = [];
|
||||
let queryIndex = 0;
|
||||
|
||||
sourceChars.forEach((char, index) => {
|
||||
const matched =
|
||||
queryIndex < queryChars.length &&
|
||||
lowerChars[index] === queryChars[queryIndex];
|
||||
if (matched) {
|
||||
queryIndex++;
|
||||
}
|
||||
const previousChunk = chunks[chunks.length - 1];
|
||||
if (previousChunk && previousChunk.matched === matched) {
|
||||
previousChunk.text += char;
|
||||
} else {
|
||||
chunks.push({ matched, text: char });
|
||||
}
|
||||
});
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// 存储所有需要转义的特殊字符
|
||||
const code = new Set([
|
||||
'$',
|
||||
'(',
|
||||
@@ -173,20 +165,12 @@ 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}.*`);
|
||||
}
|
||||
|
||||
@@ -196,11 +180,20 @@ watch(
|
||||
if (val) {
|
||||
handleSearch(val);
|
||||
} else {
|
||||
searchResults.value = [...searchHistory.value];
|
||||
searchResults.value = [];
|
||||
activeIndex.value = -1;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => displayResults.value.length,
|
||||
(count) => {
|
||||
emit('resultsChange', count);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
searchItems.value = mapTree(props.menus, (item) => {
|
||||
return {
|
||||
@@ -208,81 +201,67 @@ onMounted(() => {
|
||||
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>
|
||||
<EasyFlowScrollbar>
|
||||
<div class="!flex h-full justify-center px-2 sm:max-h-[450px]">
|
||||
<!-- 无搜索结果 -->
|
||||
<div
|
||||
v-if="keyword && searchResults.length === 0"
|
||||
class="text-muted-foreground text-center"
|
||||
>
|
||||
<SearchX class="mx-auto mt-4 size-12" />
|
||||
<p class="mb-10 mt-6 text-xs">
|
||||
{{ $t('ui.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('ui.widgets.search.noRecent') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul v-show="searchResults.length > 0" class="w-full">
|
||||
<div class="px-2 py-1 sm:max-h-[420px]">
|
||||
<ul v-if="displayResults.length > 0" class="space-y-2">
|
||||
<li
|
||||
v-if="searchHistory.length > 0 && !keyword"
|
||||
class="text-muted-foreground mb-2 text-xs"
|
||||
>
|
||||
{{ $t('ui.widgets.search.recent') }}
|
||||
</li>
|
||||
<li
|
||||
v-for="(item, index) in uniqueByField(searchResults, 'path')"
|
||||
v-for="(item, index) in displayResults"
|
||||
:key="item.path"
|
||||
:class="
|
||||
activeIndex === index
|
||||
? 'active bg-primary text-primary-foreground'
|
||||
? 'bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.22,hsl(var(--glass-tint))/0.1_18%,hsl(var(--surface-panel))/0.12)] shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.34,0_18px_30px_-24px_hsl(var(--primary)/0.12)] ring-1 ring-[hsl(var(--glass-border))/0.26]'
|
||||
: ''
|
||||
"
|
||||
: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"
|
||||
class="group flex w-full cursor-pointer items-center gap-3 rounded-2xl bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.14,hsl(var(--glass-tint))/0.08_18%,hsl(var(--surface-panel))/0.08)] px-4 py-3.5 backdrop-blur-2xl transition-[transform,background-color,box-shadow] hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.18,hsl(var(--glass-tint))/0.12_18%,hsl(var(--surface-panel))/0.1)] hover:shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.28,0_16px_28px_-24px_hsl(var(--primary)/0.1)]"
|
||||
@click="handleSelect(index)"
|
||||
@mouseenter="handleMouseenter(index)"
|
||||
>
|
||||
<EasyFlowIcon
|
||||
: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)"
|
||||
class="flex size-10 flex-shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.24,hsl(var(--nav-item-active))/0.09_22%,hsl(var(--glass-tint))/0.14)] text-[hsl(var(--nav-item-active-foreground))] shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.36]"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<EasyFlowIcon :icon="item.icon" class="size-5" fallback />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<span
|
||||
class="block truncate text-sm font-medium leading-6 text-[hsl(var(--text-strong))]"
|
||||
>
|
||||
<template
|
||||
v-for="(chunk, chunkIndex) in getHighlightedChunks(
|
||||
item.name || '',
|
||||
keyword,
|
||||
)"
|
||||
:key="`${item.path}-${chunkIndex}`"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
chunk.matched
|
||||
? activeIndex === index
|
||||
? 'rounded-md bg-[hsl(var(--primary))/0.1] px-0.5 font-semibold text-current'
|
||||
: 'rounded-md bg-[hsl(var(--primary))/0.08] px-0.5 font-semibold text-[hsl(var(--primary))]'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ chunk.text }}
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="hidden">
|
||||
<SearchX class="size-0" />
|
||||
</div>
|
||||
</div>
|
||||
</EasyFlowScrollbar>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user