feat: 增强命令面板功能与AI助手集成
This commit is contained in:
@@ -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用户的姓名为测试人员",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ export const enum ApiCodeEnum {
|
||||
* 成功
|
||||
*/
|
||||
SUCCESS = "00000",
|
||||
/**
|
||||
* 错误
|
||||
*/
|
||||
ERROR = "B0001",
|
||||
|
||||
/**
|
||||
* 访问令牌无效或过期
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user