feat(frontend): 接通完整页面路由与桌面壳层

This commit is contained in:
2026-08-29 22:49:53 +08:00
parent 5192a8b4e8
commit e5f803c364
10 changed files with 218 additions and 82 deletions
+2 -4
View File
@@ -4,12 +4,11 @@ import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useAgentStore } from '@/stores/agent'
import PrimarySidebar from './PrimarySidebar.vue'
import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue'
defineProps<{
showSecondarySidebar?: boolean
@@ -18,8 +17,6 @@ defineProps<{
const workspaceStore = useWorkspaceStore()
const themeStore = useThemeStore()
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const agentStore = useAgentStore()
const route = useRoute()
const router = useRouter()
@@ -58,6 +55,7 @@ defineExpose({ openCitation })
</main>
</div>
<StatusBar />
<CommandPalette />
</div>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { useWorkspaceStore } from '@/stores/workspace'
import * as workspaceService from '@/services/workspaceService'
const router = useRouter()
const editorStore = useEditorStore()
const themeStore = useThemeStore()
const workspaceStore = useWorkspaceStore()
const open = ref(false)
const query = ref('')
const input = ref<HTMLInputElement | null>(null)
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
const commands = computed<Command[]>(() => [
{ id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') },
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
{ id: 'agent', label: '创建 Agent Run', hint: '导航', run: () => router.push('/agent/runs') },
{ id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') },
{ id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() },
{ id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() },
{ id: 'theme', label: `切换为${themeStore.isDark ? '浅色' : '深色'}主题`, hint: '外观', run: () => themeStore.toggleTheme() },
{ id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote },
])
const filteredCommands = computed(() => {
const value = query.value.trim().toLocaleLowerCase()
return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value
})
function show() {
open.value = true
query.value = ''
void nextTick(() => input.value?.focus())
}
function hide() { open.value = false }
async function execute(command: Command | undefined) {
if (!command) return
hide()
await command.run()
}
async function createNote() {
const rawName = window.prompt('笔记名称')?.trim()
if (!rawName) return
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`)
workspaceStore.addFileToTree('/', file)
workspaceStore.openFile(file.path)
await editorStore.loadFile(file.path)
await router.push('/workspace')
}
function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
event.preventDefault()
open.value ? hide() : show()
} else if (event.key === 'Escape' && open.value) {
hide()
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</script>
<template>
<Teleport to="body">
<div v-if="open" class="command-backdrop" @click.self="hide">
<section class="command-palette" role="dialog" aria-modal="true" aria-label="命令面板">
<input ref="input" v-model="query" class="command-input" placeholder="输入命令…" @keydown.enter.prevent="execute(filteredCommands[0])" />
<div class="command-list">
<button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)">
<span>{{ command.label }}</span><small>{{ command.hint }}</small>
</button>
<p v-if="!filteredCommands.length">没有匹配的命令</p>
</div>
<footer><span>Enter 执行</span><span>Esc 关闭</span></footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.command-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: var(--color-background-overlay); }
.command-palette { width: min(600px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); }
.command-input { width: 100%; padding: var(--space-lg); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; font-size: var(--font-size-xl); }
.command-list { max-height: 360px; overflow: auto; padding: var(--space-sm); }
.command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md); border-radius: var(--radius-md); text-align: left; }
.command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
.command-list small, .command-list p, footer { color: var(--color-text-tertiary); }
.command-list p { padding: var(--space-xl); text-align: center; }
footer { display: flex; gap: var(--space-lg); padding: var(--space-sm) var(--space-lg); border-top: 1px solid var(--color-border-subtle); font-size: var(--font-size-xs); }
</style>
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { computed, ref } from 'vue'
const route = useRoute()
const router = useRouter()
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
const navItems = [
{ name: 'workspace', icon: '📁', label: '工作区' },
@@ -26,10 +27,15 @@ const currentName = computed(() => {
function navigate(name: string) {
router.push({ name })
}
function toggleExpanded() {
expanded.value = !expanded.value
localStorage.setItem('primary-sidebar-expanded', String(expanded.value))
}
</script>
<template>
<aside class="primary-sidebar">
<aside class="primary-sidebar" :class="{ expanded }">
<nav class="nav-list">
<div
v-for="item in navItems"
@@ -44,9 +50,10 @@ function navigate(name: string) {
</div>
</nav>
<div class="sidebar-footer">
<div class="nav-item" @click="navigate('settings')" title="设置">
<span class="nav-icon"></span>
</div>
<button class="nav-item collapse-button" type="button" :title="expanded ? '收起导航' : '展开导航'" @click="toggleExpanded">
<span class="nav-icon">{{ expanded ? '«' : '»' }}</span>
<span class="nav-label">{{ expanded ? '收起' : '展开' }}</span>
</button>
</div>
</aside>
</template>
@@ -62,6 +69,11 @@ function navigate(name: string) {
z-index: var(--z-sidebar);
}
.primary-sidebar.expanded { width: var(--sidebar-primary-width-expanded); }
.primary-sidebar.expanded .nav-item { flex-direction: row; justify-content: flex-start; gap: var(--space-md); padding: 0 var(--space-lg); }
.primary-sidebar.expanded .nav-icon { margin-bottom: 0; }
.primary-sidebar.expanded .nav-label { font-size: var(--font-size-sm); }
.nav-list {
flex: 1;
padding: var(--space-sm) 0;
@@ -121,4 +133,6 @@ function navigate(name: string) {
padding: var(--space-sm) 0;
border-top: 1px solid var(--color-border-subtle);
}
.collapse-button { width: calc(100% - 8px); }
</style>
@@ -1,6 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import FileTreePanel from '@/features/workspace/FileTreePanel.vue'
import ConversationListPanel from '@/features/chat/ConversationListPanel.vue'
import RunListPanel from '@/features/agent/RunListPanel.vue'
import SearchFiltersPanel from '@/features/search/SearchFiltersPanel.vue'
import TaskFiltersPanel from '@/features/tasks/TaskFiltersPanel.vue'
import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue'
import { useRoute } from 'vue-router'
const props = defineProps<{
@@ -36,7 +41,11 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
</div>
<div class="sidebar-content">
<FileTreePanel v-if="component === 'file-tree'" />
<p v-else class="sidebar-placeholder">该功能将在对应页面实现时补充</p>
<ConversationListPanel v-else-if="component === 'conversation-list'" />
<RunListPanel v-else-if="component === 'run-list'" />
<SearchFiltersPanel v-else-if="component === 'search-filters'" />
<TaskFiltersPanel v-else-if="component === 'task-filters'" />
<ExtensionListPanel v-else-if="component === 'extension-list'" />
</div>
</aside>
</template>
@@ -101,9 +110,4 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
overflow-x: hidden;
}
.sidebar-placeholder {
padding: var(--space-lg);
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
</style>
+2 -2
View File
@@ -75,8 +75,8 @@ const showEditorInfo = computed(() => route.name === 'workspace')
{{ saveStatusText }}
</span>
<span class="status-item" :title="indexStatusText">
<span class="status-dot" style="background: var(--color-success)" />
索引就绪
<span class="status-dot" :style="{ background: settingsStore.indexStatus.status === 'error' ? 'var(--color-error)' : settingsStore.indexStatus.status === 'indexing' ? 'var(--color-warning)' : 'var(--color-success)' }" />
{{ indexStatusText }}
</span>
<span class="status-item" :style="{ color: aiCoreColor }">
<span class="status-dot" :style="{ background: aiCoreColor }" />
@@ -1,34 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const title = computed(() => String(route.meta.title ?? '功能开发中'))
</script>
<template>
<section class="placeholder-view">
<div>
<h1>{{ title }}</h1>
<p>基础路由已经就绪具体页面将在后续功能开发中实现</p>
</div>
</section>
</template>
<style scoped>
.placeholder-view {
display: grid;
height: 100%;
place-items: center;
color: var(--color-text-secondary);
}
.placeholder-view div {
text-align: center;
}
.placeholder-view h1 {
margin: 0 0 var(--space-sm);
color: var(--color-text-primary);
}
</style>
+27 -2
View File
@@ -4,12 +4,24 @@ import { useWorkspaceStore } from '@/stores/workspace'
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
const statusText: Record<string, string> = {
idle: '空闲', dirty: '未保存', saving: '保存中…', saved: '已保存', save_failed: '保存失败',
external_changed: '外部文件已变化', conflict: '存在编辑冲突',
}
</script>
<template>
<header class="editor-header">
<strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong>
<span>{{ editorStore.saveStatus }}</span>
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions">
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
<div class="mode-switch" aria-label="编辑模式">
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">写作</button>
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">源码</button>
</div>
<button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">保存</button>
</div>
</header>
</template>
@@ -23,4 +35,17 @@ const workspaceStore = useWorkspaceStore()
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text-secondary);
}
.file-identity { display: grid; min-width: 0; }
.file-identity strong, .file-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-identity small { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.editor-actions, .mode-switch { display: flex; align-items: center; gap: var(--space-sm); }
.save-status { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.save-status.dirty, .save-status.external_changed { color: var(--color-warning); }
.save-status.save_failed, .save-status.conflict { color: var(--color-error); }
.save-status.saved { color: var(--color-success); }
.mode-switch { gap: 2px; padding: 2px; border-radius: var(--radius-md); background: var(--color-background-secondary); }
.mode-switch button, .save-button { padding: 5px 9px; border-radius: var(--radius-sm); }
.mode-switch button.active { background: var(--color-surface-primary); color: var(--color-accent-primary); box-shadow: var(--shadow-sm); }
.save-button { background: var(--color-accent-primary); color: var(--color-text-inverse); }
.save-button:disabled { opacity: .55; }
</style>
+7 -2
View File
@@ -5,10 +5,12 @@ const editorStore = useEditorStore()
</script>
<template>
<textarea
<textarea
class="editor-pane"
:class="editorStore.mode"
:value="editorStore.content"
spellcheck="false"
:spellcheck="editorStore.mode === 'wysiwyg'"
:aria-label="editorStore.mode === 'source' ? 'Markdown 源码编辑器' : 'Markdown 写作编辑器'"
@input="editorStore.updateContent(($event.target as HTMLTextAreaElement).value); editorStore.scheduleAutoSave()"
/>
</template>
@@ -26,5 +28,8 @@ const editorStore = useEditorStore()
color: var(--color-text-primary);
font: inherit;
line-height: 1.7;
user-select: text;
}
.editor-pane.wysiwyg { max-width: 920px; margin: 0 auto; padding: var(--space-3xl) clamp(var(--space-xl), 8vw, 80px); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
.editor-pane.source { font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
</style>
+8 -9
View File
@@ -1,6 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import PlaceholderView from '@/features/common/PlaceholderView.vue'
const routes = [
{
@@ -18,49 +17,49 @@ const routes = [
{
path: '/search',
name: 'search',
component: PlaceholderView,
component: () => import('@/features/search/SearchView.vue'),
meta: { title: '搜索', requiresVault: true },
},
{
path: '/chat',
name: 'chat',
component: PlaceholderView,
component: () => import('@/features/chat/ChatView.vue'),
meta: { title: 'AI 对话', requiresVault: true },
},
{
path: '/agent/runs/:runId?',
name: 'agent',
component: PlaceholderView,
component: () => import('@/features/agent/AgentView.vue'),
meta: { title: 'Agent Trace', requiresVault: true },
},
{
path: '/tasks',
name: 'tasks',
component: PlaceholderView,
component: () => import('@/features/tasks/TasksView.vue'),
meta: { title: '任务', requiresVault: true },
},
{
path: '/extensions/skills',
name: 'skills',
component: PlaceholderView,
component: () => import('@/features/skills/SkillsView.vue'),
meta: { title: 'Skill 管理', requiresVault: true },
},
{
path: '/extensions/plugins',
name: 'plugins',
component: PlaceholderView,
component: () => import('@/features/plugins/PluginsView.vue'),
meta: { title: 'Plugin 管理', requiresVault: true },
},
{
path: '/themes',
name: 'themes',
component: PlaceholderView,
component: () => import('@/features/themes/ThemesView.vue'),
meta: { title: '主题管理', requiresVault: true },
},
{
path: '/settings',
name: 'settings',
component: PlaceholderView,
component: () => import('@/features/settings/SettingsView.vue'),
meta: { title: '设置', requiresVault: true },
},
]