mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 21:06:32 +00:00
style: 移除Python文件中的编码声明并优化代码格式
refactor: 重构前端组件和样式,添加AI助手功能 docs: 更新README文档,添加ruff代码检查说明 feat: 新增AI助手相关API和前端组件 chore: 更新.gitignore文件,添加ruff缓存配置 fix: 修复前端布局和设置相关的问题 perf: 优化代码结构和性能,移除冗余代码 test: 更新测试文件,移除编码声明 build: 更新依赖版本,调整requirements.txt
This commit is contained in:
+16
-2
@@ -3,27 +3,41 @@
|
||||
<!-- 开启水印 -->
|
||||
<el-watermark
|
||||
:font="{ color: fontColor }"
|
||||
:content="showWatermark ? defaultSettings.watermarkContent : ''"
|
||||
:content="showWatermark ? watermarkContent : ''"
|
||||
:z-index="9999"
|
||||
class="wh-full"
|
||||
>
|
||||
<router-view />
|
||||
|
||||
<!-- AI 助手 -->
|
||||
<AiAssistant v-if="enableAiAssistant" />
|
||||
</el-watermark>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore, useSettingsStore } from "@/store";
|
||||
import { useAppStore, useSettingsStore, useUserStore } from "@/store";
|
||||
import { defaultSettings } from "@/settings";
|
||||
import { ThemeMode } from "@/enums/settings/theme.enum";
|
||||
import { ComponentSize } from "@/enums/settings/layout.enum";
|
||||
import AiAssistant from "@/components/AiAssistant/index.vue";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const locale = computed(() => appStore.locale);
|
||||
const size = computed(() => appStore.size as ComponentSize);
|
||||
const showWatermark = computed(() => settingsStore.showWatermark);
|
||||
const watermarkContent = defaultSettings.watermarkContent;
|
||||
|
||||
// 只有在启用 AI 助手且用户已登录时才显示
|
||||
// 使用 userInfo 作为响应式依赖,当用户退出登录时会自动更新
|
||||
const enableAiAssistant = computed(() => {
|
||||
const isEnabled = settingsStore.userEnableAi;
|
||||
const isLoggedIn = userStore.basicInfo && Object.keys(userStore.basicInfo).length > 0;
|
||||
return isEnabled && isLoggedIn;
|
||||
});
|
||||
|
||||
// 明亮/暗黑主题水印字体颜色适配
|
||||
const fontColor = computed(() => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
const API_PATH = "/application/ai";
|
||||
|
||||
export const McpAPI = {
|
||||
/**
|
||||
* 查询应用列表
|
||||
* @param query 查询参数
|
||||
*/
|
||||
chatMcp(query: AiChatQuery) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/chat`,
|
||||
method: "post",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default McpAPI;
|
||||
|
||||
/**
|
||||
* 应用表单
|
||||
*/
|
||||
export interface AiChatQuery {
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg t="1765953898889" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6018" width="200" height="200"><path d="M603.904 244.992c134.4 0 243.3536 108.9536 243.3536 243.3536v202.8032c0 134.4-108.9536 243.3536-243.3536 243.3536H320c-134.4 0-243.3536-108.9536-243.3536-243.3536V488.3456c0-134.4 108.9536-243.3536 243.3536-243.3536h283.904z m0 81.1008H320c-89.6 0-162.2528 72.6528-162.2528 162.2528v202.8032c0 89.6 72.6528 162.2528 162.2528 162.2528h283.904c89.6 0 162.2528-72.6528 162.2528-162.2528V488.3456c0-89.6-72.6528-162.2528-162.2528-162.2528z" p-id="6019"></path><path d="M340.224 508.6208c27.0336 0 40.5504 13.5168 40.5504 40.5504v81.1008c0 27.0336-13.5168 40.5504-40.5504 40.5504-27.0336 0-40.5504-13.5168-40.5504-40.5504v-81.1008c0-27.0336 13.5168-40.5504 40.5504-40.5504zM583.2192 501.3504c15.2576-11.4176 36.864-8.3456 48.2816 6.912a34.4832 34.4832 0 0 1-6.912 48.2816l-44.3904 33.28 44.3904 33.28a34.53952 34.53952 0 0 1 9.472 44.3392l-2.56 3.9424c-11.4176 15.2576-33.024 18.3296-48.2816 6.912l-59.4944-44.5952c-13.7728-10.3424-21.9136-26.5728-21.9136-43.8272s8.0896-33.4848 21.9136-43.8272l59.4944-44.5952zM883.5072 261.12l-19.7632 47.3088c-2.7648 6.656-9.2672 10.9568-16.4864 10.9568s-13.6704-4.3008-16.4864-10.9568L811.008 261.12a44.416 44.416 0 0 0-19.7632-21.9648l-34.8672-19.0976c-5.7344-3.1232-9.2672-9.1136-9.2672-15.6672s3.5328-12.544 9.2672-15.6672l34.8672-19.0464a44.89728 44.89728 0 0 0 19.7632-21.9648l19.7632-47.3088c2.7648-6.656 9.2672-10.9568 16.4864-10.9568s13.6704 4.3008 16.4864 10.9568l19.7632 47.3088a44.416 44.416 0 0 0 19.7632 21.9648l34.8672 19.0976c5.7344 3.1232 9.2672 9.1136 9.2672 15.6672s-3.584 12.544-9.2672 15.6672l-34.8672 19.0464c-8.9088 4.864-15.872 12.6464-19.7632 21.9648z" p-id="6020"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,849 @@
|
||||
<template>
|
||||
<!-- 悬浮按钮 -->
|
||||
<div class="ai-assistant">
|
||||
<!-- AI 助手图标按钮 -->
|
||||
<el-button
|
||||
v-if="!dialogVisible && !fabCollapsed"
|
||||
class="ai-fab-button"
|
||||
type="primary"
|
||||
circle
|
||||
size="large"
|
||||
:style="fabStyle"
|
||||
@contextmenu.prevent="fabCollapsed = true"
|
||||
@click="handleOpen"
|
||||
>
|
||||
<div class="i-svg:ai ai-icon" />
|
||||
</el-button>
|
||||
|
||||
<!-- 收缩态:贴边小标签,避免遮挡表单控件 -->
|
||||
<div
|
||||
v-if="!dialogVisible && fabCollapsed"
|
||||
class="ai-fab-tab"
|
||||
:style="fabStyle"
|
||||
@click="fabCollapsed = false"
|
||||
>
|
||||
AI
|
||||
</div>
|
||||
|
||||
<!-- AI 对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="AI 智能助手"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
draggable
|
||||
class="ai-assistant-dialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="i-svg:ai header-icon" />
|
||||
<span class="title">AI 智能助手</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 命令输入 -->
|
||||
<div class="command-input">
|
||||
<el-input
|
||||
v-model="command"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="试试说:修改test用户的姓名为测试人员 或者:跳转到用户管理 按 Ctrl+Enter 快速发送"
|
||||
:disabled="loading"
|
||||
@keydown.ctrl.enter="handleExecute"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 快捷命令示例 -->
|
||||
<div class="quick-commands">
|
||||
<div class="section-title">💡 试试这些命令:</div>
|
||||
<el-tag
|
||||
v-for="example in examples"
|
||||
:key="example"
|
||||
class="command-tag"
|
||||
@click="command = example"
|
||||
>
|
||||
{{ example }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- AI 响应结果 -->
|
||||
<div v-if="response" class="ai-response">
|
||||
<el-alert :title="response.explanation" type="success" :closable="false" show-icon />
|
||||
|
||||
<!-- 将要执行的操作 -->
|
||||
<div v-if="response.action" class="action-preview">
|
||||
<div class="action-title">🎯 将要执行:</div>
|
||||
<div class="action-content">
|
||||
<div v-if="response.action.type === 'navigate'">
|
||||
<el-icon><Position /></el-icon>
|
||||
跳转到:
|
||||
<strong>{{ response.action.pageName }}</strong>
|
||||
<span v-if="response.action.query" class="query-info">
|
||||
并搜索:
|
||||
<el-tag type="warning" size="small">{{ response.action.query }}</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="response.action.type === 'navigate-and-execute'">
|
||||
<el-icon><Position /></el-icon>
|
||||
跳转至:
|
||||
<strong>{{ response.action.pageName }}</strong>
|
||||
<span v-if="response.action.query" class="query-info">
|
||||
并搜索:
|
||||
<el-tag type="warning" size="small">{{ response.action.query }}</el-tag>
|
||||
</span>
|
||||
<el-divider direction="vertical" />
|
||||
<el-icon><Tools /></el-icon>
|
||||
执行:
|
||||
<strong>{{ response.action.functionCall.name }}</strong>
|
||||
</div>
|
||||
<div v-if="response.action.type === 'execute'">
|
||||
<el-icon><Tools /></el-icon>
|
||||
执行:
|
||||
<strong>{{ response.action.functionName }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleExecute">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
执行命令
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useSettingsStore } from "@/store";
|
||||
import McpAPI from "@/api/module_application/mcp";
|
||||
|
||||
type ToolFunctionCall = {
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
};
|
||||
|
||||
// 统一的动作描述(区分“跳转”、“跳转+执行”、“仅执行”三种场景)
|
||||
type AiAction =
|
||||
| {
|
||||
type: "navigate";
|
||||
path: string;
|
||||
pageName: string;
|
||||
query?: string;
|
||||
}
|
||||
| {
|
||||
type: "navigate-and-execute";
|
||||
path: string;
|
||||
pageName: string;
|
||||
query?: string;
|
||||
functionCall: ToolFunctionCall;
|
||||
}
|
||||
| {
|
||||
type: "execute";
|
||||
functionName: string;
|
||||
functionCall: ToolFunctionCall;
|
||||
};
|
||||
|
||||
type AiResponse = {
|
||||
explanation: string;
|
||||
action: AiAction | null;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 状态管理
|
||||
const dialogVisible = ref(false);
|
||||
const command = ref("");
|
||||
const loading = ref(false);
|
||||
const response = ref<AiResponse | null>(null);
|
||||
|
||||
const fabCollapsed = useStorage<boolean>("vea:ui:ai_assistant_fab_collapsed", false);
|
||||
|
||||
const fabRight = ref(30);
|
||||
const fabBottom = ref(80);
|
||||
const fabStyle = computed(() => ({
|
||||
right: `${fabRight.value}px`,
|
||||
bottom: `${fabBottom.value}px`,
|
||||
}));
|
||||
|
||||
const isElementVisible = (el: Element) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false;
|
||||
}
|
||||
return (el as HTMLElement).getClientRects().length > 0;
|
||||
};
|
||||
|
||||
const getActiveRightDrawerWidth = (): number => {
|
||||
const drawers = Array.from(document.querySelectorAll(".el-drawer"));
|
||||
for (let i = drawers.length - 1; i >= 0; i--) {
|
||||
const drawer = drawers[i] as HTMLElement;
|
||||
if (!isElementVisible(drawer)) {
|
||||
continue;
|
||||
}
|
||||
const rect = drawer.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.right >= window.innerWidth - 8) {
|
||||
return rect.width;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const updateFabPosition = () => {
|
||||
const safeMargin = 24;
|
||||
const drawerWidth = getActiveRightDrawerWidth() || 0;
|
||||
const baseRight = drawerWidth + 30;
|
||||
|
||||
// base position
|
||||
const nextRight = baseRight;
|
||||
let nextBottom = 80;
|
||||
|
||||
// Avoid Element Plus popper overlays (select dropdown, icon picker, date picker, etc.)
|
||||
// If the FAB would overlap any visible popper, push it upward.
|
||||
const fabSize = fabCollapsed.value ? 42 : 60;
|
||||
const computeFabRect = (rightPx: number, bottomPx: number) => {
|
||||
const right = window.innerWidth - rightPx;
|
||||
const left = right - fabSize;
|
||||
const bottom = window.innerHeight - bottomPx;
|
||||
const top = bottom - fabSize;
|
||||
return { left, right, top, bottom };
|
||||
};
|
||||
|
||||
const intersects = (
|
||||
a: { left: number; right: number; top: number; bottom: number },
|
||||
b: DOMRect
|
||||
) => {
|
||||
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
|
||||
};
|
||||
|
||||
const poppers = Array.from(document.querySelectorAll(".el-popper"));
|
||||
for (const popper of poppers) {
|
||||
if (!isElementVisible(popper)) {
|
||||
continue;
|
||||
}
|
||||
const rect = (popper as HTMLElement).getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateFabRect = computeFabRect(nextRight, nextBottom);
|
||||
if (intersects(candidateFabRect, rect)) {
|
||||
const requiredBottom = Math.ceil(window.innerHeight - rect.top + safeMargin);
|
||||
nextBottom = Math.max(nextBottom, requiredBottom);
|
||||
}
|
||||
}
|
||||
|
||||
// clamp so the button doesn't get pushed off-screen
|
||||
const maxBottom = window.innerHeight - fabSize - safeMargin;
|
||||
nextBottom = Math.min(nextBottom, Math.max(0, maxBottom));
|
||||
|
||||
fabRight.value = nextRight + (drawerWidth > 0 ? safeMargin : 0);
|
||||
fabBottom.value = nextBottom;
|
||||
};
|
||||
|
||||
watch(
|
||||
fabCollapsed,
|
||||
() => {
|
||||
updateFabPosition();
|
||||
},
|
||||
{ flush: "post" }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => settingsStore.settingsVisible,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
scheduleUpdateFabPositionBurst();
|
||||
});
|
||||
},
|
||||
{ flush: "post" }
|
||||
);
|
||||
|
||||
let domObserver: MutationObserver | null = null;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const scheduleUpdateFabPosition = () => {
|
||||
if (rafId != null) {
|
||||
return;
|
||||
}
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
updateFabPosition();
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleUpdateFabPositionBurst = (frames = 18) => {
|
||||
let count = 0;
|
||||
const tick = () => {
|
||||
scheduleUpdateFabPosition();
|
||||
count += 1;
|
||||
if (count < frames) {
|
||||
window.requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
};
|
||||
|
||||
// 快捷命令示例
|
||||
const examples = [
|
||||
"修改test用户的姓名为测试人员",
|
||||
"获取姓名为张三的用户信息",
|
||||
"跳转到用户管理",
|
||||
"打开角色管理页面",
|
||||
];
|
||||
|
||||
// 打开对话框
|
||||
const handleOpen = () => {
|
||||
dialogVisible.value = true;
|
||||
command.value = "";
|
||||
response.value = null;
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false;
|
||||
command.value = "";
|
||||
response.value = null;
|
||||
};
|
||||
|
||||
// 执行命令
|
||||
const handleExecute = async () => {
|
||||
const rawCommand = command.value.trim();
|
||||
if (!rawCommand) {
|
||||
ElMessage.warning("请输入命令");
|
||||
return;
|
||||
}
|
||||
|
||||
// 优先检测无需调用 AI 的纯跳转命令
|
||||
const directNavigation = tryDirectNavigate(rawCommand);
|
||||
if (directNavigation && directNavigation.action) {
|
||||
response.value = directNavigation;
|
||||
await executeAction(directNavigation.action);
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// 调用 AI API 解析命令
|
||||
const result = await McpAPI.chatMcp({
|
||||
message: rawCommand,
|
||||
});
|
||||
|
||||
if (!result.data.success) {
|
||||
ElMessage.error(result.data.msg || "命令解析失败");
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 AI 返回的操作类型
|
||||
const action = parseAction(result.data, rawCommand);
|
||||
response.value = {
|
||||
explanation: result.data.msg ?? "命令解析成功,准备执行操作",
|
||||
action,
|
||||
};
|
||||
|
||||
// 等待用户确认后执行
|
||||
if (action) {
|
||||
await executeAction(action);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("AI 命令执行失败:", error);
|
||||
ElMessage.error(error.message || "命令执行失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 路由配置映射表
|
||||
const routeConfig = [
|
||||
{ keywords: ["用户", "user", "user list"], path: "/system/user", name: "用户管理" },
|
||||
{ keywords: ["角色", "role"], path: "/system/role", name: "角色管理" },
|
||||
{ keywords: ["菜单", "menu"], path: "/system/menu", name: "菜单管理" },
|
||||
{ keywords: ["部门", "dept"], path: "/system/dept", name: "部门管理" },
|
||||
{ keywords: ["字典", "dict"], path: "/system/dict", name: "字典管理" },
|
||||
{ keywords: ["日志", "log"], path: "/system/log", name: "系统日志" },
|
||||
];
|
||||
|
||||
// 根据函数名推断路由(如 getUserInfo -> /system/user)
|
||||
const normalizeText = (text: string) => text.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
|
||||
const inferRouteFromFunction = (functionName: string) => {
|
||||
const fnLower = normalizeText(functionName);
|
||||
for (const config of routeConfig) {
|
||||
// 检查函数名是否包含关键词(如 getUserInfo 包含 user)
|
||||
if (config.keywords.some((kw) => fnLower.includes(kw.toLowerCase()))) {
|
||||
return { path: config.path, name: config.name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 根据命令文本匹配路由
|
||||
const matchRouteFromCommand = (cmd: string) => {
|
||||
const normalized = normalizeText(cmd);
|
||||
for (const config of routeConfig) {
|
||||
if (config.keywords.some((kw) => normalized.includes(kw.toLowerCase()))) {
|
||||
return { path: config.path, name: config.name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractKeywordFromCommand = (cmd: string): string => {
|
||||
const normalized = normalizeText(cmd);
|
||||
// 从 routeConfig 动态获取所有数据类型关键词
|
||||
const allKeywords = routeConfig.flatMap((config) =>
|
||||
config.keywords.map((kw) => kw.toLowerCase())
|
||||
);
|
||||
const keywordsPattern = allKeywords.join("|");
|
||||
|
||||
const patterns = [
|
||||
new RegExp(`(?:查询|获取|搜索|查找|找).*?([^\\s,。]+?)(?:的)?(?:${keywordsPattern})`, "i"),
|
||||
new RegExp(`(?:${keywordsPattern}).*?([^\\s,。]+?)(?:的|信息|详情)?`, "i"),
|
||||
new RegExp(
|
||||
`(?:姓名为|名字叫|叫做|名称为|名是|为)([^\\s,。]+?)(?:的)?(?:${keywordsPattern})?`,
|
||||
"i"
|
||||
),
|
||||
new RegExp(`([^\\s,。]+?)(?:的)?(?:${keywordsPattern})(?:信息|详情)?`, "i"),
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match && match[1]) {
|
||||
let extracted = match[1].trim();
|
||||
extracted = extracted.replace(/姓名为|名字叫|叫做|名称为|名是|为|的|信息|详情/g, "");
|
||||
if (
|
||||
extracted &&
|
||||
!allKeywords.some((type) => extracted.toLowerCase().includes(type.toLowerCase()))
|
||||
) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const tryDirectNavigate = (rawCommand: string): AiResponse | null => {
|
||||
const navigationIntents = ["跳转", "打开", "进入", "前往", "去", "浏览", "查看"];
|
||||
const operationIntents = [
|
||||
"修改",
|
||||
"更新",
|
||||
"变更",
|
||||
"删除",
|
||||
"添加",
|
||||
"创建",
|
||||
"设置",
|
||||
"获取",
|
||||
"查询",
|
||||
"搜索",
|
||||
];
|
||||
|
||||
const hasNavigationIntent = navigationIntents.some((keyword) => rawCommand.includes(keyword));
|
||||
const hasOperationIntent = operationIntents.some((keyword) => rawCommand.includes(keyword));
|
||||
|
||||
if (!hasNavigationIntent || hasOperationIntent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const routeInfo = matchRouteFromCommand(rawCommand);
|
||||
if (!routeInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyword = extractKeywordFromCommand(rawCommand);
|
||||
const action: AiAction = {
|
||||
type: "navigate",
|
||||
path: routeInfo.path,
|
||||
pageName: routeInfo.name,
|
||||
query: keyword || undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
explanation: `检测到跳转命令,正在前往 ${routeInfo.name}`,
|
||||
action,
|
||||
};
|
||||
};
|
||||
|
||||
// 解析 AI 返回的操作类型
|
||||
const parseAction = (result: any, rawCommand: string): AiAction | null => {
|
||||
const cmd = normalizeText(rawCommand);
|
||||
const primaryCall = result.functionCalls?.[0];
|
||||
const functionName = primaryCall?.name;
|
||||
|
||||
// 优先从函数名推断路由,其次从命令文本匹配
|
||||
let routeInfo = functionName ? inferRouteFromFunction(functionName) : null;
|
||||
if (!routeInfo) {
|
||||
routeInfo = matchRouteFromCommand(cmd);
|
||||
}
|
||||
|
||||
const routePath = routeInfo?.path || "";
|
||||
const pageName = routeInfo?.name || "";
|
||||
const keyword = extractKeywordFromCommand(cmd);
|
||||
|
||||
if (primaryCall && functionName) {
|
||||
const fnNameLower = functionName.toLowerCase();
|
||||
|
||||
// 1) 查询类函数(query/search/list/get)-> 跳转并执行筛选操作
|
||||
const isQueryFunction =
|
||||
fnNameLower.includes("query") ||
|
||||
fnNameLower.includes("search") ||
|
||||
fnNameLower.includes("list") ||
|
||||
fnNameLower.includes("get");
|
||||
|
||||
if (isQueryFunction) {
|
||||
// 统一使用 keywords 参数(约定大于配置)
|
||||
const args = (primaryCall.arguments || {}) as Record<string, unknown>;
|
||||
const keywords =
|
||||
typeof args.keywords === "string" && args.keywords.trim().length > 0
|
||||
? args.keywords
|
||||
: keyword;
|
||||
|
||||
if (routePath) {
|
||||
return {
|
||||
type: "navigate-and-execute",
|
||||
path: routePath,
|
||||
pageName,
|
||||
functionCall: primaryCall,
|
||||
query: keywords || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 其他操作类函数(修改/删除/创建/更新等)-> 跳转并执行
|
||||
const isModifyFunction =
|
||||
fnNameLower.includes("update") ||
|
||||
fnNameLower.includes("modify") ||
|
||||
fnNameLower.includes("edit") ||
|
||||
fnNameLower.includes("delete") ||
|
||||
fnNameLower.includes("remove") ||
|
||||
fnNameLower.includes("create") ||
|
||||
fnNameLower.includes("add") ||
|
||||
fnNameLower.includes("save");
|
||||
|
||||
if (isModifyFunction && routePath) {
|
||||
return {
|
||||
type: "navigate-and-execute",
|
||||
path: routePath,
|
||||
pageName,
|
||||
functionCall: primaryCall,
|
||||
};
|
||||
}
|
||||
|
||||
// 3) 其他未匹配的函数,如果有路由则跳转,否则执行
|
||||
if (routePath) {
|
||||
return {
|
||||
type: "navigate-and-execute",
|
||||
path: routePath,
|
||||
pageName,
|
||||
functionCall: primaryCall,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "execute",
|
||||
functionName,
|
||||
functionCall: primaryCall,
|
||||
};
|
||||
}
|
||||
|
||||
// 4) 无函数调用,仅跳转
|
||||
if (routePath) {
|
||||
return {
|
||||
type: "navigate",
|
||||
path: routePath,
|
||||
pageName,
|
||||
query: keyword || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 定时器引用(用于清理)
|
||||
let navigationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let executeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 执行操作
|
||||
const executeAction = async (action: AiAction) => {
|
||||
// 🎯 新增:跳转并执行操作
|
||||
if (action.type === "navigate-and-execute") {
|
||||
ElMessage.success(`正在跳转到 ${action.pageName} 并执行操作...`);
|
||||
|
||||
// 清理之前的定时器
|
||||
if (navigationTimer) {
|
||||
clearTimeout(navigationTimer);
|
||||
}
|
||||
|
||||
// 跳转并传递待执行的操作信息
|
||||
navigationTimer = setTimeout(() => {
|
||||
navigationTimer = null;
|
||||
const queryParams: any = {
|
||||
// 通过 URL 参数传递 AI 操作信息
|
||||
aiAction: encodeURIComponent(
|
||||
JSON.stringify({
|
||||
functionName: action.functionCall.name,
|
||||
arguments: action.functionCall.arguments,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
// 如果有查询关键字,也一并传递
|
||||
if (action.query) {
|
||||
queryParams.keywords = action.query;
|
||||
queryParams.autoSearch = "true";
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: action.path,
|
||||
query: queryParams,
|
||||
});
|
||||
|
||||
// 关闭对话框
|
||||
handleClose();
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === "navigate") {
|
||||
// 检查是否已经在目标页面
|
||||
const currentPath = router.currentRoute.value.path;
|
||||
|
||||
if (currentPath === action.path) {
|
||||
// 如果已经在目标页面
|
||||
if (action.query) {
|
||||
// 有查询关键字,直接在当前页面执行搜索
|
||||
ElMessage.info(`您已在 ${action.pageName} 页面,为您执行搜索:${action.query}`);
|
||||
|
||||
// 触发路由更新,让页面执行搜索
|
||||
router.replace({
|
||||
path: action.path,
|
||||
query: {
|
||||
keywords: action.query,
|
||||
autoSearch: "true",
|
||||
_t: Date.now().toString(), // 添加时间戳强制刷新
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 没有查询关键字,只是跳转,给出提示
|
||||
ElMessage.warning(`您已经在 ${action.pageName} 页面了`);
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// 不在目标页面,正常跳转
|
||||
ElMessage.success(`正在跳转到 ${action.pageName}...`);
|
||||
|
||||
// 清理之前的定时器
|
||||
if (navigationTimer) {
|
||||
clearTimeout(navigationTimer);
|
||||
}
|
||||
|
||||
// 延迟一下让用户看到提示
|
||||
navigationTimer = setTimeout(() => {
|
||||
navigationTimer = null;
|
||||
// 跳转并传递查询参数
|
||||
router.push({
|
||||
path: action.path,
|
||||
query: action.query
|
||||
? {
|
||||
keywords: action.query, // 传递关键字参数
|
||||
autoSearch: "true", // 标记自动搜索
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// 关闭对话框
|
||||
handleClose();
|
||||
}, 1000);
|
||||
} else if (action.type === "execute") {
|
||||
// 执行函数调用
|
||||
ElMessage.info("功能开发中,请前往 AI 命令助手页面体验完整功能");
|
||||
|
||||
// 清理之前的定时器
|
||||
if (executeTimer) {
|
||||
clearTimeout(executeTimer);
|
||||
}
|
||||
|
||||
// 可以跳转到完整的 AI 命令页面
|
||||
executeTimer = setTimeout(() => {
|
||||
executeTimer = null;
|
||||
router.push("/function/ai-command");
|
||||
handleClose();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onMounted(() => {
|
||||
updateFabPosition();
|
||||
window.addEventListener("resize", updateFabPosition);
|
||||
|
||||
domObserver = new MutationObserver(() => {
|
||||
scheduleUpdateFabPosition();
|
||||
});
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", updateFabPosition);
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
domObserver = null;
|
||||
}
|
||||
if (rafId != null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
|
||||
if (navigationTimer) {
|
||||
clearTimeout(navigationTimer);
|
||||
navigationTimer = null;
|
||||
}
|
||||
if (executeTimer) {
|
||||
clearTimeout(executeTimer);
|
||||
executeTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-assistant {
|
||||
.ai-fab-button {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
box-shadow: 0 4px 12px rgba(2, 119, 252, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 20px rgba(2, 119, 252, 0.6);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-fab-tab {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 12px rgba(2, 119, 252, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-assistant-dialog {
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
.header-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.command-input {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.quick-commands {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.command-tag {
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-response {
|
||||
margin-top: 16px;
|
||||
|
||||
.action-preview {
|
||||
padding: 12px;
|
||||
margin-top: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
|
||||
.action-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.action-content {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
.el-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.query-info {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,7 +33,7 @@ function getBreadcrumb() {
|
||||
let matched = currentRoute.matched.filter((item) => item.meta && item.meta.title);
|
||||
const first = matched[0];
|
||||
if (!isDashboard(first)) {
|
||||
matched = [{ path: "/dashboard", meta: { title: "dashboard" } } as any].concat(matched);
|
||||
matched = [{ path: "/home", meta: { title: "dashboard" } } as any].concat(matched);
|
||||
}
|
||||
breadcrumbs.value = matched.filter((item) => {
|
||||
return item.meta && item.meta.title && item.meta.breadcrumb !== false;
|
||||
|
||||
+2
-1
@@ -23,7 +23,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore } from "@/store";
|
||||
import { ThemeMode } from "@/enums";
|
||||
import { Moon, Sunny } from "@element-plus/icons-vue";
|
||||
import { Moon, Sunny, Monitor } from "@element-plus/icons-vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -31,6 +31,7 @@ const settingsStore = useSettingsStore();
|
||||
const theneList = [
|
||||
{ label: t("login.light"), value: ThemeMode.LIGHT, component: Sunny },
|
||||
{ label: t("login.dark"), value: ThemeMode.DARK, component: Moon },
|
||||
{ label: t("login.auto"), value: ThemeMode.AUTO, component: Monitor },
|
||||
];
|
||||
|
||||
const handleDarkChange = (theme: ThemeMode) => {
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import McpAPI from "@/api/module_application/mcp";
|
||||
|
||||
/**
|
||||
* AI 操作处理器(简化版)
|
||||
*
|
||||
* 可以是简单函数,也可以是配置对象
|
||||
*/
|
||||
export type AiActionHandler<T = any> =
|
||||
| ((args: T) => Promise<void> | void)
|
||||
| {
|
||||
/** 执行函数 */
|
||||
execute: (args: T) => Promise<void> | void;
|
||||
/** 是否需要确认(默认 true) */
|
||||
needConfirm?: boolean;
|
||||
/** 确认消息(支持函数或字符串) */
|
||||
confirmMessage?: string | ((args: T) => string);
|
||||
/** 成功消息(支持函数或字符串) */
|
||||
successMessage?: string | ((args: T) => string);
|
||||
/** 是否调用后端 API(默认 false,如果为 true 则自动调用 executeCommand) */
|
||||
callBackendApi?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* AI 操作配置
|
||||
*/
|
||||
export interface UseAiActionOptions {
|
||||
/** 操作映射表:函数名 -> 处理器 */
|
||||
actionHandlers?: Record<string, AiActionHandler>;
|
||||
/** 数据刷新函数(操作完成后调用) */
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
/** 自动搜索处理函数 */
|
||||
onAutoSearch?: (keywords: string) => void;
|
||||
/** 当前路由路径(用于执行命令时传递) */
|
||||
currentRoute?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 操作 Composable
|
||||
*
|
||||
* 统一处理 AI 助手传递的操作,支持:
|
||||
* - 自动搜索(通过 keywords + autoSearch 参数)
|
||||
* - 执行 AI 操作(通过 aiAction 参数)
|
||||
* - 配置化的操作处理器
|
||||
*/
|
||||
export function useAiAction(options: UseAiActionOptions = {}) {
|
||||
const route = useRoute();
|
||||
const { actionHandlers = {}, onRefresh, onAutoSearch, currentRoute = route.path } = options;
|
||||
|
||||
// 用于跟踪是否已卸载,防止在卸载后执行回调
|
||||
let isUnmounted = false;
|
||||
|
||||
/**
|
||||
* 执行 AI 操作(统一处理确认、执行、反馈流程)
|
||||
*/
|
||||
async function executeAiAction(action: any) {
|
||||
if (isUnmounted) return;
|
||||
|
||||
// 兼容两种入参:{ functionName, arguments } 或 { functionCall: { name, arguments } }
|
||||
const fnCall = action.functionCall ?? {
|
||||
name: action.functionName,
|
||||
arguments: action.arguments,
|
||||
};
|
||||
|
||||
if (!fnCall?.name) {
|
||||
ElMessage.warning("未识别的 AI 操作");
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找对应的处理器
|
||||
const handler = actionHandlers[fnCall.name];
|
||||
if (!handler) {
|
||||
ElMessage.warning(`暂不支持操作: ${fnCall.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 判断处理器类型(函数 or 配置对象)
|
||||
const isSimpleFunction = typeof handler === "function";
|
||||
|
||||
if (isSimpleFunction) {
|
||||
// 简单函数形式:直接执行
|
||||
await handler(fnCall.arguments);
|
||||
} else {
|
||||
// 配置对象形式:统一处理确认、执行、反馈
|
||||
const config = handler;
|
||||
|
||||
// 1. 确认阶段(默认需要确认)
|
||||
if (config.needConfirm !== false) {
|
||||
const confirmMsg =
|
||||
typeof config.confirmMessage === "function"
|
||||
? config.confirmMessage(fnCall.arguments)
|
||||
: config.confirmMessage || "确认执行此操作吗?";
|
||||
|
||||
await ElMessageBox.confirm(confirmMsg, "AI 助手操作确认", {
|
||||
confirmButtonText: "确认执行",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 执行阶段
|
||||
if (config.callBackendApi) {
|
||||
// 自动调用后端 API
|
||||
await McpAPI.chatMcp({
|
||||
message: fnCall.arguments,
|
||||
});
|
||||
} else {
|
||||
// 执行自定义函数
|
||||
await config.execute(fnCall.arguments);
|
||||
}
|
||||
|
||||
// 3. 成功反馈
|
||||
const successMsg =
|
||||
typeof config.successMessage === "function"
|
||||
? config.successMessage(fnCall.arguments)
|
||||
: config.successMessage || "操作执行成功";
|
||||
ElMessage.success(successMsg);
|
||||
}
|
||||
|
||||
// 4. 刷新数据
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 处理取消操作
|
||||
if (error === "cancel") {
|
||||
ElMessage.info("已取消操作");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("AI 操作执行失败:", error);
|
||||
ElMessage.error(error.message || "操作执行失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行后端命令(通用方法)
|
||||
*/
|
||||
async function executeCommand(
|
||||
functionName: string,
|
||||
args: any,
|
||||
options: {
|
||||
originalCommand?: string;
|
||||
confirmMode?: "auto" | "manual";
|
||||
needConfirm?: boolean;
|
||||
confirmMessage?: string;
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
originalCommand = "",
|
||||
confirmMode = "manual",
|
||||
needConfirm = false,
|
||||
confirmMessage,
|
||||
} = options;
|
||||
|
||||
// 如果需要确认,先显示确认对话框
|
||||
if (needConfirm && confirmMessage) {
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmMessage, "AI 助手操作确认", {
|
||||
confirmButtonText: "确认执行",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
} catch {
|
||||
ElMessage.info("已取消操作");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await McpAPI.chatMcp({
|
||||
message: originalCommand,
|
||||
});
|
||||
|
||||
ElMessage.success("操作执行成功");
|
||||
} catch (error: any) {
|
||||
if (error !== "cancel") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动搜索
|
||||
*/
|
||||
function handleAutoSearch(keywords: string) {
|
||||
if (onAutoSearch) {
|
||||
onAutoSearch(keywords);
|
||||
} else {
|
||||
ElMessage.info(`AI 助手已为您自动搜索:${keywords}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:处理 URL 参数中的 AI 操作
|
||||
*
|
||||
* 注意:此方法只处理 AI 相关参数,不负责页面数据的初始加载
|
||||
* 页面数据加载应由组件的 onMounted 钩子自行处理
|
||||
*/
|
||||
async function init() {
|
||||
if (isUnmounted) return;
|
||||
|
||||
// 检查是否有 AI 助手传递的参数
|
||||
const keywords = route.query.keywords as string;
|
||||
const autoSearch = route.query.autoSearch as string;
|
||||
const aiActionParam = route.query.aiAction as string;
|
||||
|
||||
// 如果没有任何 AI 参数,直接返回
|
||||
if (!keywords && !autoSearch && !aiActionParam) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 在 nextTick 中执行,确保页面数据已加载
|
||||
nextTick(async () => {
|
||||
if (isUnmounted) return;
|
||||
|
||||
// 1. 处理自动搜索
|
||||
if (autoSearch === "true" && keywords) {
|
||||
handleAutoSearch(keywords);
|
||||
}
|
||||
|
||||
// 2. 处理 AI 操作
|
||||
if (aiActionParam) {
|
||||
try {
|
||||
const aiAction = JSON.parse(decodeURIComponent(aiActionParam));
|
||||
await executeAiAction(aiAction);
|
||||
} catch (error) {
|
||||
console.error("解析 AI 操作失败:", error);
|
||||
ElMessage.error("AI 操作参数解析失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 组件挂载时自动初始化
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
// 组件卸载时清理
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounted = true;
|
||||
});
|
||||
|
||||
return {
|
||||
executeAiAction,
|
||||
executeCommand,
|
||||
handleAutoSearch,
|
||||
};
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
// 该文件作用是:导出所有 composable 函数,作为全局函数使用
|
||||
// AI 相关
|
||||
export { useAiAction } from "./ai/useAiAction";
|
||||
export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* 存储键常量统一管理
|
||||
* 包括 localStorage、sessionStorage 等各种存储的键名
|
||||
* 存储键名常量
|
||||
*
|
||||
* @description
|
||||
* 统一管理所有 localStorage/sessionStorage 的键名
|
||||
* 命名规则:{APP_PREFIX}:{分类}:{具体名称}
|
||||
*/
|
||||
|
||||
// 🔐 用户认证相关
|
||||
@@ -26,6 +29,8 @@ export const LAYOUT_KEY = "layout";
|
||||
export const SIDEBAR_COLOR_SCHEME_KEY = "sidebarColorScheme";
|
||||
export const THEME_KEY = "theme";
|
||||
export const THEME_COLOR_KEY = "themeColor";
|
||||
export const GRAY_MODE_KEY = "grayMode";
|
||||
export const AI_ENABLED_KEY = "aiEnabled";
|
||||
|
||||
export const ROLE_ROOT = "ADMIN"; // 超级管理员角色
|
||||
|
||||
@@ -59,6 +64,8 @@ export const SETTINGS_KEYS = {
|
||||
LAYOUT: LAYOUT_KEY,
|
||||
THEME_COLOR: THEME_COLOR_KEY,
|
||||
THEME: THEME_KEY,
|
||||
GRAY_MODE: GRAY_MODE_KEY,
|
||||
AI_ENABLED: AI_ENABLED_KEY,
|
||||
} as const;
|
||||
|
||||
// 📦 所有存储键的统一集合
|
||||
|
||||
@@ -75,6 +75,7 @@ export default {
|
||||
languageToggle: "Language Switch",
|
||||
dark: "Dark",
|
||||
light: "Light",
|
||||
auto: "Auto",
|
||||
username: "Please enter Username",
|
||||
password: "Please enter Password",
|
||||
newPassword: "Please enter new Password",
|
||||
@@ -179,7 +180,7 @@ export default {
|
||||
showAppLogo: "Show App Logo",
|
||||
sidebarColorScheme: "Sidebar Color Scheme",
|
||||
showWatermark: "Show Watermark",
|
||||
showDesktopTools: "Desktop Tools Settings",
|
||||
showDesktopTools: "Tools Settings",
|
||||
showMenuSearch: "Show Menu Search",
|
||||
showFullscreen: "Show Fullscreen Toggle",
|
||||
showSizeSelect: "Show Layout Size",
|
||||
@@ -205,6 +206,7 @@ export default {
|
||||
resetConfigDescription: "Restore all settings to system default values",
|
||||
systemTheme: "System Theme",
|
||||
showGuide: "Show Guide",
|
||||
showSettings: "Show Settings",
|
||||
},
|
||||
error: {
|
||||
noPermission: `Sorry, you don't have permission to access this page.`,
|
||||
|
||||
@@ -75,6 +75,7 @@ export default {
|
||||
languageToggle: "语言切换",
|
||||
dark: "暗黑",
|
||||
light: "明亮",
|
||||
auto: "自动",
|
||||
username: "请输入账号",
|
||||
password: "请输入密码",
|
||||
newPassword: "请输入新密码",
|
||||
@@ -183,7 +184,7 @@ export default {
|
||||
showTagsView: "显示页签",
|
||||
showAppLogo: "显示Logo",
|
||||
showWatermark: "显示水印",
|
||||
showDesktopTools: "桌面端工具设置",
|
||||
showDesktopTools: "工具设置",
|
||||
showMenuSearch: "显示菜单搜索",
|
||||
showFullscreen: "显示全屏切换",
|
||||
showSizeSelect: "显示布局大小",
|
||||
@@ -207,6 +208,7 @@ export default {
|
||||
resetConfigDescription: "恢复所有设置为系统默认值",
|
||||
systemTheme: "系统主题",
|
||||
showGuide: "启动引导",
|
||||
showSettings: "显示设置",
|
||||
},
|
||||
error: {
|
||||
noPermission: `抱歉,您无权访问此页面。`,
|
||||
|
||||
@@ -21,12 +21,21 @@
|
||||
<div v-if="settingStore.showLangSelect" class="navbar-actions__item">
|
||||
<LangSelect />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 通知 -->
|
||||
<div v-if="settingStore.showNotification" class="navbar-actions__item">
|
||||
<Notification />
|
||||
</div>
|
||||
<!-- 通知 -->
|
||||
<div v-if="settingStore.showNotification" class="navbar-actions__item">
|
||||
<Notification />
|
||||
</div>
|
||||
|
||||
<!-- 系统设置按钮 -->
|
||||
<div
|
||||
v-if="settingStore.showSettings"
|
||||
class="navbar-actions__item"
|
||||
@click="handleSettingsClick"
|
||||
>
|
||||
<div class="i-svg:setting" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 用户菜单,不管桌面还是移动端都显示 -->
|
||||
<div class="navbar-actions__item">
|
||||
@@ -146,6 +155,13 @@ function handleDocumentClick() {
|
||||
window.open("https://service.fastapiadmin.com", "_blank");
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开系统设置页面
|
||||
*/
|
||||
function handleSettingsClick() {
|
||||
settingStore.settingsVisible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitee 项目地址
|
||||
*/
|
||||
@@ -259,7 +275,6 @@ function logout() {
|
||||
justify-content: center;
|
||||
min-width: 44px; /* 增加最小点击区域到44px,符合人机交互标准 */
|
||||
height: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
@@ -282,6 +297,15 @@ function logout() {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.i-svg\:language) {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
// 图标样式
|
||||
:deep([class^="i-svg:"]) {
|
||||
font-size: 18px;
|
||||
@@ -303,6 +327,7 @@ function logout() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
|
||||
&__avatar-wrapper {
|
||||
@@ -313,6 +338,9 @@ function logout() {
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,17 @@
|
||||
<el-switch v-model="settingsStore.showWatermark" />
|
||||
</div>
|
||||
|
||||
<!-- 桌面端工具项控制 -->
|
||||
<div class="flex-x-between">
|
||||
<span class="text-xs">灰色模式</span>
|
||||
<el-switch v-model="settingsStore.grayMode" />
|
||||
</div>
|
||||
|
||||
<div class="flex-x-between">
|
||||
<span class="text-xs">AI 助手</span>
|
||||
<el-switch v-model="settingsStore.userEnableAi" />
|
||||
</div>
|
||||
|
||||
<!-- 工具控制 -->
|
||||
<el-divider>{{ t("settings.showDesktopTools") }}</el-divider>
|
||||
|
||||
<div class="flex-x-between">
|
||||
@@ -218,11 +228,9 @@
|
||||
import { DocumentCopy, RefreshLeft, Check } from "@element-plus/icons-vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
import { LayoutMode, SidebarColor, ThemeMode } from "@/enums";
|
||||
import { useSettingsStore } from "@/store";
|
||||
import { LayoutMode, SidebarColor, ThemeMode, DeviceEnum } from "@/enums";
|
||||
import { useSettingsStore, useAppStore } from "@/store";
|
||||
import { themeColorPresets } from "@/settings";
|
||||
import { useAppStore } from "@/store/modules/app.store";
|
||||
import { DeviceEnum } from "@/enums/settings/device.enum";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "400px" : "90%"));
|
||||
@@ -375,6 +383,8 @@ const generateSettingsCode = (): string => {
|
||||
showWatermark: settingsStore.showWatermark,
|
||||
watermarkContent: "pkg.name",
|
||||
sidebarColorScheme: `SidebarColor.${settingsStore.sidebarColorScheme.toUpperCase().replace("-", "_")}`,
|
||||
grayMode: settingsStore.grayMode,
|
||||
userEnableAi: settingsStore.userEnableAi,
|
||||
};
|
||||
|
||||
return `const defaultSettings: AppSettings = {
|
||||
@@ -396,6 +406,8 @@ const generateSettingsCode = (): string => {
|
||||
showWatermark: ${settings.showWatermark},
|
||||
watermarkContent: ${settings.watermarkContent},
|
||||
sidebarColorScheme: ${settings.sidebarColorScheme},
|
||||
grayMode: ${settings.grayMode},
|
||||
userEnableAi: ${settings.userEnableAi},
|
||||
};`;
|
||||
};
|
||||
|
||||
@@ -412,6 +424,8 @@ const handleCloseDrawer = () => {
|
||||
.settings-drawer {
|
||||
:deep(.el-drawer__body) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -420,9 +434,8 @@ const handleCloseDrawer = () => {
|
||||
|
||||
/* 设置内容区域 */
|
||||
.settings-content {
|
||||
height: calc(100vh - 120px); /* 减去头部和底部按钮的高度 */
|
||||
flex: 1 1 auto;
|
||||
padding: 20px;
|
||||
padding-bottom: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -448,11 +461,11 @@ const handleCloseDrawer = () => {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
/* 底部操作区域样式 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.action-btn {
|
||||
& > .el-button {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
@@ -554,7 +567,11 @@ const handleCloseDrawer = () => {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
width: 12px;
|
||||
background: var(--el-color-primary-light-3);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--el-color-primary-dark-2) 0%,
|
||||
var(--el-color-primary) 100%
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -637,7 +654,11 @@ const handleCloseDrawer = () => {
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--el-color-primary-light-9) 0%,
|
||||
var(--el-color-primary-light-8) 100%
|
||||
);
|
||||
border-color: var(--el-color-primary);
|
||||
transform: translateY(-2px) scale(1.08);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAppStore, useSettingsStore } from "@/store";
|
||||
import { defaultSettings } from "@/settings";
|
||||
import { DeviceEnum } from "@/enums";
|
||||
|
||||
/**
|
||||
* 布局相关的通用逻辑
|
||||
@@ -24,13 +25,13 @@ export function useLayout() {
|
||||
const isShowLogo = computed(() => settingsStore.showAppLogo);
|
||||
|
||||
// 是否移动设备
|
||||
const isMobile = computed(() => appStore.device === "mobile");
|
||||
const isMobile = computed(() => appStore.device === DeviceEnum.MOBILE);
|
||||
|
||||
// 布局CSS类
|
||||
const layoutClass = computed(() => ({
|
||||
hideSidebar: !appStore.sidebar.opened,
|
||||
openSidebar: appStore.sidebar.opened,
|
||||
mobile: appStore.device === "mobile",
|
||||
mobile: appStore.device === DeviceEnum.MOBILE,
|
||||
[`layout-${settingsStore.layout}`]: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ import TopLayout from "./views/TopLayout.vue";
|
||||
import MixLayout from "./views/MixLayout.vue";
|
||||
import Settings from "./components/Settings/index.vue";
|
||||
import { LayoutMode } from "@/enums/settings/layout.enum";
|
||||
import { defaultSettings } from "@/settings";
|
||||
|
||||
const { currentLayout } = useLayout();
|
||||
const { currentLayout, isShowSettings } = useLayout();
|
||||
|
||||
// 根据当前布局模式选择对应的组件
|
||||
const currentLayoutComponent = computed(() => {
|
||||
@@ -31,9 +30,6 @@ const currentLayoutComponent = computed(() => {
|
||||
return LeftLayout;
|
||||
}
|
||||
});
|
||||
|
||||
// 是否显示设置面板
|
||||
const isShowSettings = computed(() => defaultSettings.showSettings);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -10,34 +10,12 @@
|
||||
<el-backtop target=".app-main">
|
||||
<div class="i-svg:backtop w-6 h-6" />
|
||||
</el-backtop>
|
||||
|
||||
<!-- 新增:悬浮的系统设置按钮 -->
|
||||
<el-button
|
||||
v-if="settingStore.showSettings"
|
||||
class="floating-settings-button"
|
||||
type="primary"
|
||||
@click="handleSettingsClick"
|
||||
>
|
||||
<el-icon>
|
||||
<Setting />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout } from "../composables/useLayout";
|
||||
import { useLayoutResponsive } from "../composables/useLayoutResponsive";
|
||||
import { useSettingsStore } from "@/store";
|
||||
|
||||
const settingStore = useSettingsStore();
|
||||
|
||||
/**
|
||||
* 打开系统设置页面
|
||||
*/
|
||||
function handleSettingsClick() {
|
||||
settingStore.settingsVisible = true;
|
||||
}
|
||||
|
||||
// 布局相关
|
||||
const { layoutClass, isSidebarOpen, closeSidebar } = useLayout();
|
||||
@@ -60,17 +38,5 @@ const { isMobile } = useLayoutResponsive();
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// 新增:悬浮按钮样式
|
||||
.floating-settings-button {
|
||||
position: fixed; // 固定在页面上
|
||||
top: 50%; // 距离顶部20px
|
||||
right: 0px; // 距离右侧8px
|
||||
z-index: 999; // 层叠顺序
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-top-right-radius: 0; // 右侧顶部无圆角
|
||||
border-bottom-right-radius: 0; // 右侧底部无圆角
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { LayoutMode, ComponentSize, SidebarColor, ThemeMode, LanguageEnum } from "./enums";
|
||||
|
||||
const env = import.meta.env;
|
||||
const { pkg } = __APP_INFO__;
|
||||
|
||||
// 检查用户的操作系统是否使用深色模式
|
||||
const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
name: pkg.name as string,
|
||||
// 系统Title
|
||||
title: pkg.name,
|
||||
title: (env.VITE_APP_TITLE as string) || pkg.name,
|
||||
// 系统版本
|
||||
version: pkg.version,
|
||||
version: pkg.version as string,
|
||||
// 是否显示设置按钮
|
||||
showSettings: true,
|
||||
// 桌面端工具项单独控制
|
||||
@@ -26,7 +28,7 @@ export const defaultSettings: AppSettings = {
|
||||
// 布局方式,默认为左侧布局
|
||||
layout: LayoutMode.LEFT,
|
||||
// 主题,根据操作系统的色彩方案自动选择
|
||||
theme: mediaQueryList.matches ? ThemeMode.DARK : ThemeMode.LIGHT,
|
||||
theme: prefersDark ? ThemeMode.DARK : ThemeMode.LIGHT,
|
||||
// 组件大小 default | medium | small | large
|
||||
size: ComponentSize.DEFAULT,
|
||||
// 语言
|
||||
@@ -34,7 +36,7 @@ export const defaultSettings: AppSettings = {
|
||||
// 主题颜色 - 修改此值时需同步修改 src/styles/variables.scss
|
||||
themeColor: "#4080FF",
|
||||
// 是否显示水印 (修改默认开启水印)
|
||||
showWatermark: true,
|
||||
showWatermark: false,
|
||||
// 水印内容
|
||||
watermarkContent: pkg.name,
|
||||
// 侧边栏配色方案
|
||||
@@ -43,6 +45,10 @@ export const defaultSettings: AppSettings = {
|
||||
guideVisible: false,
|
||||
/** 是否启动引导 */
|
||||
showGuide: true,
|
||||
/** 是否开启AI助手 */
|
||||
aiEnabled: false,
|
||||
/** 是否开启灰色模式 */
|
||||
grayMode: false,
|
||||
};
|
||||
|
||||
// 主题色预设 - 现代化配色方案
|
||||
|
||||
@@ -12,7 +12,7 @@ interface SettingsState {
|
||||
showAppLogo: boolean;
|
||||
showWatermark: boolean;
|
||||
showSettings: boolean;
|
||||
showGuide: boolean; // 引导功能开关
|
||||
showGuide: boolean;
|
||||
|
||||
// 桌面端工具显示设置
|
||||
showMenuSearch: boolean;
|
||||
@@ -24,6 +24,8 @@ interface SettingsState {
|
||||
// 布局设置
|
||||
layout: LayoutMode;
|
||||
sidebarColorScheme: string;
|
||||
grayMode: boolean;
|
||||
userEnableAi: boolean;
|
||||
|
||||
// 主题设置
|
||||
theme: ThemeMode;
|
||||
@@ -102,6 +104,11 @@ export const useSettingsStore = defineStore("setting", () => {
|
||||
// 主题模式
|
||||
const theme = useStorage<ThemeMode>(SETTINGS_KEYS.THEME, defaultSettings.theme);
|
||||
|
||||
// 是否开启灰色模式
|
||||
const grayMode = useStorage<boolean>(SETTINGS_KEYS.GRAY_MODE, defaultSettings.grayMode);
|
||||
// 是否开启AI助手
|
||||
const userEnableAi = useStorage<boolean>(SETTINGS_KEYS.AI_ENABLED, defaultSettings.aiEnabled);
|
||||
|
||||
// 🎯 设置项映射
|
||||
const settingsMap = {
|
||||
showTagsView,
|
||||
@@ -116,6 +123,8 @@ export const useSettingsStore = defineStore("setting", () => {
|
||||
showNotification,
|
||||
sidebarColorScheme,
|
||||
layout,
|
||||
grayMode,
|
||||
userEnableAi,
|
||||
} as const;
|
||||
|
||||
// 🎯 监听器 - 主题变化
|
||||
@@ -138,6 +147,15 @@ export const useSettingsStore = defineStore("setting", () => {
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 灰色模式监听
|
||||
watch(
|
||||
grayMode,
|
||||
(v) => {
|
||||
document.documentElement.style.filter = v ? "grayscale(100%)" : "";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 🎯 统一的设置更新方法 - 类型安全
|
||||
function updateSetting<K extends keyof typeof settingsMap>(key: K, value: SettingValue<K>): void {
|
||||
const setting = settingsMap[key];
|
||||
@@ -181,6 +199,16 @@ export const useSettingsStore = defineStore("setting", () => {
|
||||
settingsVisible.value = false;
|
||||
}
|
||||
|
||||
// 更新AI助手状态
|
||||
function updateUserEnableAi(newValue: boolean): void {
|
||||
userEnableAi.value = newValue;
|
||||
}
|
||||
|
||||
// 更新灰色模式状态
|
||||
function updateGrayMode(newValue: boolean): void {
|
||||
grayMode.value = newValue;
|
||||
}
|
||||
|
||||
// 🎯 批量重置设置
|
||||
function resetSettings(): void {
|
||||
// 界面显示设置
|
||||
@@ -202,6 +230,10 @@ export const useSettingsStore = defineStore("setting", () => {
|
||||
layout.value = defaultSettings.layout as LayoutMode;
|
||||
themeColor.value = defaultSettings.themeColor;
|
||||
theme.value = defaultSettings.theme;
|
||||
|
||||
// 系统设置
|
||||
grayMode.value = defaultSettings.grayMode;
|
||||
userEnableAi.value = defaultSettings.aiEnabled;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -240,6 +272,14 @@ export const useSettingsStore = defineStore("setting", () => {
|
||||
showSettingsPanel,
|
||||
hideSettingsPanel,
|
||||
|
||||
// 🎯 系统设置状态
|
||||
grayMode,
|
||||
userEnableAi,
|
||||
|
||||
// 🎯 更新系统设置方法
|
||||
updateUserEnableAi,
|
||||
updateGrayMode,
|
||||
|
||||
// 🎯 重置功能
|
||||
resetSettings,
|
||||
};
|
||||
|
||||
Vendored
+6
@@ -59,6 +59,8 @@ declare global {
|
||||
* 系统设置
|
||||
*/
|
||||
interface AppSettings {
|
||||
/** 系统名称 */
|
||||
name: string;
|
||||
/** 系统标题 */
|
||||
title: string;
|
||||
/** 系统版本 */
|
||||
@@ -99,6 +101,10 @@ declare global {
|
||||
guideVisible: boolean;
|
||||
/** 是否启动引导 */
|
||||
showGuide: boolean;
|
||||
/** 是否开启AI助手 */
|
||||
aiEnabled: boolean;
|
||||
/** 是否开启灰色模式 */
|
||||
grayMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* NProgress 进度条配置
|
||||
*/
|
||||
import NProgress from "nprogress";
|
||||
import "nprogress/nprogress.css";
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 搜索区域 -->
|
||||
<div class="filter-section">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||
<el-form-item prop="keywords" label="关键字">
|
||||
<el-input
|
||||
v-model="queryParams.keywords"
|
||||
placeholder="原始命令/函数名称/用户名"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="aiProvider" label="AI提供方">
|
||||
<el-select
|
||||
v-model="queryParams.aiProvider"
|
||||
placeholder="请选择"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
>
|
||||
<el-option label="通义千问" value="qwen" />
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="DeepSeek" value="deepseek" />
|
||||
<el-option label="Gemini" value="gemini" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="aiModel" label="AI模型">
|
||||
<el-input
|
||||
v-model="queryParams.aiModel"
|
||||
placeholder="如: qwen-plus"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="parseStatus" label="解析状态">
|
||||
<el-select
|
||||
v-model="queryParams.parseStatus"
|
||||
placeholder="请选择"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
>
|
||||
<el-option label="成功" :value="1" />
|
||||
<el-option label="失败" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="executeStatus" label="执行状态">
|
||||
<el-select
|
||||
v-model="queryParams.executeStatus"
|
||||
placeholder="请选择"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
>
|
||||
<el-option label="待执行" :value="0" />
|
||||
<el-option label="成功" :value="1" />
|
||||
<el-option label="失败" :value="-1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="createTime" label="创建时间">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
:editable="false"
|
||||
type="daterange"
|
||||
range-separator="~"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="截止时间"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="search-buttons">
|
||||
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="Refresh" @click="handleResetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="hover" class="table-section">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="pageData"
|
||||
highlight-current-row
|
||||
border
|
||||
class="table-section__content"
|
||||
>
|
||||
<el-table-column label="创建时间" prop="createTime" width="180" />
|
||||
<el-table-column label="用户名" prop="username" width="120" />
|
||||
<el-table-column
|
||||
label="原始命令"
|
||||
prop="originalCommand"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
label="函数名称"
|
||||
prop="functionName"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="AI提供方" prop="aiProvider" width="120" />
|
||||
<el-table-column label="AI模型" prop="aiModel" width="160" show-overflow-tooltip />
|
||||
<el-table-column label="解析状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.parseStatus === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.parseStatus === 1 ? "成功" : "失败" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.executeStatus !== null && row.executeStatus !== undefined"
|
||||
:type="getExecuteStatusTagType(row.executeStatus)"
|
||||
size="small"
|
||||
>
|
||||
{{ getExecuteStatusText(row.executeStatus) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="置信度" prop="confidence" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.confidence !== undefined && row.confidence !== null">
|
||||
{{ (row.confidence * 100).toFixed(0) }}%
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="解析耗时(ms)" prop="parseDurationMs" width="120" align="center" />
|
||||
<el-table-column label="IP地址" prop="ipAddress" width="140" />
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleViewDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-if="total > 0"
|
||||
v-model:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="fetchData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="AI 命令记录详情" width="880px" append-to-body>
|
||||
<el-descriptions v-if="currentRow" :column="2" border>
|
||||
<el-descriptions-item label="记录ID">
|
||||
{{ currentRow.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">
|
||||
{{ currentRow.username }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="AI提供方">
|
||||
{{ currentRow.aiProvider || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="AI模型">
|
||||
{{ currentRow.aiModel || "-" }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="解析状态">
|
||||
<el-tag :type="currentRow.parseStatus === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ currentRow.parseStatus === 1 ? "成功" : "失败" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="置信度">
|
||||
<span v-if="currentRow.confidence !== undefined && currentRow.confidence !== null">
|
||||
{{ (currentRow.confidence * 100).toFixed(0) }}%
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="解析耗时">
|
||||
{{ formatNumber(currentRow.parseDurationMs) }} ms
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Token统计">
|
||||
输入 {{ currentRow.inputTokens || 0 }} / 输出 {{ currentRow.outputTokens || 0 }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="原始命令" :span="2">
|
||||
<el-input :model-value="currentRow.originalCommand" type="textarea" :rows="2" readonly />
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="currentRow.explanation" label="AI说明" :span="2">
|
||||
{{ currentRow.explanation }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="currentRow.functionCalls" label="函数调用" :span="2">
|
||||
<el-input
|
||||
:model-value="formatJson(currentRow.functionCalls)"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
readonly
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="currentRow.parseErrorMessage" label="解析错误" :span="2">
|
||||
<el-alert :title="currentRow.parseErrorMessage" type="error" :closable="false" />
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="函数名称">
|
||||
{{ currentRow.functionName || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行状态">
|
||||
<el-tag
|
||||
v-if="currentRow.executeStatus !== null && currentRow.executeStatus !== undefined"
|
||||
:type="getExecuteStatusTagType(currentRow.executeStatus)"
|
||||
size="small"
|
||||
>
|
||||
{{ getExecuteStatusText(currentRow.executeStatus) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="currentRow.functionArguments" label="执行参数" :span="2">
|
||||
<el-input
|
||||
:model-value="formatJson(currentRow.functionArguments)"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
readonly
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="currentRow.executeErrorMessage" label="执行错误" :span="2">
|
||||
<el-alert :title="currentRow.executeErrorMessage" type="error" :closable="false" />
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="IP地址">
|
||||
{{ currentRow.ipAddress || "-" }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ currentRow.createTime }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">
|
||||
{{ currentRow.updateTime || "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: "AiAssistantRecord",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import AiCommandApi from "@/api/ai";
|
||||
import type { AiAssistantRecordItem, AiAssistantRecordQueryParams } from "@/types/api";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
const queryFormRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
|
||||
const queryParams = reactive<AiAssistantRecordQueryParams>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keywords: "",
|
||||
aiProvider: "",
|
||||
aiModel: "",
|
||||
parseStatus: undefined,
|
||||
executeStatus: undefined,
|
||||
createTime: ["", ""],
|
||||
});
|
||||
|
||||
const pageData = ref<AiAssistantRecordItem[]>([]);
|
||||
|
||||
const detailDialogVisible = ref(false);
|
||||
const currentRow = ref<AiAssistantRecordItem>();
|
||||
|
||||
function getExecuteStatusText(status: number): string {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return "待执行";
|
||||
case 1:
|
||||
return "成功";
|
||||
case -1:
|
||||
return "失败";
|
||||
default:
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
function getExecuteStatusTagType(status: number): "info" | "success" | "danger" {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return "info";
|
||||
case 1:
|
||||
return "success";
|
||||
case -1:
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
function fetchData() {
|
||||
loading.value = true;
|
||||
AiCommandApi.getPage(queryParams)
|
||||
.then((res) => {
|
||||
pageData.value = res.data || [];
|
||||
total.value = res.page?.total ?? 0;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
queryParams.pageNum = 1;
|
||||
fetchData();
|
||||
}
|
||||
|
||||
function handleResetQuery() {
|
||||
queryFormRef.value?.resetFields();
|
||||
queryParams.pageNum = 1;
|
||||
fetchData();
|
||||
}
|
||||
|
||||
function handleViewDetail(row: AiAssistantRecordItem) {
|
||||
currentRow.value = row;
|
||||
detailDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function formatJson(jsonStr?: string) {
|
||||
if (!jsonStr) return "-";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(jsonStr), null, 2);
|
||||
} catch {
|
||||
return jsonStr;
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value?: number | string | null) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "-";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-buttons {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="action-bar">
|
||||
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
|
||||
<CommonWrapper>
|
||||
<DarkModeSwitch />
|
||||
<ThemeSwitch />
|
||||
</CommonWrapper>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('login.languageToggle')" placement="bottom">
|
||||
@@ -79,7 +79,7 @@
|
||||
// import logo from "@/assets/logo.png";
|
||||
// import { defaultSettings } from "@/settings";
|
||||
import CommonWrapper from "@/components/CommonWrapper/index.vue";
|
||||
import DarkModeSwitch from "@/components/DarkModeSwitch/index.vue";
|
||||
import ThemeSwitch from "@/components/ThemeSwitch/index.vue";
|
||||
import { useConfigStore } from "@/store";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="auth-view__toolbar">
|
||||
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
|
||||
<CommonWrapper>
|
||||
<DarkModeSwitch />
|
||||
<ThemeSwitch />
|
||||
</CommonWrapper>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('login.languageToggle')" placement="bottom">
|
||||
@@ -27,15 +27,19 @@
|
||||
</p>
|
||||
<ul class="auth-feature__highlights">
|
||||
<li>
|
||||
<span>⦿</span>
|
||||
<span>✓</span>
|
||||
统一身份认证与权限管理
|
||||
</li>
|
||||
<li>
|
||||
<span>⦿</span>
|
||||
<span>✓</span>
|
||||
支持定时任务与任务调度
|
||||
</li>
|
||||
<li>
|
||||
<span>✓</span>
|
||||
数据安全与操作审计
|
||||
</li>
|
||||
<li>
|
||||
<span>⦿</span>
|
||||
<span>✓</span>
|
||||
灵活扩展与高可用架构
|
||||
</li>
|
||||
</ul>
|
||||
@@ -87,14 +91,17 @@
|
||||
<footer class="auth-panel__footer">
|
||||
<el-text size="small">
|
||||
<a :href="configStore.configData?.sys_git_code?.config_value || ''" target="_blank">
|
||||
{{ configStore.configData?.sys_web_copyright?.config_value || "" }} |
|
||||
{{ configStore.configData?.sys_web_copyright?.config_value || "" }}
|
||||
</a>
|
||||
|
|
||||
<a :href="configStore.configData?.sys_help_doc?.config_value || ''" target="_blank">
|
||||
帮助 |
|
||||
帮助
|
||||
</a>
|
||||
|
|
||||
<a :href="configStore.configData?.sys_web_privacy?.config_value || ''" target="_blank">
|
||||
隐私 |
|
||||
隐私
|
||||
</a>
|
||||
|
|
||||
<a :href="configStore.configData?.sys_web_clause?.config_value || ''" target="_blank">
|
||||
条款
|
||||
</a>
|
||||
@@ -110,7 +117,7 @@
|
||||
// import logo from "@/assets/logo.png";
|
||||
// import { defaultSettings } from "@/settings";
|
||||
import CommonWrapper from "@/components/CommonWrapper/index.vue";
|
||||
import DarkModeSwitch from "@/components/DarkModeSwitch/index.vue";
|
||||
import ThemeSwitch from "@/components/ThemeSwitch/index.vue";
|
||||
import { useConfigStore } from "@/store";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
@@ -181,6 +188,7 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
overflow: hidden;
|
||||
background-color: #f5f7ff;
|
||||
|
||||
&::before {
|
||||
position: fixed;
|
||||
@@ -214,6 +222,7 @@ onBeforeUnmount(() => {
|
||||
box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 16px 40px rgba(22, 93, 255, 0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -255,8 +264,7 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: clamp(1.5rem, 3vw, 3rem);
|
||||
color: rgba(20, 40, 80, 0.95);
|
||||
text-shadow: 0 4px 16px rgba(15, 60, 110, 0.12);
|
||||
color: var(--el-text-color-primary);
|
||||
animation: featureFade 0.8s ease-out;
|
||||
}
|
||||
|
||||
@@ -273,6 +281,9 @@ onBeforeUnmount(() => {
|
||||
.auth-panel {
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
box-shadow:
|
||||
0 12px 32px rgba(22, 93, 255, 0.18),
|
||||
0 2px 8px rgba(22, 93, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +294,7 @@ onBeforeUnmount(() => {
|
||||
width: fit-content;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(22, 93, 255, 0.95);
|
||||
color: var(--el-color-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: rgba(22, 93, 255, 0.1);
|
||||
@@ -293,7 +304,7 @@ onBeforeUnmount(() => {
|
||||
.auth-feature__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background: #165dff;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 12px rgba(22, 93, 255, 0.7);
|
||||
}
|
||||
@@ -309,7 +320,7 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(35, 40, 65, 0.85);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.auth-feature__highlights {
|
||||
@@ -325,9 +336,9 @@ onBeforeUnmount(() => {
|
||||
align-items: flex-start;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 500;
|
||||
color: rgba(26, 32, 48, 0.9);
|
||||
background: rgba(230, 236, 255, 0.85);
|
||||
border: 1px solid rgba(86, 140, 255, 0.28);
|
||||
color: var(--el-text-color-primary);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(64, 128, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(6px);
|
||||
|
||||
@@ -380,9 +391,13 @@ onBeforeUnmount(() => {
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(22, 93, 255, 0.06);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border-color: rgba(64, 128, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-panel__logo-wrap {
|
||||
@@ -486,11 +501,34 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.auth-panel__footer {
|
||||
padding-top: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.875rem;
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(22, 93, 255, 0.06);
|
||||
|
||||
a {
|
||||
margin-left: 0.1rem;
|
||||
color: var(--el-text-color-regular);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: rgba(22, 93, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border-color: rgba(64, 128, 255, 0.12);
|
||||
|
||||
a {
|
||||
color: rgba(140, 170, 255, 0.88);
|
||||
|
||||
&:hover {
|
||||
color: rgba(160, 190, 255, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes featureFade {
|
||||
|
||||
Reference in New Issue
Block a user