perf: defer route prefetch and enrich workbench

This commit is contained in:
2026-06-21 22:03:33 +08:00
parent 46401e1a4e
commit 81ce2c4f0a
3 changed files with 515 additions and 189 deletions
+4 -1
View File
@@ -111,6 +111,10 @@ function setupCommonGuard(router: Router) {
if (preferences.transition.progress) { if (preferences.transition.progress) {
stopProgress(); stopProgress();
} }
if (useAccessStore().accessToken) {
prefetchAiPlatformPages(to.path);
}
}); });
} }
@@ -198,7 +202,6 @@ function setupAccessGuard(router: Router) {
accessStore.setAccessMenus(accessibleMenus); accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes); accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true); accessStore.setIsAccessChecked(true);
prefetchAiPlatformPages();
// 子应用根路径重定向(/app/hr -> /app/hr/xxx // 子应用根路径重定向(/app/hr -> /app/hr/xxx
if (appContextStore.isSubApp) { if (appContextStore.isSubApp) {
+84 -31
View File
@@ -18,25 +18,51 @@ type NavigatorWithConnection = Navigator & {
}; };
}; };
let prefetched = false; const prefetchedGroups = new Set<string>();
const corePrefetchTasks = [ type PrefetchTask = {
() => import('#/views/_core/user/index.vue'), key: string;
() => import('#/views/_core/menu/index.vue'), load: () => Promise<unknown>;
() => import('#/views/_core/role/index.vue'), };
const corePrefetchTasks: PrefetchTask[] = [
{ key: 'system-user', load: () => import('#/views/_core/user/index.vue') },
{ key: 'system-menu', load: () => import('#/views/_core/menu/index.vue') },
{ key: 'system-role', load: () => import('#/views/_core/role/index.vue') },
]; ];
const aiInteractivePrefetchTasks = [ const aiInteractivePrefetchTasks: PrefetchTask[] = [
() => import('#/views/_core/agent-chat/index.vue'), {
() => import('#/components/ai-chat-panel/AiChatPanel.vue'), key: 'agent-chat',
load: () => import('#/views/_core/agent-chat/index.vue'),
},
{
key: 'ai-chat-panel',
load: () => import('#/components/ai-chat-panel/AiChatPanel.vue'),
},
]; ];
const aiManagementPrefetchTasks = [ const aiManagementPrefetchTasks: PrefetchTask[] = [
() => import('#/views/ai-platform/agent/index.vue'), {
() => import('#/views/ai-platform/workflow/index.vue'), key: 'ai-agent',
() => import('#/views/ai-platform/model/index.vue'), load: () => import('#/views/ai-platform/agent/index.vue'),
() => import('#/views/ai-platform/knowledge/index.vue'), },
() => import('#/views/ai-platform/workflow-runs/index.vue'), {
key: 'ai-workflow',
load: () => import('#/views/ai-platform/workflow/index.vue'),
},
{
key: 'ai-model',
load: () => import('#/views/ai-platform/model/index.vue'),
},
{
key: 'ai-knowledge',
load: () => import('#/views/ai-platform/knowledge/index.vue'),
},
{
key: 'ai-workflow-runs',
load: () => import('#/views/ai-platform/workflow-runs/index.vue'),
},
]; ];
function scheduleIdle(callback: () => void) { function scheduleIdle(callback: () => void) {
@@ -63,38 +89,65 @@ function canPrefetch() {
return !/(^|-)2g$/.test(connection?.effectiveType || ''); return !/(^|-)2g$/.test(connection?.effectiveType || '');
} }
async function runTasksSequentially(tasks: Array<() => Promise<unknown>>) { async function runTasksSequentially(tasks: PrefetchTask[]) {
for (const task of tasks) { for (const task of tasks) {
if (!canPrefetch()) { if (!canPrefetch()) {
return; return;
} }
await task().catch(() => undefined); if (prefetchedGroups.has(task.key)) {
continue;
}
prefetchedGroups.add(task.key);
await task.load().catch(() => undefined);
} }
} }
function prefetchAiPlatformPages() { function delayedPrefetch(tasks: PrefetchTask[], delay: number) {
if (prefetched || typeof window === 'undefined') { if (!tasks.length || typeof window === 'undefined') {
return; return;
} }
prefetched = true;
window.setTimeout(() => { window.setTimeout(() => {
scheduleIdle(() => { scheduleIdle(() => {
void runTasksSequentially(corePrefetchTasks); void runTasksSequentially(tasks);
}); });
}, 800); }, delay);
}
window.setTimeout(() => { function isSystemRoute(path: string) {
scheduleIdle(() => { return path.startsWith('/system/') || path.startsWith('/message/');
void runTasksSequentially(aiInteractivePrefetchTasks); }
});
}, 4000);
window.setTimeout(() => { function isAiPlatformRoute(path: string) {
scheduleIdle(() => { return path.startsWith('/ai-platform/');
void runTasksSequentially(aiManagementPrefetchTasks); }
});
}, 9000); function isAgentChatRoute(path: string) {
return path.startsWith('/agent-chat/');
}
function prefetchAiPlatformPages(currentPath = '') {
if (typeof window === 'undefined') {
return;
}
if (isSystemRoute(currentPath)) {
delayedPrefetch(corePrefetchTasks, 6000);
return;
}
if (isAgentChatRoute(currentPath)) {
delayedPrefetch(aiInteractivePrefetchTasks, 1500);
return;
}
if (isAiPlatformRoute(currentPath)) {
delayedPrefetch(aiManagementPrefetchTasks, 2000);
return;
}
delayedPrefetch(corePrefetchTasks, 7000);
delayedPrefetch(aiInteractivePrefetchTasks, 12_000);
} }
export { prefetchAiPlatformPages }; export { prefetchAiPlatformPages };
@@ -1,23 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { UserAnnouncement } from '#/api/core/announcement';
import type { Message } from '#/api/core/message';
import type { import type {
WorkbenchProjectItem, RealtimeStats,
WorkbenchQuickNavItem, ServerMonitorResponse,
WorkbenchTodoItem, } from '#/api/core/server-monitor';
WorkbenchTrendItem,
} from '@vben/common-ui';
import { ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { import {
WorkbenchHeader, Bell,
WorkbenchProject,
WorkbenchQuickNav,
WorkbenchTodo,
WorkbenchTrends,
} from '@vben/common-ui';
import {
Bot, Bot,
Brain,
Database, Database,
GitBranch, GitBranch,
History, History,
@@ -26,240 +21,515 @@ import {
LayoutDashboard, LayoutDashboard,
Megaphone, Megaphone,
Menu, Menu,
MessageSquareText,
Play,
Send,
Server,
Settings, Settings,
ShieldCheck, ShieldCheck,
Users, Users,
} from '@vben/icons'; } from '@vben/icons';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores'; import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import {
getUserAnnouncementListApi,
getUnreadAnnouncementCountApi,
} from '#/api/core/announcement';
import {
getMessageListApi,
getUnreadCountApi,
} from '#/api/core/message';
import {
getRealtimeStatsApi,
getServerOverviewApi,
} from '#/api/core/server-monitor';
import {
getAgentListApi,
getWorkflowListApi,
} from '#/api/ai-platform/ai-platform';
defineOptions({ name: 'DashboardWorkspace' }); defineOptions({ name: 'DashboardWorkspace' });
type IconType = string | typeof Bot;
interface NavItem {
color: string;
desc: string;
icon: IconType;
title: string;
url: string;
}
interface MetricItem {
label: string;
value: string;
}
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const now = ref(new Date());
const announcements = ref<UserAnnouncement[]>([]);
const messages = ref<Message[]>([]);
const unreadAnnouncements = ref(0);
const unreadMessages = ref(0);
const agentCount = ref(0);
const workflowCount = ref(0);
const serverOverview = ref<null | ServerMonitorResponse>(null);
const realtimeStats = ref<null | RealtimeStats>(null);
const loading = ref({
announcements: false,
messages: false,
server: false,
});
const headerStats = [ let timer: ReturnType<typeof setInterval> | undefined;
{ label: '智能体', value: '18' },
{ label: '工作流', value: '12' },
{ label: '运行事件', value: '12.8w' },
];
const projectItems: WorkbenchProjectItem[] = [ const displayName = computed(
() => userStore.userInfo?.realName || userStore.userInfo?.username || '管理员',
);
const greeting = computed(() => {
const hour = now.value.getHours();
if (hour < 6) return '夜深了';
if (hour < 11) return '早上好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
return '晚上好';
});
const currentTime = computed(() =>
now.value.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
);
const currentDate = computed(() =>
now.value.toLocaleDateString('zh-CN', {
day: '2-digit',
month: 'long',
weekday: 'long',
year: 'numeric',
}),
);
const metrics = computed<MetricItem[]>(() => [
{ label: '智能体', value: String(agentCount.value) },
{ label: '工作流', value: String(workflowCount.value) },
{ label: '未读消息', value: String(unreadMessages.value) },
{ label: '未读公告', value: String(unreadAnnouncements.value) },
]);
const aiActions: NavItem[] = [
{ {
color: '#2563eb', color: '#2563eb',
content: '管理已发布智能体,维护角色、提示词、工具能力和对话入口', desc: '已发布智能体、提示词和对话入口',
date: 'AI 平台',
group: '智能体',
icon: Bot, icon: Bot,
title: '智能体管理', title: '智能体',
url: '/ai-platform/agent', url: '/ai-platform/agent',
}, },
{ {
color: '#16a34a', color: '#16a34a',
content: '编排自主、对话流、子流程和并行节点,构建协作式任务链路。', desc: '自主、对话流、子流程和并行节点',
date: 'AI 平台',
group: '流程',
icon: GitBranch, icon: GitBranch,
title: '流程编排', title: '流程编排',
url: '/ai-platform/workflow', url: '/ai-platform/workflow',
}, },
{
color: '#7c3aed',
desc: '通用聊天和代码协作入口',
icon: MessageSquareText,
title: 'Codex',
url: '/agent-chat/codex',
},
{ {
color: '#f97316', color: '#f97316',
content: '查看节点状态、LLM 调用、等待输入、错误和最终输出', desc: '节点状态、错误和最终输出',
date: '可观测',
group: '运行',
icon: History, icon: History,
title: '执行历史', title: '执行历史',
url: '/ai-platform/workflow-runs', url: '/ai-platform/workflow-runs',
}, },
{
color: '#7c3aed',
content: '维护默认 chat 模型和供应商,保障智能体运行时可解析模型。',
date: '配置',
group: '模型',
icon: Settings,
title: '模型配置',
url: '/ai-platform/model',
},
{ {
color: '#0f766e', color: '#0f766e',
content: '沉淀业务知识、提示词资料和协作过程中的可复用上下文', desc: '业务知识和协作上下文',
date: '知识',
group: '资料',
icon: Database, icon: Database,
title: '知识库', title: '知识库',
url: '/ai-platform/knowledge-base', url: '/ai-platform/knowledge-base',
}, },
{ {
color: '#475569', color: '#475569',
content: '查看智能体、流程、模型调用和基础管理的整体运行态势。', desc: '供应商、默认 chat 模型',
date: '看板', icon: Settings,
group: '分析', title: '模型配置',
icon: LayoutDashboard, url: '/ai-platform/model',
title: '运行分析',
url: '/dashboard/analytics',
}, },
]; ];
const quickNavItems: WorkbenchQuickNavItem[] = [ const adminActions: NavItem[] = [
{ {
color: '#2563eb', color: '#2563eb',
desc: '账号、状态和组织信息',
icon: Users, icon: Users,
title: '用户管理', title: '用户管理',
url: '/system/user', url: '/system/user',
}, },
{ {
color: '#7c3aed', color: '#7c3aed',
desc: '角色、菜单和 API 授权',
icon: ShieldCheck, icon: ShieldCheck,
title: '角色权限', title: '角色权限',
url: '/system/role', url: '/system/role',
}, },
{
color: '#0f766e',
icon: KeyRound,
title: 'API 权限',
url: '/system/new-permission',
},
{ {
color: '#f97316', color: '#f97316',
desc: '动态菜单和路由配置',
icon: Menu, icon: Menu,
title: '菜单管理', title: '菜单管理',
url: '/system/menu', url: '/system/menu',
}, },
{
color: '#0f766e',
desc: '接口访问权限维护',
icon: KeyRound,
title: 'API 权限',
url: '/system/new-permission',
},
{ {
color: '#16a34a', color: '#16a34a',
desc: '公告发布和阅读统计',
icon: Megaphone, icon: Megaphone,
title: '公告管理', title: '公告管理',
url: '/message/announcement', url: '/message/announcement',
}, },
{ {
color: '#475569', color: '#475569',
desc: '系统消息和流程通知',
icon: Inbox, icon: Inbox,
title: '公告列表', title: '消息列表',
url: '/message/announcement-list', url: '/message/list',
}, },
]; ];
const todoItems = ref<WorkbenchTodoItem[]>([ const opsActions: NavItem[] = [
{ {
completed: false, color: '#2563eb',
content: '用真实业务需求触发业务需求分析师,确认能输出可测试需求、边界和下一步。', desc: '触发需求分析师',
date: '今日', icon: Send,
title: '验证业务需求分析', title: '需求分析',
url: '/ai-platform/agent',
}, },
{ {
completed: false, color: '#16a34a',
content: '检查 start、node_start、llm_chunk、error、complete 等事件在前端是否可读。', desc: '运行 Multica 协作流',
date: '今日', icon: Play,
title: '检查 SSE 运行事件', title: '协作流程',
url: '/ai-platform/workflow',
}, },
{ {
completed: true, color: '#f97316',
content: '发布智能体允许 model_id 为空,运行时回退到启用的默认 chat 模型。', desc: '查看运行事件',
date: '已完成', icon: LayoutDashboard,
title: '模型兜底策略',
},
{
completed: false,
content: '继续观察 AI 平台、智能体聊天和运行记录页面的首屏加载与预取效果。',
date: '本周',
title: '页面切换性能',
},
{
completed: true,
content: '保留 systemd 非 Docker 部署路径,Docker 入口仅作为可选方式保留。',
date: '已完成',
title: '部署路径收敛',
},
]);
const trendItems: WorkbenchTrendItem[] = [
{
avatar: 'svg:avatar-1',
content: '智能体运行时已增加默认 chat 模型解析,避免 <a>model_id 为空</a> 直接失败。',
date: '刚刚',
title: '运行时',
},
{
avatar: 'svg:avatar-2',
content: '工作流运行记录聚焦节点状态、错误原因、子流程和并行分支结果。',
date: '1 小时前',
title: '可观测性', title: '可观测性',
url: '/ai-platform/workflow-runs',
}, },
{ {
avatar: 'svg:avatar-3', color: '#7c3aed',
content: '用户、角色、权限、菜单和公告作为轻量后台的基础模块保留。', desc: '检查默认模型',
date: '今天', icon: Brain,
title: '基础后台', title: '模型策略',
}, url: '/ai-platform/model',
{
avatar: 'svg:avatar-4',
content: 'AI 平台保留智能体、流程编排、模型配置、知识库和执行历史。',
date: '今天',
title: 'AI 平台',
},
{
avatar: 'svg:avatar-1',
content: '线上部署路径固定为 <a>/ai-agent-admin</a>,后端由 systemd 托管。',
date: '本周',
title: '部署',
}, },
]; ];
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) { function navTo(url: string) {
if (nav.url?.startsWith('http')) { router.push(url).catch((error) => {
openWindow(nav.url); console.error('Navigation failed:', error);
return; });
} }
if (nav.url?.startsWith('/')) { function formatPercent(value?: number) {
router.push(nav.url).catch((error) => { return `${Number(value || 0).toFixed(1)}%`;
console.error('Navigation failed:', error); }
});
function formatMemory(gb?: number) {
const size = Number(gb || 0);
if (size <= 0) return '0 GB';
if (size < 1) return `${(size * 1024).toFixed(0)} MB`;
return `${size.toFixed(1)} GB`;
}
async function loadAiSummary() {
try {
const [agents, workflows] = await Promise.all([
getAgentListApi({ page: 1, pageSize: 1 }),
getWorkflowListApi({ page: 1, pageSize: 1 }),
]);
agentCount.value = agents.total || agents.items?.length || 0;
workflowCount.value = workflows.total || workflows.items?.length || 0;
} catch (error) {
console.error('加载 AI 统计失败:', error);
} }
} }
async function loadAnnouncements() {
loading.value.announcements = true;
try {
const [list, count] = await Promise.all([
getUserAnnouncementListApi({ page: 1, pageSize: 4 }),
getUnreadAnnouncementCountApi(),
]);
announcements.value = list.items || [];
unreadAnnouncements.value = count.count || 0;
} catch (error) {
console.error('加载公告失败:', error);
} finally {
loading.value.announcements = false;
}
}
async function loadMessages() {
loading.value.messages = true;
try {
const [list, count] = await Promise.all([
getMessageListApi({ page: 1, pageSize: 4 }),
getUnreadCountApi(),
]);
messages.value = list.items || [];
unreadMessages.value = count.total || 0;
} catch (error) {
console.error('加载消息失败:', error);
} finally {
loading.value.messages = false;
}
}
async function loadServerOverview() {
loading.value.server = true;
try {
const [overview, realtime] = await Promise.all([
getServerOverviewApi(),
getRealtimeStatsApi(),
]);
serverOverview.value = overview;
realtimeStats.value = realtime;
} catch (error) {
console.error('加载服务器信息失败:', error);
} finally {
loading.value.server = false;
}
}
onMounted(() => {
timer = setInterval(() => {
now.value = new Date();
}, 1000);
void loadAiSummary();
void loadAnnouncements();
void loadMessages();
void loadServerOverview();
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script> </script>
<template> <template>
<div class="p-3"> <div class="p-3">
<WorkbenchHeader <section class="grid gap-3 xl:grid-cols-[1fr_286px]">
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar" <div class="rounded-lg border bg-card p-5 shadow-sm">
:stats="headerStats" <div class="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
> <div class="flex items-center gap-4">
<template #title> <div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-lg font-semibold text-primary-foreground">
早安{{ userStore.userInfo?.realName || userStore.userInfo?.username || '管理员' }}这里是 AI Agent Admin 工作台 {{ displayName.slice(0, 1) }}
</template> </div>
<template #description> <div>
保留 zq-ai-admin 成熟后台体验聚焦智能体协作流程编排模型配置知识库和基础权限治理 <h1 class="text-xl font-semibold">
</template> {{ greeting }}{{ displayName }}欢迎回来
</WorkbenchHeader> </h1>
<p class="text-muted-foreground mt-1 text-sm">
AI Agent Admin 已保留 zq-ai-admin 成熟后台能力聚焦智能体协作流程编排和基础权限治理
</p>
</div>
</div>
<div class="grid min-w-72 grid-cols-4 gap-4 text-center">
<div v-for="item in metrics" :key="item.label">
<div class="text-xl font-semibold">{{ item.value }}</div>
<div class="text-muted-foreground mt-1 text-xs">{{ item.label }}</div>
</div>
</div>
</div>
</div>
<div class="mt-5 flex flex-col gap-4 lg:flex-row"> <div class="rounded-lg border bg-card p-5 shadow-sm">
<div class="w-full lg:w-3/5"> <div class="text-muted-foreground text-sm">当前时间</div>
<WorkbenchProject <div class="mt-4 text-2xl font-semibold tabular-nums">{{ currentTime }}</div>
:items="projectItems" <div class="text-muted-foreground mt-2 text-sm">{{ currentDate }}</div>
title="核心入口"
@click="navTo"
/>
<WorkbenchTrends
:items="trendItems"
class="mt-5"
title="运行动态"
/>
</div> </div>
<div class="w-full lg:w-2/5"> </section>
<WorkbenchQuickNav
:items="quickNavItems" <section class="mt-3 grid gap-3 xl:grid-cols-[1.4fr_1fr]">
title="基础管理" <div class="rounded-lg border bg-card p-4 shadow-sm">
@click="navTo" <div class="mb-5 flex items-center justify-between">
/> <h2 class="text-base font-semibold">AI 协作中心</h2>
<WorkbenchTodo <button class="text-muted-foreground hover:text-foreground text-sm" @click="navTo('/ai-platform/agent')">
:items="todoItems" 更多
class="mt-5" </button>
title="协作闭环待办" </div>
/> <div class="grid grid-cols-2 gap-4 md:grid-cols-3">
<button
v-for="item in aiActions"
:key="item.title"
class="group flex items-start gap-3 rounded-md border p-4 text-left transition-colors hover:bg-accent"
@click="navTo(item.url)"
>
<span class="flex size-10 shrink-0 items-center justify-center rounded-full text-white" :style="{ backgroundColor: item.color }">
<component :is="item.icon" class="size-5" />
</span>
<span class="min-w-0">
<span class="block font-medium">{{ item.title }}</span>
<span class="text-muted-foreground mt-1 line-clamp-2 block text-xs leading-5">{{ item.desc }}</span>
</span>
</button>
</div>
</div> </div>
</div>
<div class="rounded-lg border bg-card p-4 shadow-sm">
<div class="mb-5 flex items-center justify-between">
<h2 class="text-base font-semibold">基础管理</h2>
<button class="text-muted-foreground hover:text-foreground text-sm" @click="navTo('/system/user')">
更多
</button>
</div>
<div class="grid grid-cols-3 border-b border-r">
<button
v-for="item in adminActions"
:key="item.title"
class="flex h-28 flex-col items-center justify-center gap-2 border-l border-t text-center transition-colors hover:bg-accent"
@click="navTo(item.url)"
>
<component :is="item.icon" class="size-7" :style="{ color: item.color }" />
<span class="text-sm font-medium">{{ item.title }}</span>
</button>
</div>
</div>
</section>
<section class="mt-3 grid gap-3 xl:grid-cols-[1fr_1fr]">
<div class="rounded-lg border bg-card p-4 shadow-sm">
<div class="mb-4 flex items-center gap-2">
<Megaphone class="text-muted-foreground size-4" />
<h2 class="text-base font-semibold">最新公告</h2>
<span v-if="unreadAnnouncements" class="ml-auto rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{{ unreadAnnouncements }} 未读
</span>
</div>
<div v-if="loading.announcements" class="text-muted-foreground py-10 text-center text-sm">加载公告中...</div>
<div v-else-if="announcements.length" class="divide-y">
<button
v-for="item in announcements"
:key="item.id"
class="block w-full py-3 text-left"
@click="navTo('/message/announcement-list')"
>
<div class="flex items-center gap-2">
<span class="truncate font-medium">{{ item.title }}</span>
<span v-if="!item.is_read" class="size-2 rounded-full bg-primary"></span>
</div>
<p class="text-muted-foreground mt-1 line-clamp-1 text-sm">{{ item.summary || item.content }}</p>
</button>
</div>
<button v-else class="text-muted-foreground flex h-40 w-full flex-col items-center justify-center gap-2 text-sm" @click="navTo('/message/announcement-list')">
<Megaphone class="size-10 opacity-30" />
暂无公告
</button>
</div>
<div class="rounded-lg border bg-card p-4 shadow-sm">
<div class="mb-4 flex items-center gap-2">
<Bell class="text-muted-foreground size-4" />
<h2 class="text-base font-semibold">消息通知</h2>
<span v-if="unreadMessages" class="ml-auto rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{{ unreadMessages }} 未读
</span>
</div>
<div v-if="loading.messages" class="text-muted-foreground py-10 text-center text-sm">加载消息中...</div>
<div v-else-if="messages.length" class="divide-y">
<button
v-for="item in messages"
:key="item.id"
class="block w-full py-3 text-left"
@click="navTo('/message/list')"
>
<div class="flex items-center gap-2">
<span class="truncate font-medium">{{ item.title }}</span>
<span v-if="item.status === 'unread'" class="size-2 rounded-full bg-primary"></span>
</div>
<p class="text-muted-foreground mt-1 line-clamp-1 text-sm">{{ item.content }}</p>
</button>
</div>
<button v-else class="text-muted-foreground flex h-40 w-full flex-col items-center justify-center gap-2 text-sm" @click="navTo('/message/list')">
<Inbox class="size-10 opacity-30" />
暂无消息
</button>
</div>
</section>
<section class="mt-3 grid gap-3 xl:grid-cols-[1.4fr_1fr]">
<div class="rounded-lg border bg-card p-4 shadow-sm">
<div class="mb-5 flex items-center gap-2">
<Server class="text-muted-foreground size-4" />
<h2 class="text-base font-semibold">运行环境</h2>
</div>
<div v-if="loading.server" class="text-muted-foreground py-10 text-center text-sm">加载服务器信息中...</div>
<div v-else class="grid gap-4 md:grid-cols-4">
<div class="rounded-md bg-muted/40 p-4">
<div class="text-muted-foreground text-xs">CPU</div>
<div class="mt-3 text-xl font-semibold">{{ formatPercent(realtimeStats?.cpu_percent) }}</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ serverOverview?.cpu_info?.physical_cores || 0 }} / {{ serverOverview?.cpu_info?.total_cores || 0 }}线程
</div>
</div>
<div class="rounded-md bg-muted/40 p-4">
<div class="text-muted-foreground text-xs">内存</div>
<div class="mt-3 text-xl font-semibold">{{ formatPercent(realtimeStats?.memory_percent) }}</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ formatMemory(realtimeStats?.memory_details?.used) }} /
{{ formatMemory(realtimeStats?.memory_details?.total) }}
</div>
</div>
<div class="rounded-md bg-muted/40 p-4">
<div class="text-muted-foreground text-xs">部署</div>
<div class="mt-3 text-xl font-semibold">systemd</div>
<div class="text-muted-foreground mt-1 text-xs">/ai-agent-admin</div>
</div>
<div class="rounded-md bg-muted/40 p-4">
<div class="text-muted-foreground text-xs">运行状态</div>
<div class="mt-3 text-xl font-semibold">可用</div>
<div class="text-muted-foreground mt-1 text-xs"> Docker 主部署</div>
</div>
</div>
</div>
<div class="rounded-lg border bg-card p-4 shadow-sm">
<div class="mb-5 flex items-center gap-2">
<LayoutDashboard class="text-muted-foreground size-4" />
<h2 class="text-base font-semibold">快捷入口</h2>
</div>
<div class="grid grid-cols-2 gap-3">
<button
v-for="item in opsActions"
:key="item.title"
class="rounded-md border p-3 text-left transition-colors hover:bg-accent"
@click="navTo(item.url)"
>
<component :is="item.icon" class="mb-3 size-5" :style="{ color: item.color }" />
<div class="font-medium">{{ item.title }}</div>
<div class="text-muted-foreground mt-1 text-xs">{{ item.desc }}</div>
</button>
</div>
</div>
</section>
</div> </div>
</template> </template>