Procházet zdrojové kódy

feat: 后台活动栏按使用场景分组(7 组),组内页面以页签切换并记住上次位置

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang před 1 týdnem
rodič
revize
aff75bf7c8
2 změnil soubory, kde provedl 91 přidání a 24 odebrání
  1. 76 24
      apps/admin/src/components/Workbench.vue
  2. 15 0
      docs/design/09-admin-ui.md

+ 76 - 24
apps/admin/src/components/Workbench.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onBeforeUnmount, onMounted, ref } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import type { ProviderStatusDto } from '@agent-server/shared'
 import { admin } from '../lib/api'
@@ -9,23 +9,29 @@ import { requireLogin } from '../router'
 const route = useRoute()
 const router = useRouter()
 
-const nav = [
-  { to: '/playground', icon: 'codicon-comment-discussion', label: 'Playground' },
-  { to: '/bot-playground', icon: 'codicon-organization', label: 'Bot Playground' },
-  { to: '/bot-cases', icon: 'codicon-beaker', label: '回归用例' },
-  { to: '/sessions', icon: 'codicon-history', label: '会话记录' },
-  { to: '/schedules', icon: 'codicon-watch', label: '定时任务' },
-  { to: '/background-calls', icon: 'codicon-server-environment', label: '后台调用' },
-  { to: '/overview', icon: 'codicon-dashboard', label: '概览' },
-  { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },
-  { to: '/bots', icon: 'codicon-robot', label: '机器人' },
-  { to: '/knowledge', icon: 'codicon-library', label: '资料库' },
-  { to: '/files', icon: 'codicon-files', label: '文件管理' },
-  { to: '/plugins', icon: 'codicon-extensions', label: '技能与插件' },
-  { to: '/mcp', icon: 'codicon-server-process', label: 'MCP 服务' },
-  { to: '/api-keys', icon: 'codicon-key', label: 'API Keys' },
-  { to: '/api-docs', icon: 'codicon-book', label: 'API 协议' },
-  { to: '/audit', icon: 'codicon-checklist', label: '审计日志' },
+/**
+ * 活动栏按使用场景分组;组内有多个页面时,在内容区顶部以页签切换。页面地址不变(页面之间的跳转链接照常可用)。
+ * 点击组时回到这组里上次看的页面,点击页签时回到这个页面上次的位置(例如打开过的工作组)
+ */
+interface NavPage { to: string; label: string }
+interface NavGroup { key: string; icon: string; label: string; pages: NavPage[] }
+const groups: NavGroup[] = [
+  { key: 'playground', icon: 'codicon-comment-discussion', label: 'Playground', pages: [{ to: '/playground', label: 'Playground' }] },
+  {
+    key: 'teams', icon: 'codicon-organization', label: '工作组',
+    pages: [{ to: '/bot-playground', label: 'Bot Playground' }, { to: '/bots', label: '机器人' }, { to: '/bot-cases', label: '回归用例' }],
+  },
+  { key: 'overview', icon: 'codicon-dashboard', label: '概览', pages: [{ to: '/overview', label: '概览' }] },
+  {
+    key: 'records', icon: 'codicon-history', label: '运行记录',
+    pages: [{ to: '/sessions', label: '会话记录' }, { to: '/schedules', label: '定时任务' }, { to: '/background-calls', label: '后台调用' }, { to: '/audit', label: '审计日志' }],
+  },
+  {
+    key: 'capabilities', icon: 'codicon-extensions', label: '能力',
+    pages: [{ to: '/profiles', label: 'Profiles' }, { to: '/plugins', label: '技能与插件' }, { to: '/mcp', label: 'MCP 服务' }, { to: '/knowledge', label: '资料库' }],
+  },
+  { key: 'files', icon: 'codicon-files', label: '文件管理', pages: [{ to: '/files', label: '文件管理' }] },
+  { key: 'access', icon: 'codicon-key', label: '接入', pages: [{ to: '/api-keys', label: 'API Keys' }, { to: '/api-docs', label: 'API 协议' }] },
 ]
 
 const providers = ref<ProviderStatusDto[]>([])
@@ -57,6 +63,37 @@ async function logout() {
 }
 
 const isActive = (to: string) => route.path === to || route.path.startsWith(`${to}/`)
+const currentGroup = computed(() => groups.find(g => g.pages.some(p => isActive(p.to))))
+const currentPage = computed(() => currentGroup.value?.pages.find(p => isActive(p.to)))
+
+// 每个页面上次的位置、每组上次的页面(只是便利,存不了也不影响使用)
+const NAV_KEY = 'agent-server.nav'
+const memory: { pages: Record<string, string>; groups: Record<string, string> } = (() => {
+  try {
+    return { pages: {}, groups: {}, ...JSON.parse(localStorage.getItem(NAV_KEY) ?? '{}') }
+  } catch {
+    return { pages: {}, groups: {} }
+  }
+})()
+watch(() => route.fullPath, path => {
+  const g = currentGroup.value, p = currentPage.value
+  if (!g || !p) return
+  memory.pages[p.to] = path
+  memory.groups[g.key] = p.to
+  try {
+    localStorage.setItem(NAV_KEY, JSON.stringify(memory))
+  } catch {
+    // 隐私模式等情况下忽略
+  }
+}, { immediate: true })
+
+const openPage = (p: NavPage) => router.push(memory.pages[p.to] ?? p.to)
+function openGroup(g: NavGroup) {
+  const page = g.pages.find(p => p.to === memory.groups[g.key]) ?? g.pages[0]!
+  // 已经在这组里时回到页面本身(例如从某个工作组回到 Bot Playground)
+  if (currentGroup.value === g && currentPage.value === page) void router.push(page.to)
+  else void openPage(page)
+}
 
 onMounted(() => {
   void refresh()
@@ -71,12 +108,12 @@ onBeforeUnmount(() => clearInterval(timer))
       <!-- 活动栏 -->
       <nav class="w-12 flex-none flex flex-col items-center bg-[var(--app-activitybar-background)] border-r border-r-solid border-border">
         <button
-          v-for="n in nav" :key="n.to" type="button" :title="n.label"
+          v-for="g in groups" :key="g.key" type="button" :title="g.pages.length > 1 ? `${g.label}:${g.pages.map(p => p.label).join('、')}` : g.label"
           class="relative w-12 h-12 flex items-center justify-center border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg"
-          :class="{ '!text-fg': isActive(n.to) }" @click="router.push(n.to)"
+          :class="{ '!text-fg': currentGroup === g }" @click="openGroup(g)"
         >
-          <span v-if="isActive(n.to)" class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent" />
-          <span class="codicon text-[22px]" :class="n.icon" />
+          <span v-if="currentGroup === g" class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent" />
+          <span class="codicon text-[22px]" :class="g.icon" />
         </button>
         <div class="flex-1" />
         <button type="button" title="切换主题" class="w-12 h-11 flex items-center justify-center border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg" @click="cycleTheme">
@@ -93,8 +130,23 @@ onBeforeUnmount(() => clearInterval(timer))
           <span class="codicon codicon-sign-out text-[20px]" />
         </button>
       </nav>
-      <main class="flex-1 min-w-0 min-h-0">
-        <slot />
+      <main class="flex-1 min-w-0 min-h-0 flex flex-col">
+        <!-- 组内页签 -->
+        <div v-if="currentGroup && currentGroup.pages.length > 1" class="h-[33px] flex-none flex items-stretch gap-1 px-3 bg-bg-side border-b border-b-solid border-border">
+          <span class="flex items-center gap-1.5 pr-3 mr-1 text-[11px] font-600 uppercase tracking-wide text-fg-muted border-r border-r-solid border-border my-2">
+            <span class="codicon text-[14px]" :class="currentGroup.icon" />{{ currentGroup.label }}
+          </span>
+          <button
+            v-for="p in currentGroup.pages" :key="p.to" type="button"
+            class="px-2.5 border-none bg-transparent cursor-pointer text-[12.5px] border-b-2 border-b-solid"
+            :class="currentPage === p ? 'text-fg border-b-accent' : 'text-fg-muted border-b-transparent hover:text-fg'" @click="openPage(p)"
+          >
+            {{ p.label }}
+          </button>
+        </div>
+        <div class="flex-1 min-h-0">
+          <slot />
+        </div>
       </main>
     </div>
     <!-- 状态栏 -->

+ 15 - 0
docs/design/09-admin-ui.md

@@ -19,6 +19,21 @@
 
 ## 2. 页面清单
 
+活动栏按使用场景分组(2026-10-27 起),组内有多个页面时在主区域顶部以页签切换;页面地址不变,点击组时回到这组里上次看的页面:
+
+| 活动栏 | 页签 |
+|---|---|
+| 💬 Playground | — |
+| 👥 工作组 | Bot Playground · 机器人 · 回归用例(13-bot-teams) |
+| 📊 概览 | — |
+| 🕘 运行记录 | 会话记录 · 定时任务(14-schedules)· 后台调用(15-background-calls)· 审计日志 |
+| 🧩 能力 | Profiles · 技能与插件 · MCP 服务 · 资料库 |
+| 🗃 文件管理 | — |
+| 🔑 接入 | API Keys · API 协议 |
+| ⚙ 设置(底部) | — |
+
+各页面的内容:
+
 | 活动栏 | 侧栏 | 主区域 |
 |---|---|---|
 | 💬 Playground | 会话列表(搜索、按引擎/使用者筛选) | 对话面板 + 右侧可收起的调试面板 |