feat(gui): add four-quadrant Agent-First layout with top navigation (U2)

- Create AgentLayout.vue with CSS Grid four-quadrant layout
- Create SplitPane.vue with draggable divider and localStorage persistence
- Create TopNav.vue with logo, status indicator, and settings entry
- Create QuadrantPanel.vue with tab switching and collapse support
- Restructure router: /agent as main route, legacy routes redirect
- App.vue now uses router-view for layout switching
This commit is contained in:
chiguyong 2026-06-13 02:35:28 +08:00
parent 2988d3768e
commit 9273612a5b
6 changed files with 719 additions and 53 deletions

View File

@ -1,13 +1,12 @@
<template>
<a-config-provider :locale="zhCN" :theme="themeConfig">
<AppLayout />
<router-view />
</a-config-provider>
</template>
<script setup lang="ts">
import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import AppLayout from './components/layout/AppLayout.vue'
import { themeConfig } from './styles'
</script>

View File

@ -0,0 +1,162 @@
<template>
<div class="agent-layout">
<TopNav />
<div class="agent-layout__body">
<SplitPane
direction="horizontal"
:default-ratio="0.5"
storage-key="agent-h-split"
>
<template #first>
<SplitPane
direction="vertical"
:default-ratio="0.6"
storage-key="agent-left-v-split"
>
<template #first>
<QuadrantPanel
:tabs="topLeftTabs"
default-tab="chat"
storage-key="quadrant-tl-tab"
>
<template #chat>
<ChatView />
</template>
</QuadrantPanel>
</template>
<template #second>
<QuadrantPanel
:tabs="bottomLeftTabs"
default-tab="terminal"
storage-key="quadrant-bl-tab"
>
<template #terminal>
<TerminalView />
</template>
</QuadrantPanel>
</template>
</SplitPane>
</template>
<template #second>
<SplitPane
direction="vertical"
:default-ratio="0.6"
storage-key="agent-right-v-split"
>
<template #first>
<QuadrantPanel
:tabs="topRightTabs"
default-tab="code"
storage-key="quadrant-tr-tab"
>
<template #code>
<div class="agent-layout__placeholder">
<FileTextOutlined style="font-size: 32px; color: var(--text-placeholder)" />
<p>代码预览</p>
</div>
</template>
<template #workflow>
<WorkflowView />
</template>
<template #knowledge>
<KnowledgeBaseView />
</template>
</QuadrantPanel>
</template>
<template #second>
<QuadrantPanel
:tabs="bottomRightTabs"
default-tab="monitor"
storage-key="quadrant-br-tab"
>
<template #monitor>
<EvolutionView />
</template>
<template #skills>
<SkillsView />
</template>
<template #settings>
<SettingsView />
</template>
</QuadrantPanel>
</template>
</SplitPane>
</template>
</SplitPane>
</div>
</div>
</template>
<script setup lang="ts">
import { type Component } from 'vue'
import {
MessageOutlined,
CodeOutlined,
FileTextOutlined,
ApartmentOutlined,
BookOutlined,
DashboardOutlined,
AppstoreOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import TopNav from './TopNav.vue'
import SplitPane from './SplitPane.vue'
import QuadrantPanel from './QuadrantPanel.vue'
import type { QuadrantTab } from './QuadrantPanel.vue'
import ChatView from '@/views/ChatView.vue'
import TerminalView from '@/views/TerminalView.vue'
import WorkflowView from '@/views/WorkflowView.vue'
import KnowledgeBaseView from '@/views/KnowledgeBaseView.vue'
import EvolutionView from '@/views/EvolutionView.vue'
import SkillsView from '@/views/SkillsView.vue'
import SettingsView from '@/views/SettingsView.vue'
const topLeftTabs: QuadrantTab[] = [
{ key: 'chat', label: '对话', icon: MessageOutlined as Component },
]
const bottomLeftTabs: QuadrantTab[] = [
{ key: 'terminal', label: '终端', icon: CodeOutlined as Component },
]
const topRightTabs: QuadrantTab[] = [
{ key: 'code', label: '代码', icon: FileTextOutlined as Component },
{ key: 'workflow', label: '工作流', icon: ApartmentOutlined as Component },
{ key: 'knowledge', label: '知识库', icon: BookOutlined as Component },
]
const bottomRightTabs: QuadrantTab[] = [
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component },
{ key: 'skills', label: '技能', icon: AppstoreOutlined as Component },
{ key: 'settings', label: '设置', icon: SettingOutlined as Component },
]
</script>
<style scoped>
.agent-layout {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--bg-secondary);
}
.agent-layout__body {
flex: 1;
padding: var(--space-2);
gap: 0;
overflow: hidden;
}
.agent-layout__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-3);
color: var(--text-placeholder);
font-size: var(--font-sm);
}
</style>

