feat(frontend): 接通完整页面路由与桌面壳层
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user