feat: 增强命令面板功能与AI助手集成

This commit is contained in:
Ray.Hao
2026-01-06 20:21:29 +08:00
parent 2953642e99
commit 4a8efc770e
6 changed files with 314 additions and 55 deletions

View File

@@ -119,10 +119,11 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from "vue";
import { nextTick, onBeforeUnmount, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import AiCommandApi from "@/api/ai";
import { useSettingsStore } from "@/store";
type ToolFunctionCall = {
name: string;
@@ -156,6 +157,7 @@ type AiResponse = {
};
const router = useRouter();
const settingsStore = useSettingsStore();
// 状态管理
const dialogVisible = ref(false);
@@ -188,7 +190,7 @@ const getActiveRightDrawerWidth = (): number => {
continue;
}
const rect = drawer.getBoundingClientRect();
if (rect.width > 0 && rect.right >= window.innerWidth - 1) {
if (rect.width > 0 && rect.right >= window.innerWidth - 8) {
return rect.width;
}
}
@@ -255,6 +257,16 @@ watch(
{ flush: "post" }
);
watch(
() => settingsStore.settingsVisible,
() => {
nextTick(() => {
scheduleUpdateFabPositionBurst();
});
},
{ flush: "post" }
);
let domObserver: MutationObserver | null = null;
let rafId: number | null = null;
@@ -268,6 +280,18 @@ const scheduleUpdateFabPosition = () => {
});
};
const scheduleUpdateFabPositionBurst = (frames = 18) => {
let count = 0;
const tick = () => {
scheduleUpdateFabPosition();
count += 1;
if (count < frames) {
window.requestAnimationFrame(tick);
}
};
tick();
};
// 快捷命令示例
const examples = [
"修改test用户的姓名为测试人员",

View File

@@ -1,66 +1,294 @@
<template>
<div>
<el-button type="text" icon="Search" aria-label="打开搜索面板" @click="open" />
<div
class="command-palette-trigger"
role="button"
tabindex="0"
aria-label="打开搜索面板"
@click="open"
@keydown.enter.prevent="open"
@keydown.space.prevent="open"
>
<div class="command-palette-trigger__left">
<div class="i-svg:search" />
<span class="command-palette-trigger__text">搜索菜单</span>
</div>
<kbd class="command-palette-trigger__kbd">Ctrl K</kbd>
</div>
<el-dialog v-model="visible" width="640px" :close-on-click-modal="true" @close="close">
<template #title>
<div class="flex items-center gap-2">
<el-icon><Search /></el-icon>
<span>搜索菜单</span>
</div>
</template>
<div>
<el-dialog
v-model="visible"
width="720px"
:close-on-click-modal="true"
:show-close="false"
@close="close"
>
<div class="command-palette-dialog">
<el-input
ref="inputRef"
v-model="keyword"
placeholder="输入菜单名称或关键字搜索"
class="command-palette-input"
placeholder="搜索菜单"
@input="onSearch"
@keydown.enter.prevent="onSelect"
/>
@keydown="handleInputKeydown"
>
<template #prefix>
<div class="i-svg:search" />
</template>
<template #suffix>
<div class="command-palette-input__suffix">
<div
class="i-svg:close"
role="button"
tabindex="0"
aria-label="关闭"
@click="close"
/>
</div>
</template>
</el-input>
<div class="mt-3">
<div
v-if="results.length === 0 && history.length === 0"
class="text-center text-gray-500"
>
没有搜索历史
</div>
<div class="command-palette-results">
<div v-if="displayList.length === 0" class="command-palette-empty">没有搜索历史</div>
<ul v-else class="space-y-2">
<ul v-else class="command-palette-list">
<li
v-for="(item, idx) in results.length ? results : history"
v-for="(item, idx) in displayList"
:key="item.path + idx"
class="p-2 hover:bg-gray-100 cursor-pointer rounded"
:class="['command-palette-item', { 'is-active': activeIndex === idx }]"
@mouseenter="activeIndex = idx"
@click="onGo(item)"
>
<div>{{ item.title }}</div>
<div class="text-sm text-gray-400">{{ item.path }}</div>
<div class="command-palette-item__title">{{ item.title }}</div>
<div class="command-palette-item__path">{{ item.path }}</div>
</li>
</ul>
</div>
</div>
<template #footer>
<div style="text-align: right">
<el-button @click="close">关闭</el-button>
<div class="command-palette-hints">
<div class="command-palette-hint">
<div class="command-palette-hint__key"><div class="i-svg:up" /></div>
<div class="command-palette-hint__key"><div class="i-svg:down" /></div>
<span class="command-palette-hint__text">切换</span>
</div>
<div class="command-palette-hint">
<div class="command-palette-hint__key"><div class="i-svg:enter" /></div>
<span class="command-palette-hint__text">选择</span>
</div>
<div class="command-palette-hint">
<div class="command-palette-hint__key"><div class="i-svg:esc" /></div>
<span class="command-palette-hint__text">关闭</span>
</div>
</div>
</template>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { Search } from "@element-plus/icons-vue";
import { computed } from "vue";
import { useCommandPalette } from "./useCommandPalette";
const { visible, keyword, results, history, inputRef, open, close, onSearch, onSelect, onGo } =
useCommandPalette();
const {
visible,
keyword,
results,
history,
activeIndex,
inputRef,
open,
close,
onSearch,
onSelect,
onNavigate,
onGo,
} = useCommandPalette();
onMounted(() => {
// no-op for now
});
const displayList = computed(() => (results.value.length ? results.value : history.value));
const handleInputKeydown: (evt: KeyboardEvent | Event) => any = (evt) => {
if (!(evt instanceof KeyboardEvent)) return;
const e = evt;
const key = e.key.toLowerCase();
if (key === "escape") {
e.preventDefault();
close();
return;
}
if (key === "arrowup") {
e.preventDefault();
onNavigate("up");
return;
}
if (key === "arrowdown") {
e.preventDefault();
onNavigate("down");
return;
}
if (key === "enter") {
e.preventDefault();
if (displayList.value.length === 0) return;
if (activeIndex.value < 0) activeIndex.value = 0;
onSelect();
}
};
</script>
<style scoped></style>
<style scoped>
.command-palette-trigger {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 12px;
user-select: none;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 999px;
}
.command-palette-trigger__left {
display: flex;
gap: 8px;
align-items: center;
}
.command-palette-trigger__text {
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.command-palette-trigger__kbd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
font-size: 12px;
line-height: 1;
color: var(--el-text-color-secondary);
white-space: nowrap;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.command-palette-trigger:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
.command-palette-trigger:hover {
border-color: var(--el-border-color);
}
.command-palette-dialog {
display: flex;
flex-direction: column;
gap: 14px;
}
.command-palette-input :deep(.el-input__wrapper) {
border-radius: 10px;
}
.command-palette-input__suffix {
display: inline-flex;
gap: 10px;
align-items: center;
}
.command-palette-input__suffix :deep([class^="i-svg:"]) {
font-size: 16px;
color: var(--el-text-color-secondary);
}
.command-palette-input__suffix :deep([class^="i-svg:"]):hover {
color: var(--el-color-primary);
}
.command-palette-results {
max-height: 48vh;
overflow: auto;
}
.command-palette-empty {
padding: 24px 0;
color: var(--el-text-color-secondary);
text-align: center;
}
.command-palette-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
}
.command-palette-item {
padding: 10px 12px;
cursor: pointer;
border-radius: 10px;
}
.command-palette-item:hover {
background: var(--el-fill-color-light);
}
.command-palette-item.is-active {
background: var(--el-color-primary-light-9);
}
.command-palette-item__title {
font-size: 14px;
color: var(--el-text-color-primary);
}
.command-palette-item__path {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.command-palette-hints {
display: flex;
gap: 14px;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--el-border-color-lighter);
}
.command-palette-hint {
display: inline-flex;
gap: 6px;
align-items: center;
}
.command-palette-hint__key {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 8px;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.command-palette-hint__key :deep([class^="i-svg:"]) {
font-size: 14px;
color: var(--el-text-color-secondary);
}
.command-palette-hint__text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -65,19 +65,27 @@ export function useCommandPalette() {
results.value = menuItems.value.filter((item) => item.title.toLowerCase().includes(kw));
}
function getDisplayList() {
return results.value.length ? results.value : history.value;
}
function onSelect() {
if (results.value.length > 0 && activeIndex.value >= 0) {
onGo(results.value[activeIndex.value]);
}
const list = getDisplayList();
if (list.length === 0) return;
if (activeIndex.value < 0) return;
const item = list[activeIndex.value];
if (!item) return;
onGo(item);
}
function onNavigate(direction: "up" | "down") {
if (results.value.length === 0) return;
const list = getDisplayList();
if (list.length === 0) return;
if (direction === "up") {
activeIndex.value = activeIndex.value <= 0 ? results.value.length - 1 : activeIndex.value - 1;
activeIndex.value = activeIndex.value <= 0 ? list.length - 1 : activeIndex.value - 1;
} else {
activeIndex.value = activeIndex.value >= results.value.length - 1 ? 0 : activeIndex.value + 1;
activeIndex.value = activeIndex.value >= list.length - 1 ? 0 : activeIndex.value + 1;
}
}

View File

@@ -13,10 +13,6 @@ export const enum ApiCodeEnum {
* 成功
*/
SUCCESS = "00000",
/**
* 错误
*/
ERROR = "B0001",
/**
* 访问令牌无效或过期

View File

@@ -49,12 +49,14 @@
<div class="i-svg:captcha" />
</template>
</el-input>
<div cursor-pointer h-40px w-120px flex-center @click="getCaptcha">
<div cursor-pointer h-44px w-140px flex-center @click="getCaptcha">
<el-icon v-if="codeLoading" class="is-loading" size="20"><Loading /></el-icon>
<img
v-else-if="captchaBase64"
border-rd-4px
object-cover
w-full
h-full
object-contain
shadow="[0_0_0_1px_var(--el-border-color)_inset]"
:src="captchaBase64"
alt="captchaCode"

View File

@@ -58,14 +58,15 @@
<div class="i-svg:captcha" />
</template>
</el-input>
<div cursor-pointer h="[40px]" w="[120px]" flex-center ml-10px @click="getCaptcha">
<div cursor-pointer h="[44px]" w="[140px]" flex-center ml-10px @click="getCaptcha">
<el-icon v-if="codeLoading" class="is-loading"><Loading /></el-icon>
<img
v-else
object-cover
border-rd-4px
p-1px
w-full
h-full
object-contain
shadow="[0_0_0_1px_var(--el-border-color)_inset]"
:src="captchaBase64"
alt="code"