Files
nl-tcm-agent-admin/src/layouts/AdminLayout.vue
2026-08-15 12:52:39 +08:00

313 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// ========================================================================
// AdminLayout —— 全站布局框架
// ========================================================================
// - 左侧玻璃 Sider三分组菜单监控 / AI 管理 / 知识库),折叠态记 localStorage
// - 顶栏:面包屑 + 服务健康点 + 失败告警铃铛 + 命令面板按钮 + 主题切换 + 帮助 + 用户区
// - 全局机制:/health 离线检测、失败告警轮询、JWT 静默续签、Ctrl+K、首登 Tour
// ========================================================================
import { ref, computed, onMounted, onUnmounted, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Modal, notification } from 'ant-design-vue'
import {
DashboardOutlined, UnorderedListOutlined, HistoryOutlined, BarChartOutlined,
FileTextOutlined, DesktopOutlined, BugOutlined, ApiOutlined, SettingOutlined,
DatabaseOutlined, SearchOutlined, BellOutlined, BulbOutlined, QuestionCircleOutlined,
UserOutlined, LogoutOutlined, ThunderboltOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
CloudDownloadOutlined
} from '@ant-design/icons-vue'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { probeHealth } from '@/api/request'
import { getStats, getRuns } from '@/api/agent'
import { usePolling } from '@/composables/usePolling'
import { fmtTime, sceneLabel } from '@/utils/format'
const app = useAppStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
// ---------------- Sider 菜单(三分组) ----------------
const COLLAPSE_KEY = 'agent_admin_sider_collapsed'
const collapsed = ref(localStorage.getItem(COLLAPSE_KEY) === '1')
function toggleCollapse() {
collapsed.value = !collapsed.value
localStorage.setItem(COLLAPSE_KEY, collapsed.value ? '1' : '0')
}
const menuItems = [
{ type: 'group', label: '监控', children: [
{ key: '/dashboard', icon: () => h(DashboardOutlined), label: '仪表盘' },
{ key: '/runs', icon: () => h(UnorderedListOutlined), label: '运行记录' },
{ key: '/history', icon: () => h(HistoryOutlined), label: '历史记录' },
{ key: '/stats', icon: () => h(BarChartOutlined), label: '统计分析' },
{ key: '/logs', icon: () => h(FileTextOutlined), label: '实时日志' },
{ key: '/system', icon: () => h(DesktopOutlined), label: '系统状态' }
]},
{ type: 'group', label: 'AI 管理', children: [
{ key: '/debug', icon: () => h(BugOutlined), label: '调试工具' },
{ key: '/models', icon: () => h(ApiOutlined), label: '模型管理' },
{ key: '/config', icon: () => h(SettingOutlined), label: '配置总览' }
]},
{ type: 'group', label: '知识库', children: [
{ key: '/kb', icon: () => h(DatabaseOutlined), label: '知识库管理' },
{ key: '/kb-search', icon: () => h(SearchOutlined), label: '知识库检索' },
{ key: '/kb-crawl', icon: () => h(CloudDownloadOutlined), label: '药品抓取' }
]}
]
const selectedKeys = computed(() => ['/' + route.path.split('/')[1]])
function onMenuClick({ key }) { router.push(key) }
// ---------------- 服务健康点(离线检测) ----------------
let healthFails = 0
usePolling(async () => {
try {
await probeHealth()
healthFails = 0
if (app.offline) {
app.offline = false
notification.success({ message: '服务已恢复', description: 'Agent 服务重新可达' })
}
} catch {
healthFails++
// 连续 2 次失败才判离线(容忍单次网络抖动)
if (healthFails >= 2 && !app.offline) {
app.offline = true
app.offlineSince = Date.now()
}
}
}, 10000)
// ---------------- 失败告警(铃铛 + 浏览器通知) ----------------
const bellOpen = ref(false)
const recentFails = ref([])
usePolling(async () => {
try {
const res = await getStats({ silent: true })
const failed = res.data.failed + res.data.blocked
if (app.lastFailedTotal >= 0 && failed > app.lastFailedTotal) {
const delta = failed - app.lastFailedTotal
app.alarmCount += delta
if (app.notifyEnabled) {
notification.warning({
message: `新增 ${delta} 条失败/拦截运行`,
description: res.data.last_error ? res.data.last_error.slice(0, 80) : '',
onClick: () => router.push('/runs?status=2')
})
// 浏览器系统通知(需授权;页面在后台时也能提醒)
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('TCM Agent 告警', { body: `新增 ${delta} 条失败运行` })
}
}
}
app.lastFailedTotal = failed
} catch { /* 离线时静默 */ }
}, 15000)
async function openBell() {
bellOpen.value = true
app.clearAlarm()
try {
// 铃铛计数含「失败(2)+拦截(3)」,列表也要两种都展示——
// runs 接口一次只能筛一种状态,所以拉全量后前端过滤取前 8 条
const res = await getRuns({ limit: 50 }, { silent: true })
recentFails.value = (res.data || []).filter(r => r.status >= 2).slice(0, 8)
} catch { recentFails.value = [] }
}
function toggleNotifyPermission() {
app.toggleNotify()
if (app.notifyEnabled && 'Notification' in window && Notification.permission === 'default') {
Notification.requestPermission()
}
}
// ---------------- JWT 静默续签(每 10 分钟检查一次) ----------------
usePolling(() => userStore.maybeRefresh(), 600000, { immediate: true })
// ---------------- Ctrl+K 命令面板 ----------------
function onGlobalKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
app.paletteOpen = !app.paletteOpen
}
}
onMounted(() => window.addEventListener('keydown', onGlobalKey))
onUnmounted(() => window.removeEventListener('keydown', onGlobalKey))
// ---------------- 首登新手引导antd Tour ----------------
const TOUR_KEY = 'agent_admin_tour_done'
const refSider = ref(null), refHealth = ref(null), refBell = ref(null)
const refPalette = ref(null), refTheme = ref(null), refHelp = ref(null)
// Tour 目标解析:必须返回真实 DOM 元素,否则返回 nullantd 会安全地居中展示)。
// 组件 ref 拿到的是组件实例,直接传给 Tour 会在定位计算时抛
// "getBoundingClientRect is not a function"——遮罩渲染了但定位卡死,
// 且异常会打断 Vue 更新队列导致整个布局停止响应(已踩过坑)
function tourEl(r) {
const el = r?.$el ?? r
return el instanceof HTMLElement ? el : null
}
const tourSteps = [
{ title: '侧边菜单', description: '功能按「监控 / AI 管理 / 知识库」三组划分:看运行去监控,调模型去 AI 管理,管语料去知识库。', target: () => tourEl(refSider.value) },
{ title: '服务健康点', description: '绿点=Go 服务在线,红点=不可达(会弹出全局横幅并自动重连)。', target: () => tourEl(refHealth.value) },
{ title: '失败告警', description: '出现新的失败/拦截运行时这里会亮红点,可开关浏览器通知;点开可直达失败详情。', target: () => tourEl(refBell.value) },
{ title: '命令面板', description: '按 Ctrl+K 随时唤起:搜页面、执行快捷动作、输入运行 ID 直达详情。', target: () => tourEl(refPalette.value) },
{ title: '主题切换', description: '暗色暖调是默认主题,也可以切到亮色,偏好会记住。', target: () => tourEl(refTheme.value) },
{ title: '随时回看引导', description: '点这里可以重新播放本引导。先去「调试工具」发一条测试请求,再回仪表盘看数据流动起来!', target: () => tourEl(refHelp.value) }
]
function onTourFinish() {
app.tourOpen = false
localStorage.setItem(TOUR_KEY, '1')
}
onMounted(() => {
// 首次登录自动启动localStorage 无标记时)
if (localStorage.getItem(TOUR_KEY) !== '1') {
setTimeout(() => app.startTour(), 800)
}
})
// ---------------- 用户区 ----------------
function doLogout() {
Modal.confirm({
title: '确定退出登录?',
onOk: () => {
userStore.logout()
router.push('/login')
}
})
}
// 离线横幅重连倒计时展示
const now = ref(Date.now())
usePolling(() => { now.value = Date.now() }, 1000)
const retryCountdown = computed(() => {
const elapsed = Math.floor((now.value - app.offlineSince) / 1000)
return 10 - (elapsed % 10)
})
</script>
<template>
<a-layout class="min-h-screen" style="background: transparent">
<!-- 侧边栏 -->
<a-layout-sider ref="refSider" v-model:collapsed="collapsed" :trigger="null" collapsible
:width="224" :collapsed-width="64"
class="!sticky top-0 h-screen glass-strong !rounded-none border-r"
style="border-color: var(--glass-border); z-index: 20">
<div class="flex items-center gap-2 px-4 h-14" style="border-bottom: 1px solid var(--glass-border)">
<span class="text-xl">🌿</span>
<span v-if="!collapsed" class="font-semibold text-sm whitespace-nowrap" style="color: var(--primary)">TCM Agent 控制台</span>
</div>
<a-menu :selected-keys="selectedKeys" mode="inline" :items="menuItems"
style="background: transparent; border-inline-end: none"
@click="onMenuClick" />
</a-layout-sider>
<a-layout style="background: transparent">
<!-- 顶栏 -->
<a-layout-header class="!px-4 !h-14 flex items-center gap-3 sticky top-0 glass-strong !rounded-none"
style="z-index: 19; line-height: normal; border-bottom: 1px solid var(--glass-border); background: var(--glass-bg-strong)">
<a-button type="text" @click="toggleCollapse">
<MenuUnfoldOutlined v-if="collapsed" /><MenuFoldOutlined v-else />
</a-button>
<a-breadcrumb>
<a-breadcrumb-item>{{ route.meta.group || 'TCM Agent' }}</a-breadcrumb-item>
<a-breadcrumb-item>{{ route.meta.title }}</a-breadcrumb-item>
</a-breadcrumb>
<div class="flex-1"></div>
<!-- 服务健康点 -->
<a-tooltip :title="app.offline ? '服务不可达' : '服务在线10s 轮询 /health'">
<span ref="refHealth" class="flex items-center gap-1.5 text-xs" style="color: var(--text-2)">
<span class="health-dot" :class="{ down: app.offline }"></span>
{{ app.offline ? '离线' : '在线' }}
</span>
</a-tooltip>
<!-- 失败告警铃铛 -->
<a-popover v-model:open="bellOpen" trigger="click" placement="bottomRight" @open-change="v => v && openBell()">
<template #content>
<div class="w-72">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">最近失败运行</span>
<a-switch :checked="app.notifyEnabled" size="small"
checked-children="通知开" un-checked-children="通知关"
@click="toggleNotifyPermission" />
</div>
<div v-if="!recentFails.length" class="text-xs text-center py-4" style="color: var(--text-3)">暂无失败记录</div>
<div v-for="r in recentFails" :key="r.id"
class="py-1.5 px-2 rounded cursor-pointer text-xs glass-hover flex items-center gap-2"
@click="bellOpen = false; app.openRun(r.id)">
<span class="mono" style="color: var(--text-3)">#{{ r.id }}</span>
<span>{{ sceneLabel(r.scene) }}</span>
<span class="ml-auto" style="color: var(--text-3)">{{ fmtTime(r.started_at) }}</span>
</div>
<a-button block size="small" class="mt-2" @click="bellOpen = false; router.push('/runs?status=2')">查看全部失败</a-button>
</div>
</template>
<a-badge ref="refBell" :count="app.alarmCount" :offset="[-2, 4]" size="small">
<a-button type="text"><BellOutlined /></a-button>
</a-badge>
</a-popover>
<!-- 命令面板入口(多入口之一,另有 Ctrl+K -->
<a-tooltip title="命令面板Ctrl+K">
<a-button ref="refPalette" type="text" @click="app.paletteOpen = true">
<ThunderboltOutlined />
</a-button>
</a-tooltip>
<!-- 主题切换 -->
<a-tooltip :title="app.theme === 'dark' ? '切换到亮色' : '切换到暗色'">
<a-button ref="refTheme" type="text" @click="app.toggleTheme()"><BulbOutlined /></a-button>
</a-tooltip>
<!-- 帮助(重播引导) -->
<a-tooltip title="重播新手引导">
<a-button ref="refHelp" type="text" @click="app.startTour()"><QuestionCircleOutlined /></a-button>
</a-tooltip>
<!-- 用户下拉 -->
<a-dropdown>
<span class="flex items-center gap-2 cursor-pointer px-2">
<a-avatar size="small" style="background: var(--primary)"><template #icon><UserOutlined /></template></a-avatar>
<span class="text-sm" style="color: var(--text-1)">{{ userStore.user?.nick_name || '管理员' }}</span>
</span>
<template #overlay>
<a-menu>
<a-menu-item disabled>
<span class="text-xs">{{ userStore.user?.role_name || '面板管理员' }}</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="doLogout"><LogoutOutlined /> 退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-layout-header>
<!-- 服务离线横幅 -->
<a-alert v-if="app.offline" banner type="error" class="!rounded-none"
:message="`Agent 服务不可达 —— 正在自动重连(${retryCountdown}s 后重试),服务恢复后本横幅自动消失`" />
<!-- 内容区 -->
<a-layout-content class="p-5">
<router-view />
</a-layout-content>
</a-layout>
<!-- 新手引导 -->
<a-tour :open="app.tourOpen" :steps="tourSteps" @close="onTourFinish" @finish="onTourFinish" />
</a-layout>
</template>
<style scoped>
/* 菜单玻璃化:菜单项 hover/选中带暖色 */
:deep(.ant-menu-item-selected) {
background: rgba(245, 158, 11, 0.14) !important;
}
:deep(.ant-menu-item-group-title) {
font-size: 11px;
color: var(--text-3);
}
</style>