View File

@ -0,0 +1,154 @@
<template>
<div class="quadrant-panel" :class="{ 'quadrant-panel--collapsed': collapsed }">
<div class="quadrant-panel__header">
<div class="quadrant-panel__tabs">
<button
v-for="tab in tabs"
:key="tab.key"
:class="['quadrant-panel__tab', { 'quadrant-panel__tab--active': activeTab === tab.key }]"
@click="activeTab = tab.key"
>
<component :is="tab.icon" v-if="tab.icon" class="quadrant-panel__tab-icon" />
<span>{{ tab.label }}</span>
</button>
</div>
<button
class="quadrant-panel__collapse-btn"
@click="collapsed = !collapsed"
:title="collapsed ? '展开' : '折叠'"
>
<MinusOutlined v-if="!collapsed" />
<PlusOutlined v-else />
</button>
</div>
<div v-show="!collapsed" class="quadrant-panel__body">
<div v-for="tab in tabs" :key="tab.key" v-show="activeTab === tab.key" class="quadrant-panel__content">
<slot :name="tab.key" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { MinusOutlined, PlusOutlined } from '@ant-design/icons-vue'
export interface QuadrantTab {
key: string
label: string
icon?: Component
}
const props = withDefaults(defineProps<{
tabs: QuadrantTab[]
defaultTab?: string
storageKey?: string
}>(), {
defaultTab: '',
})
const savedTab = props.storageKey
? localStorage.getItem(props.storageKey) || props.defaultTab
: props.defaultTab
const activeTab = ref(savedTab || (props.tabs[0]?.key ?? ''))
const collapsed = ref(false)
</script>
<style scoped>
.quadrant-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
}
.quadrant-panel--collapsed {
height: auto !important;
}
.quadrant-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 var(--space-2);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.quadrant-panel__tabs {
display: flex;
gap: var(--space-1);
overflow-x: auto;
scrollbar-width: none;
}
.quadrant-panel__tabs::-webkit-scrollbar {
display: none;
}
.quadrant-panel__tab {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: none;
background: transparent;
color: var(--text-tertiary);
font-size: var(--font-xs);
cursor: pointer;
border-radius: var(--radius-sm);
white-space: nowrap;
transition: all var(--transition-fast);
}
.quadrant-panel__tab:hover {
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.quadrant-panel__tab--active {
color: var(--color-primary);
background: var(--color-primary-light);
font-weight: var(--font-weight-medium);
}
.quadrant-panel__tab-icon {
font-size: 12px;
}
.quadrant-panel__collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-sm);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.quadrant-panel__collapse-btn:hover {
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.quadrant-panel__body {
flex: 1;
overflow: hidden;
}
.quadrant-panel__content {
height: 100%;
overflow: auto;
}
</style>

View File

@ -0,0 +1,174 @@
<template>
<div
ref="containerRef"
:class="['split-pane', `split-pane--${direction}`, { 'split-pane--dragging': isDragging }]"
>
<div
class="split-pane__first"
:style="firstStyle"
>
<slot name="first" />
</div>
<div
class="split-pane__handle"
@mousedown="onMouseDown"
>
<div class="split-pane__handle-line" />
</div>
<div
class="split-pane__second"
:style="secondStyle"
>
<slot name="second" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = withDefaults(defineProps<{
direction?: 'horizontal' | 'vertical'
defaultRatio?: number
minRatio?: number
maxRatio?: number
storageKey?: string
}>(), {
direction: 'horizontal',
defaultRatio: 0.5,
minRatio: 0.2,
maxRatio: 0.8,
})
const containerRef = ref<HTMLElement | null>(null)
const isDragging = ref(false)
const savedRatio = props.storageKey
? parseFloat(localStorage.getItem(props.storageKey) || String(props.defaultRatio))
: props.defaultRatio
const ratio = ref(
Math.min(props.maxRatio, Math.max(props.minRatio, savedRatio))
)
const firstStyle = computed(() => {
if (props.direction === 'horizontal') {
return { width: `${ratio.value * 100}%` }
}
return { height: `${ratio.value * 100}%` }
})
const secondStyle = computed(() => {
if (props.direction === 'horizontal') {
return { width: `${(1 - ratio.value) * 100}%` }
}
return { height: `${(1 - ratio.value) * 100}%` }
})
function onMouseDown(e: MouseEvent) {
e.preventDefault()
isDragging.value = true
const startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
const containerSize = props.direction === 'horizontal'
? containerRef.value!.offsetWidth
: containerRef.value!.offsetHeight
const startRatio = ratio.value
function onMouseMove(ev: MouseEvent) {
const currentPos = props.direction === 'horizontal' ? ev.clientX : ev.clientY
const delta = (currentPos - startPos) / containerSize
const newRatio = Math.min(props.maxRatio, Math.max(props.minRatio, startRatio + delta))
ratio.value = newRatio
}
function onMouseUp() {
isDragging.value = false
if (props.storageKey) {
localStorage.setItem(props.storageKey, String(ratio.value))
}
// Trigger resize for Vue Flow / ECharts
window.dispatchEvent(new Event('resize'))
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
</script>
<style scoped>
.split-pane {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
.split-pane--horizontal {
flex-direction: row;
}
.split-pane--vertical {
flex-direction: column;
}
.split-pane__first,
.split-pane__second {
overflow: hidden;
min-width: 0;
min-height: 0;
}
.split-pane__handle {
flex-shrink: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--transition-fast);
}
.split-pane--horizontal > .split-pane__handle {
width: 6px;
cursor: col-resize;
flex-direction: column;
}
.split-pane--vertical > .split-pane__handle {
height: 6px;
cursor: row-resize;
flex-direction: row;
}
.split-pane__handle:hover,
.split-pane--dragging .split-pane__handle {
background-color: var(--color-primary);
}
.split-pane__handle-line {
background-color: var(--border-color);
border-radius: var(--radius-full);
transition: background-color var(--transition-fast);
}
.split-pane--horizontal > .split-pane__handle > .split-pane__handle-line {
width: 2px;
height: 24px;
}
.split-pane--vertical > .split-pane__handle > .split-pane__handle-line {
height: 2px;
width: 24px;
}
.split-pane__handle:hover .split-pane__handle-line,
.split-pane--dragging .split-pane__handle-line {
background-color: transparent;
}
.split-pane--dragging {
user-select: none;
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<header class="top-nav">
<div class="top-nav__left">
<div class="top-nav__logo" @click="router.push('/agent')">
<span class="top-nav__logo-text">Fischer</span>
<span class="top-nav__logo-badge">AgentKit</span>
</div>
</div>
<div class="top-nav__center">
<a-select
v-if="false"
:value="'default'"
class="top-nav__task-select"
size="small"
:bordered="false"
>
<a-select-option value="default">当前任务</a-select-option>
</a-select>
</div>
<div class="top-nav__right">
<div class="top-nav__status">
<a-badge
:status="wsConnected ? 'success' : 'error'"
:text="wsConnected ? '已连接' : '未连接'"
/>
</div>
<a-tooltip title="设置">
<button class="top-nav__icon-btn" @click="router.push('/agent/monitor?tab=settings')">
<SettingOutlined />
</button>
</a-tooltip>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Select as ASelect, SelectOption as ASelectOption, Badge as ABadge, Tooltip as ATooltip } from 'ant-design-vue'
import { SettingOutlined } from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat'
const router = useRouter()
const chatStore = useChatStore()
const wsConnected = computed(() => chatStore.isWsConnected)
</script>
<style scoped>
.top-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--topnav-height);
padding: 0 var(--space-4);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
z-index: var(--z-sticky);
}
.top-nav__left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.top-nav__logo {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
user-select: none;
}
.top-nav__logo-text {
font-size: var(--font-lg);
font-weight: var(--font-weight-bold);
background: var(--gradient-brand);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.top-nav__logo-badge {
font-size: var(--font-xs);
font-weight: var(--font-weight-medium);
color: var(--text-inverse);
background: var(--gradient-brand);
padding: 1px var(--space-2);
border-radius: var(--radius-full);
letter-spacing: 0.5px;
}
.top-nav__center {
display: flex;
align-items: center;
}
.top-nav__task-select {
min-width: 200px;
}
.top-nav__right {
display: flex;
align-items: center;
gap: var(--space-3);
}
.top-nav__status {
font-size: var(--font-xs);
}
.top-nav__status :deep(.ant-badge-status-text) {
color: var(--text-tertiary);
font-size: var(--font-xs);
}
.top-nav__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.top-nav__icon-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
</style>

View File

@ -2,96 +2,135 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
// Agent-First 四象限布局 (新)
{
path: '/agent',
name: 'agent',
component: () => import('@/components/layout/AgentLayout.vue'),
meta: { title: 'AgentKit' },
children: [
{
path: '',
redirect: '/agent/chat',
},
{
path: 'chat',
name: 'agent-chat',
meta: { title: '对话', quadrant: 'tl', tab: 'chat' },
component: () => import('@/views/ChatView.vue'),
},
{
path: 'code',
name: 'agent-code',
meta: { title: '代码', quadrant: 'tr', tab: 'code' },
component: () => import('@/views/ChatView.vue'),
},
{
path: 'terminal',
name: 'agent-terminal',
meta: { title: '终端', quadrant: 'bl', tab: 'terminal' },
component: () => import('@/views/TerminalView.vue'),
},
{
path: 'monitor',
name: 'agent-monitor',
meta: { title: '监控', quadrant: 'br', tab: 'monitor' },
component: () => import('@/views/EvolutionView.vue'),
},
],
},
// Default redirect to agent layout
{
path: '/',
name: 'chat',
component: () => import('@/views/ChatView.vue'),
meta: { title: '智能对话' },
redirect: '/agent',
},
// Legacy route redirects → agent quadrant routes
{
path: '/workflow',
name: 'workflow',
component: () => import('@/views/WorkflowView.vue'),
meta: { title: '工作流' },
redirect: '/agent/code?tab=workflow',
},
{
path: '/knowledge',
name: 'knowledge',
component: () => import('@/views/KnowledgeBaseView.vue'),
meta: { title: '知识库' },
redirect: '/agent/code?tab=knowledge',
},
{
path: '/skills',
name: 'skills',
component: () => import('@/views/SkillsView.vue'),
meta: { title: '技能' },
redirect: '/agent/monitor?tab=skills',
},
{
path: '/evolution',
redirect: '/agent/monitor?tab=monitor',
},
{
path: '/settings',
redirect: '/agent/monitor?tab=settings',
},
{
path: '/terminal',
name: 'terminal',
component: () => import('@/views/TerminalView.vue'),
meta: { title: '终端' },
redirect: '/agent/terminal',
},
// Computer Use (保留独立路由,显示"即将推出")
{
path: '/computer-use',
name: 'computer-use',
component: () => import('@/views/ComputerUseView.vue'),
meta: { title: 'Computer Use' },
},
// Legacy layout (fallback)
{
path: '/evolution',
name: 'evolution',
component: () => import('@/views/EvolutionView.vue'),
meta: { title: '自进化' },
path: '/legacy',
name: 'legacy',
component: () => import('@/components/layout/AppLayout.vue'),
meta: { title: 'Fischer AgentKit (Legacy)' },
children: [
{
path: '',
redirect: '/evolution/overview',
path: 'chat',
name: 'legacy-chat',
component: () => import('@/views/ChatView.vue'),
meta: { title: '智能对话' },
},
{
path: 'overview',
name: 'evolution-overview',
component: () => import('@/components/evolution/DashboardOverview.vue'),
meta: { title: '概览 - 自进化' },
path: 'workflow',
name: 'legacy-workflow',
component: () => import('@/views/WorkflowView.vue'),
meta: { title: '工作流' },
},
{
path: 'experiences',
name: 'evolution-experiences',
component: () => import('@/components/evolution/ExperiencePanel.vue'),
meta: { title: '经验记录 - 自进化' },
path: 'knowledge',
name: 'legacy-knowledge',
component: () => import('@/views/KnowledgeBaseView.vue'),
meta: { title: '知识库' },
},
{
path: 'metrics',
name: 'evolution-metrics',
component: () => import('@/components/evolution/MetricsPanel.vue'),
meta: { title: '指标趋势 - 自进化' },
path: 'skills',
name: 'legacy-skills',
component: () => import('@/views/SkillsView.vue'),
meta: { title: '技能' },
},
{
path: 'pitfalls',
name: 'evolution-pitfalls',
component: () => import('@/components/evolution/PitfallRoutePanel.vue'),
meta: { title: '避坑预警 - 自进化' },
path: 'terminal',
name: 'legacy-terminal',
component: () => import('@/views/TerminalView.vue'),
meta: { title: '终端' },
},
{
path: 'optimizations',
name: 'evolution-optimizations',
component: () => import('@/components/evolution/OptimizationPanel.vue'),
meta: { title: '路径优化 - 自进化' },
path: 'evolution',
name: 'legacy-evolution',
component: () => import('@/views/EvolutionView.vue'),
meta: { title: '自进化' },
},
{
path: 'usage',
name: 'evolution-usage',
component: () => import('@/components/evolution/UsagePanel.vue'),
meta: { title: '用量统计 - 自进化' },
path: 'settings',
name: 'legacy-settings',
component: () => import('@/views/SettingsView.vue'),
meta: { title: '设置' },
},
],
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
meta: { title: '设置' },
},
]
const router = createRouter({