fix(desktop): 补齐顶部段落菜单与浮动按钮布局
This commit is contained in:
@@ -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、无边框窗口控制和属性导入已进入 Alpha,Sidecar、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 capability,Web 模式不显示伪窗口按钮。
|
||||
- 前端:89 个文件、476 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。桌面标题栏窗口操作使用 Tauri capability,Web 模式不显示伪窗口按钮;标题栏“段落”菜单复用编辑命令能力门禁。AI 与文档滚动按钮改为同一底边横向错位,避免双滚动按钮出现时重叠。
|
||||
- Sync v1:18 项隔离测试通过,Windows / Python 3.13.9 / SQLite / 磁盘对象 Fixture。包括双线程并发 CAS;不是 PostgreSQL 多 Worker 压测。
|
||||
- Community v1:13 项隔离测试通过;前端使用 Python 生成的真实 Ed25519 向量互验。
|
||||
- Rust:系统 `rustc/cargo 1.98.1`(`x86_64-pc-windows-gnu`)下,7 项文件恢复及最近 Vault 授权测试、rustfmt、clippy `-D warnings` 和 Tauri desktop 开发构建通过。没有安装包或新标题栏实机 UI 证据。
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user