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
+42 -18
View File
@@ -1,12 +1,12 @@
# 前端壳子与接口层开发说明 # 前端壳子与接口层开发说明
> 更新日期:2026-08-29 > 更新日期:2026-08-29
> 适用范围:Vue 3 + TypeScript 前端壳子、Workspace、公共 Service、FastAPI 接口适配和 SSE。 > 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。
> 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。 > 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。
## 1. 当前实现状态 ## 1. 当前实现状态
本轮修复后,前端已经形成一条可安装、可类型检查、可生产构建的基础链路: 当前前端已经形成一条可安装、可类型检查、可生产构建和可联调的基础链路:
```text ```text
Vue Router Vue Router
@@ -17,25 +17,32 @@ Vue Router
→ FastAPI → FastAPI
``` ```
当前可以使用的界面包括: 当前已经落地的页面和公共界面包括:
- Vault 入口页; - Vault 入口页;
- 应用标题栏、主侧边栏、辅助侧边栏和状态栏; - 应用标题栏、主侧边栏、辅助侧边栏和状态栏;
- Workspace 文件树; - Workspace 文件树;
- 基础 Markdown 文本编辑区 - Markdown 写作/源码模式、手动保存和自动保存状态
- 文件打开、新建、删除和重命名交互壳子; - 文件打开、新建、删除和重命名交互壳子;
- 未完成模块的统一占位页。 - Search 查询、筛选、结果列表和 Citation 定位;
- Chat 会话、Provider/Model/Skill 选择和 SSE 输出;
- Agent Run 创建、Trace、取消和权限确认;
- Task 筛选、创建、编辑、状态切换和删除;
- Skill、Plugin 生命周期管理;
- Theme 预览、切换和编辑器 Token 覆盖;
- Settings 的通用、编辑器、Provider、索引、权限和 AI Core 诊断分区;
- 可收起主导航、功能型二级侧栏、状态栏和 `Ctrl+P` 命令面板。
Search、Chat、Agent、Task、Skill、Plugin、Theme 和 Settings 已保留稳定路由,但除公共 Store 与 Service 外,具体业务页面仍待后续实现。占位页用于保证主分支可构建、导航目标可识别,不代表对应功能页面已经验收 原统一占位页已经删除,所有已注册业务路由均指向真实页面。当前 Workspace 文件能力仍使用 Web Mock AdapterTauri 文件系统、Stronghold 和桌面窗口能力应在桌面容器阶段接入,不影响页面与 Store 的调用边界
## 2. 目录与职责 ## 2. 目录与职责
```text ```text
frontend/src/ frontend/src/
├── components/common/ App Shell 与公共导航组件 ├── components/common/ App Shell、导航、命令面板与扩展公共组件
├── contracts/index.ts UI View Model 与 FastAPI Wire DTO ├── contracts/index.ts UI View Model 与 FastAPI Wire DTO
├── features/common/ 未实现功能的统一占位页 ├── features/ 按页面领域拆分的业务组件
├── features/editor/ 基础编辑器头部与文本编辑区 ├── features/editor/ 编辑器头部与写作/源码编辑区
├── features/vault/ Vault 入口 ├── features/vault/ Vault 入口
├── features/workspace/ Workspace 与递归文件树 ├── features/workspace/ Workspace 与递归文件树
├── router/index.ts 页面路由和 Vault Guard ├── router/index.ts 页面路由和 Vault Guard
@@ -69,9 +76,23 @@ frontend/src/
/settings /settings
``` ```
除 Vault 入口外,其余路由需要先打开 Vault。尚未完成的页面统一加载 `PlaceholderView.vue`,后续开发时应逐个替换为真实页面组件,不要在路由中提前引用尚未提交的文件 除 Vault 入口外,其余路由需要先打开 Vault。全部路由均使用懒加载真实页面组件,既保持首屏包体可控,也避免占位页面掩盖缺失实现
## 4. Workspace 与编辑器 ## 4. 页面实现边界
| 页面 | 当前可用能力 | 主要 Store / Service |
| --- | --- | --- |
| Workspace | 文件树、新建、重命名、删除、打开、编辑、保存、模式切换 | `workspaceStore``editorStore``workspaceService` |
| Search | FTS/Vector/Hybrid、文件夹与标签筛选、结果定位 | `searchStore``searchService` |
| Chat | 会话选择、模型配置、RAG、Skill、SSE、Citation | `chatStore``providerStore``chatService` |
| Agent | Run 配置、Tool 选择、Trace SSE、权限确认、取消 | `agentStore``agentService` |
| Tasks | 状态筛选、CRUD、完成与恢复 | `taskStore``taskService` |
| Skills | 列表、详情、安装、启停、卸载 | `skillStore``skillService` |
| Plugins | 列表、权限确认、安装、启停、卸载 | `pluginStore``pluginService` |
| Themes | 主题预览、应用、字体与行高覆盖、恢复默认 | `themeStore` |
| Settings | 通用、编辑器、Provider、索引、权限、诊断 | `settingsStore``providerStore`、相关 Service |
## 5. Workspace 与编辑器
Workspace 当前由以下组件构成: Workspace 当前由以下组件构成:
@@ -89,7 +110,7 @@ SecondarySidebar
当前 `workspaceService` 仍是 Web 开发模式下的 Mock Adapter。保存、重命名和删除只保留调用边界,尚未接入 Tauri 文件系统命令。进入桌面端阶段后,应替换 Service 内部实现,不改变 Component 和 Store 的调用方式。 当前 `workspaceService` 仍是 Web 开发模式下的 Mock Adapter。保存、重命名和删除只保留调用边界,尚未接入 Tauri 文件系统命令。进入桌面端阶段后,应替换 Service 内部实现,不改变 Component 和 Store 的调用方式。
## 5. HTTP 接口层 ## 6. HTTP 接口层
公共请求由 `apiClient.ts` 处理: 公共请求由 `apiClient.ts` 处理:
@@ -115,7 +136,7 @@ Service 已适配当前 FastAPI Contract
界面模型中存在的展示字段不能直接发送给后端。例如 Task View Model 的 `priority``source` 当前只是界面层字段,Service 创建与更新请求不会把它们发送给不支持这些字段的 FastAPI Contract。 界面模型中存在的展示字段不能直接发送给后端。例如 Task View Model 的 `priority``source` 当前只是界面层字段,Service 创建与更新请求不会把它们发送给不支持这些字段的 FastAPI Contract。
## 6. SSE ## 7. SSE
`SseClient` 同时服务于 Chat 和 Agent Event `SseClient` 同时服务于 Chat 和 Agent Event
@@ -134,7 +155,7 @@ provider_id = mock
model = mock-1 model = mock-1
``` ```
## 7. 环境和启动 ## 8. 环境和启动
```powershell ```powershell
cd frontend cd frontend
@@ -156,7 +177,7 @@ cd frontend
pnpm build pnpm build
``` ```
## 8. 当前验证基线 ## 9. 当前验证基线
```text ```text
pnpm build passed pnpm build passed
@@ -165,13 +186,16 @@ preview smoke HTTP 200
git diff --check passed git diff --check passed
``` ```
后端测试出现过一次 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 62 项测试结果,也不涉及产品代码。 当前前端没有单独的单元测试脚本,`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过一次 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 62 项测试结果,也不涉及产品代码。
## 9. 后续开发要求 浏览器可视化冒烟在本次执行环境中因浏览器运行资源缺失未能启动;HTTP 冒烟已确认前端入口、后端健康检查与 OpenAPI 均能访问。进入合并验收前,仍建议团队在本机打开各路由完成一次人工视觉检查。
## 10. 后续开发要求
- 新页面文件与路由修改必须在同一提交中出现; - 新页面文件与路由修改必须在同一提交中出现;
- 新增或修改接口时同步更新 FastAPI DTO、Service 映射和接口文档; - 新增或修改接口时同步更新 FastAPI DTO、Service 映射和接口文档;
- 不允许用 `as any` 或错误返回类型掩盖 Contract 差异; - 不允许用 `as any` 或错误返回类型掩盖 Contract 差异;
- SSE 相关变更需要覆盖跨 Chunk、CRLF、多行 data、终态事件和取消; - SSE 相关变更需要覆盖跨 Chunk、CRLF、多行 data、终态事件和取消;
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试; - Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
- Search、Agent、Skill、Plugin 等占位页应按功能逐个替换,不一次提交大量空页面。 - 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
- Workspace 的写作/源码模式目前共享同一 Markdown 数据源,后续接入 Milkdown 与 CodeMirror 6 时不得改变 Store/Service 边界或造成切换丢稿。
+2 -4
View File
@@ -4,12 +4,11 @@ import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useAgentStore } from '@/stores/agent'
import PrimarySidebar from './PrimarySidebar.vue' import PrimarySidebar from './PrimarySidebar.vue'
import SecondarySidebar from './SecondarySidebar.vue' import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue' import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue' import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue'
defineProps<{ defineProps<{
showSecondarySidebar?: boolean showSecondarySidebar?: boolean
@@ -18,8 +17,6 @@ defineProps<{
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const agentStore = useAgentStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -58,6 +55,7 @@ defineExpose({ openCitation })
</main> </main>
</div> </div>
<StatusBar /> <StatusBar />
<CommandPalette />
</div> </div>
</template> </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"> <script setup lang="ts">
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue' import { computed, ref } from 'vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
const navItems = [ const navItems = [
{ name: 'workspace', icon: '📁', label: '工作区' }, { name: 'workspace', icon: '📁', label: '工作区' },
@@ -26,10 +27,15 @@ const currentName = computed(() => {
function navigate(name: string) { function navigate(name: string) {
router.push({ name }) router.push({ name })
} }
function toggleExpanded() {
expanded.value = !expanded.value
localStorage.setItem('primary-sidebar-expanded', String(expanded.value))
}
</script> </script>
<template> <template>
<aside class="primary-sidebar"> <aside class="primary-sidebar" :class="{ expanded }">
<nav class="nav-list"> <nav class="nav-list">
<div <div
v-for="item in navItems" v-for="item in navItems"
@@ -44,9 +50,10 @@ function navigate(name: string) {
</div> </div>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="nav-item" @click="navigate('settings')" title="设置"> <button class="nav-item collapse-button" type="button" :title="expanded ? '收起导航' : '展开导航'" @click="toggleExpanded">
<span class="nav-icon"></span> <span class="nav-icon">{{ expanded ? '«' : '»' }}</span>
</div> <span class="nav-label">{{ expanded ? '收起' : '展开' }}</span>
</button>
</div> </div>
</aside> </aside>
</template> </template>
@@ -62,6 +69,11 @@ function navigate(name: string) {
z-index: var(--z-sidebar); 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 { .nav-list {
flex: 1; flex: 1;
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
@@ -121,4 +133,6 @@ function navigate(name: string) {
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
border-top: 1px solid var(--color-border-subtle); border-top: 1px solid var(--color-border-subtle);
} }
.collapse-button { width: calc(100% - 8px); }
</style> </style>
@@ -1,6 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import FileTreePanel from '@/features/workspace/FileTreePanel.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' import { useRoute } from 'vue-router'
const props = defineProps<{ const props = defineProps<{
@@ -36,7 +41,11 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
<FileTreePanel v-if="component === 'file-tree'" /> <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> </div>
</aside> </aside>
</template> </template>
@@ -101,9 +110,4 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
overflow-x: hidden; overflow-x: hidden;
} }
.sidebar-placeholder {
padding: var(--space-lg);
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
</style> </style>
+2 -2
View File
@@ -75,8 +75,8 @@ const showEditorInfo = computed(() => route.name === 'workspace')
{{ saveStatusText }} {{ saveStatusText }}
</span> </span>
<span class="status-item" :title="indexStatusText"> <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>
<span class="status-item" :style="{ color: aiCoreColor }"> <span class="status-item" :style="{ color: aiCoreColor }">
<span class="status-dot" :style="{ background: 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 editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const statusText: Record<string, string> = {
idle: '空闲', dirty: '未保存', saving: '保存中…', saved: '已保存', save_failed: '保存失败',
external_changed: '外部文件已变化', conflict: '存在编辑冲突',
}
</script> </script>
<template> <template>
<header class="editor-header"> <header class="editor-header">
<strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong> <div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<span>{{ editorStore.saveStatus }}</span> <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> </header>
</template> </template>
@@ -23,4 +35,17 @@ const workspaceStore = useWorkspaceStore()
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text-secondary); 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> </style>
+6 -1
View File
@@ -7,8 +7,10 @@ const editorStore = useEditorStore()
<template> <template>
<textarea <textarea
class="editor-pane" class="editor-pane"
:class="editorStore.mode"
:value="editorStore.content" :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()" @input="editorStore.updateContent(($event.target as HTMLTextAreaElement).value); editorStore.scheduleAutoSave()"
/> />
</template> </template>
@@ -26,5 +28,8 @@ const editorStore = useEditorStore()
color: var(--color-text-primary); color: var(--color-text-primary);
font: inherit; font: inherit;
line-height: 1.7; 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> </style>
+8 -9
View File
@@ -1,6 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import PlaceholderView from '@/features/common/PlaceholderView.vue'
const routes = [ const routes = [
{ {
@@ -18,49 +17,49 @@ const routes = [
{ {
path: '/search', path: '/search',
name: 'search', name: 'search',
component: PlaceholderView, component: () => import('@/features/search/SearchView.vue'),
meta: { title: '搜索', requiresVault: true }, meta: { title: '搜索', requiresVault: true },
}, },
{ {
path: '/chat', path: '/chat',
name: 'chat', name: 'chat',
component: PlaceholderView, component: () => import('@/features/chat/ChatView.vue'),
meta: { title: 'AI 对话', requiresVault: true }, meta: { title: 'AI 对话', requiresVault: true },
}, },
{ {
path: '/agent/runs/:runId?', path: '/agent/runs/:runId?',
name: 'agent', name: 'agent',
component: PlaceholderView, component: () => import('@/features/agent/AgentView.vue'),
meta: { title: 'Agent Trace', requiresVault: true }, meta: { title: 'Agent Trace', requiresVault: true },
}, },
{ {
path: '/tasks', path: '/tasks',
name: 'tasks', name: 'tasks',
component: PlaceholderView, component: () => import('@/features/tasks/TasksView.vue'),
meta: { title: '任务', requiresVault: true }, meta: { title: '任务', requiresVault: true },
}, },
{ {
path: '/extensions/skills', path: '/extensions/skills',
name: 'skills', name: 'skills',
component: PlaceholderView, component: () => import('@/features/skills/SkillsView.vue'),
meta: { title: 'Skill 管理', requiresVault: true }, meta: { title: 'Skill 管理', requiresVault: true },
}, },
{ {
path: '/extensions/plugins', path: '/extensions/plugins',
name: 'plugins', name: 'plugins',
component: PlaceholderView, component: () => import('@/features/plugins/PluginsView.vue'),
meta: { title: 'Plugin 管理', requiresVault: true }, meta: { title: 'Plugin 管理', requiresVault: true },
}, },
{ {
path: '/themes', path: '/themes',
name: 'themes', name: 'themes',
component: PlaceholderView, component: () => import('@/features/themes/ThemesView.vue'),
meta: { title: '主题管理', requiresVault: true }, meta: { title: '主题管理', requiresVault: true },
}, },
{ {
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',
component: PlaceholderView, component: () => import('@/features/settings/SettingsView.vue'),
meta: { title: '设置', requiresVault: true }, meta: { title: '设置', requiresVault: true },
}, },
] ]