313 lines
14 KiB
Vue
313 lines
14 KiB
Vue
<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 元素,否则返回 null(antd 会安全地居中展示)。
|
||
// 组件 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>
|