feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
8 changed files with 118 additions and 7 deletions
Showing only changes of commit a189b89942 - Show all commits
+1 -1
View File
@@ -34,6 +34,6 @@ OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vau
## 前端与发布
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;AI Core 未接通时明确返回 CORE_UNAVAILABLE。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。原生“导入为笔记属性”菜单仅在源码模式已打开笔记时启用,转换作为单次编辑器历史事务执行。
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;AI Core 未接通时明确返回 CORE_UNAVAILABLE。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。原生菜单事件和标题栏可见的“段落”菜单复用 `editor.import-note-properties`仅在源码模式已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。
`bundle.active=false`,无自动更新、无签名安装包。开发命令为前端 `pnpm dev``cargo run --features desktop`;前端生产构建后可 `cargo build --features desktop --release`,仍只构建预览程序。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。
@@ -1,6 +1,6 @@
# Tauri / Rust 桌面客户端需求说明(第三阶段)
状态:需求预留,尚未实现桌面客户端。本文不表示已有可调用的 Tauri Command 或可发布安装包。
状态:需求基线;Rust Host、原生 Vault、无边框窗口控制和属性导入已进入 AlphaSidecar、Stronghold、多窗口及发布包仍未完成。本文不表示已有可发布安装包。
基线日期:2026-09-06。第三阶段完整范围与实施顺序见[第三阶段实施规划](../architecture/第三阶段实施规划.md)。
@@ -21,7 +21,7 @@
桌面客户端顶部菜单栏的 **段落 → 导入为笔记属性…** 预留元数据格式导入功能,与标题、正文、列表等段落操作归组。它处理笔记内容中的元数据,不是主题包安装入口。
稳定的前端命令标识为 `editor.import-note-properties`,已进入前端 v1 命令目录,但属性转换处理器Tauri IPC 尚未实现。原生菜单和编辑器命令面板应分发同一命令,避免两套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。
稳定的前端命令标识为 `editor.import-note-properties`属性转换处理器Tauri 原生菜单事件和无边框窗口内的可见“段落”菜单均分发同一命令,没有维护第二套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。
Markdown 格式(含警告框)与元数据共用 `editorCommandService` 的能力查询和命令分发接口,详见 [警告框与桌面编辑命令开发说明](../development/警告框与桌面编辑命令开发说明.md)。Host 根据 supported / enabled 显隐或禁用菜单,不能把已预留的命令 ID 当作已可执行能力;原生快捷键不绕过活动文档、只读和冲突检查。
@@ -41,7 +41,7 @@ Markdown 格式(含警告框)与元数据共用 `editorCommandService` 的
- 转换作用于当前内存文档,不先从磁盘读取旧内容覆盖未保存编辑。操作绑定文件标识和文档版本,异步处理期间切换文件或继续编辑时,应取消或重新校验。
- 成功后进入现有脏状态和自动保存流程。磁盘保存失败显示可重试状态,撤销/重做同时恢复正文、属性及标签。
- 属性块不进入正文大纲;标题跳转、引用定位仍使用完整原文件的正确偏移。写作/源码切换、保存后重开不得改变属性语义。
- 当前分支的 `frontend/src/features/editor/noteMetadata.ts` 仅是简单属性块展示与标签编辑基础;桌面阶段需补齐完整解析、合并冲突、单事务撤销和原生菜单分发,不能直接视为本节已经验收
- 当前 Alpha 已覆盖完整解析、合并冲突、单事务撤销和菜单分发的自动测试;写作模式属性面板、重开后的标签检索、缩放和真实 WebView 操作仍须按本节验收,不能仅凭单元测试视为全部通过
## 3. 桌面基础需求
@@ -17,7 +17,7 @@
## 已执行验证
- 后端:887 项 pytest 通过;新增 Web/桌面单写入者门禁测试。
- 前端:88 个文件、474 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。桌面标题栏窗口操作使用 Tauri capabilityWeb 模式不显示伪窗口按钮。
- 前端:89 个文件、476 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。桌面标题栏窗口操作使用 Tauri capabilityWeb 模式不显示伪窗口按钮;标题栏“段落”菜单复用编辑命令能力门禁。AI 与文档滚动按钮改为同一底边横向错位,避免双滚动按钮出现时重叠
- Sync v118 项隔离测试通过,Windows / Python 3.13.9 / SQLite / 磁盘对象 Fixture。包括双线程并发 CAS;不是 PostgreSQL 多 Worker 压测。
- Community v113 项隔离测试通过;前端使用 Python 生成的真实 Ed25519 向量互验。
- Rust:系统 `rustc/cargo 1.98.1``x86_64-pc-windows-gnu`)下,7 项文件恢复及最近 Vault 授权测试、rustfmt、clippy `-D warnings` 和 Tauri desktop 开发构建通过。没有安装包或新标题栏实机 UI 证据。
+1 -1
View File
@@ -36,7 +36,7 @@
| Security | 本地主密钥目前保存在数据目录;Rust Vault 文件服务已接入,Stronghold 迁移尚未完成 |
| Extension | 安装状态已持久化、社区发行签名原型已接入;生产 OS 隔离和桌面 Extension Manager 尚未完成 |
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
| Desktop | Tauri IPC 已支持单个原生 Vault 选择、版本保存和恢复;最近列表、多窗口、监听、Sidecar 与同步循环尚未完成 |
| Desktop | Tauri IPC 已支持原生 Vault 选择、最近授权重开、版本保存和恢复;多窗口、监听、Sidecar 与同步循环尚未完成 |
| Editor / Chat | 待补文件冲突合并及受控链接对话框;会话持久化已接入后端 SQLite |
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
@@ -6,6 +6,7 @@ import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
import TitleBarMenu from './TitleBarMenu.vue'
import { t } from '@/i18n'
import { isDesktop } from '@/services/platform/desktop'
import { minimizeWindow, requestWindowClose, toggleMaximizeWindow } from '@/services/platform/windowControls'
@@ -61,6 +62,7 @@ function toggleFromTitlebar(event: MouseEvent) {
{{ currentFileName }}
<span v-if="isDirty" class="dirty-dot" />
</span>
<TitleBarMenu />
</div>
<div class="titlebar-center" data-tauri-drag-region>
<span class="app-name" data-tauri-drag-region>NotesAgent</span>
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useEditorStore } from '@/stores/editor'
import TitleBarMenu from './TitleBarMenu.vue'
const execute = vi.hoisted(() => vi.fn())
vi.mock('@/services/editorCommandService', () => ({ executeEditorCommand: execute }))
beforeEach(() => {
setActivePinia(createPinia())
execute.mockReset().mockResolvedValue({ ok: true })
})
describe('桌面顶部段落菜单', () => {
it('源码笔记通过统一编辑命令执行属性导入', async () => {
const editor = useEditorStore()
editor.mode = 'source'
editor.currentFilePath = '/示例.md'
editor.saveStatus = 'saved'
const wrapper = mount(TitleBarMenu)
await wrapper.get('.menu-trigger').trigger('click')
const item = wrapper.get('[role="menuitem"]')
expect((item.element as HTMLButtonElement).disabled).toBe(false)
await item.trigger('click')
expect(execute).toHaveBeenCalledWith('editor.import-note-properties')
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
wrapper.unmount()
})
it('写作模式显示入口但禁止绕过编辑器能力', async () => {
const editor = useEditorStore()
editor.mode = 'wysiwyg'
editor.currentFilePath = '/示例.md'
const wrapper = mount(TitleBarMenu)
await wrapper.get('.menu-trigger').trigger('click')
expect((wrapper.get('[role="menuitem"]').element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.text()).toContain('请在无冲突的 Markdown 源码笔记中使用')
wrapper.unmount()
})
})
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand } from '@/services/editorCommandService'
import { t } from '@/i18n'
const editor = useEditorStore()
const open = ref(false)
const menu = ref<HTMLElement | null>(null)
const trigger = ref<HTMLButtonElement | null>(null)
const importItem = ref<HTMLButtonElement | null>(null)
const error = ref('')
const importEnabled = computed(() => editor.mode === 'source' && !!editor.currentFilePath
&& !['conflict', 'external_changed'].includes(editor.saveStatus))
function dismiss(event: PointerEvent) {
if (!menu.value?.contains(event.target as Node)) open.value = false
}
async function showAndFocus() {
open.value = true
await nextTick()
importItem.value?.focus()
}
function closeAndFocus() {
open.value = false
trigger.value?.focus()
}
async function importProperties() {
if (!importEnabled.value) return
const result = await executeEditorCommand('editor.import-note-properties')
if (!result.ok) error.value = t('当前编辑器无法执行属性导入。', 'Property import is unavailable in the current editor.')
else { error.value = ''; open.value = false }
}
onMounted(() => window.addEventListener('pointerdown', dismiss))
onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
</script>
<template>
<div ref="menu" class="titlebar-menu">
<button ref="trigger" type="button" class="menu-trigger" aria-haspopup="menu" :aria-expanded="open" @click="open ? closeAndFocus() : showAndFocus()" @keydown.down.prevent="showAndFocus">
{{ t('段落', 'Paragraph') }}
</button>
<div v-if="open" class="menu-popover" role="menu" @keydown.esc.stop.prevent="closeAndFocus">
<button ref="importItem" type="button" role="menuitem" :disabled="!importEnabled" @click="importProperties">
{{ t('导入为笔记属性', 'Import as note properties') }}
</button>
<small v-if="!importEnabled">{{ t('请在无冲突的 Markdown 源码笔记中使用', 'Available in a conflict-free Markdown source note') }}</small>
<small v-if="error" role="alert">{{ error }}</small>
</div>
</div>
</template>
<style scoped>
.titlebar-menu { position: relative; flex: 0 0 auto; -webkit-app-region: no-drag; }
.menu-trigger { min-height: 28px; padding: 3px 10px; border: 1px solid transparent; border-radius: var(--radius-sm); color: var(--color-text-secondary); }
.menu-trigger:hover, .menu-trigger[aria-expanded='true'] { border-color: var(--color-border-default); background: var(--color-background-hover); color: var(--color-text-primary); }
.menu-popover { position: absolute; top: calc(100% + 5px); left: 0; z-index: calc(var(--z-titlebar) + 2); display: grid; min-width: 220px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-elevated); box-shadow: var(--shadow-lg); }
.menu-popover button { width: 100%; padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); text-align: left; white-space: nowrap; }
.menu-popover button:hover:not(:disabled), .menu-popover button:focus-visible { background: var(--color-accent-soft); color: var(--color-accent-primary); }
.menu-popover button:disabled { opacity: .5; cursor: not-allowed; }
.menu-popover small { padding: var(--space-xs) var(--space-md); color: var(--color-text-tertiary); white-space: normal; }
.menu-popover small[role='alert'] { color: var(--color-error); }
</style>
@@ -33,7 +33,7 @@ const workspaceStore = useWorkspaceStore()
</template>
<style scoped>
.workspace-chat-launcher { position: absolute; right: 24px; bottom: 76px; z-index: 11; width: 42px; height: 42px; border-radius: var(--radius-full); background: var(--color-editor-scroll-background); color: var(--color-editor-scroll-text); box-shadow: var(--shadow-sm); }
.workspace-chat-launcher { position: absolute; right: 72px; bottom: 24px; z-index: 11; width: 42px; height: 42px; border-radius: var(--radius-full); background: var(--color-editor-scroll-background); color: var(--color-editor-scroll-text); box-shadow: var(--shadow-sm); }
.workspace-view {
position: relative;