feat(desktop): 接通无边框窗口标题栏控制
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Rust Host v1 预览契约
|
||||
|
||||
状态:Rust 核心测试、clippy、Tauri 工程编译及 Windows GNU 开发可执行文件构建通过。**尚无可发布安装包,未完成原生界面实测**。不将开发构建等同 D01–D08 全部通过。
|
||||
状态:Rust 核心测试、clippy、Tauri 工程编译及 Windows GNU 开发可执行文件构建通过。**尚无可发布安装包,新标题栏仍待原生界面实测**。不将开发构建等同 D01–D08 全部通过。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -33,6 +33,6 @@ OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vau
|
||||
|
||||
## 前端与发布
|
||||
|
||||
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;AI Core 未接通时明确返回 CORE_UNAVAILABLE。关闭请求先尝试保存;冲突或保存失败时保持窗口。原生“导入为笔记属性”菜单已创建但禁用,转换事务尚未完成。
|
||||
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;AI Core 未接通时明确返回 CORE_UNAVAILABLE。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。原生“导入为笔记属性”菜单仅在源码模式和已打开笔记时启用,转换作为单次编辑器历史事务执行。
|
||||
|
||||
`bundle.active=false`,无自动更新、无签名安装包。开发命令为前端 `pnpm dev` 与 `cargo run --features desktop`;前端生产构建后可 `cargo build --features desktop --release`,仍只构建预览程序。需要对应平台 C++ 工具链和 WebView;本次 Windows 使用工作树内隔离 GNU/LLVM 工具链,不替代官方 MSVC 或其他平台构建。
|
||||
`bundle.active=false`,无自动更新、无签名安装包。开发命令为前端 `pnpm dev` 与 `cargo run --features desktop`;前端生产构建后可 `cargo build --features desktop --release`,仍只构建预览程序。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
## 已执行验证
|
||||
|
||||
- 后端:887 项 pytest 通过;新增 Web/桌面单写入者门禁测试。
|
||||
- 前端:87 个文件、471 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。
|
||||
- 前端:88 个文件、473 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。桌面标题栏窗口操作使用 Tauri capability,Web 模式不显示伪窗口按钮。
|
||||
- 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`)下,5 项文件恢复测试、rustfmt、clippy `-D warnings` 和 Tauri desktop 开发构建通过。没有安装包或实机 UI 证据。
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"allow-workspace-recent",
|
||||
"allow-workspace-revoke",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-start-dragging",
|
||||
"allow-editor-capabilities"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1200, "height": 800, "minWidth": 640, "minHeight": 480}],
|
||||
"windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1200, "height": 800, "minWidth": 640, "minHeight": 480, "decorations": false}],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"capabilities": ["main"]
|
||||
|
||||
+14
-2
@@ -2,17 +2,29 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppShell from '@/components/common/AppShell.vue'
|
||||
import TitleBar from '@/components/common/TitleBar.vue'
|
||||
import { isDesktop } from '@/services/platform/desktop'
|
||||
|
||||
const route = useRoute()
|
||||
const isVaultEntry = computed(() => route.path === '/')
|
||||
const desktop = isDesktop()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell v-if="!isVaultEntry">
|
||||
<div v-if="isVaultEntry" class="entry-shell">
|
||||
<TitleBar v-if="desktop" />
|
||||
<router-view />
|
||||
</div>
|
||||
<AppShell v-else>
|
||||
<router-view />
|
||||
</AppShell>
|
||||
<router-view v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entry-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,14 @@ import { useThemeStore } from '@/stores/theme'
|
||||
import { Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { isDesktop } from '@/services/platform/desktop'
|
||||
import { minimizeWindow, requestWindowClose, toggleMaximizeWindow } from '@/services/platform/windowControls'
|
||||
|
||||
const route = useRoute()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
const themeStore = useThemeStore()
|
||||
const desktop = isDesktop()
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const name = route.name as string
|
||||
@@ -25,6 +28,11 @@ const pageTitle = computed(() => {
|
||||
plugins: t('Plugin 与 MCP', 'Plugins and MCP'),
|
||||
themes: t('主题管理', 'Theme Management'),
|
||||
settings: t('设置', 'Settings'),
|
||||
'vault-entry': t('选择知识库', 'Select Knowledge Base'),
|
||||
community: t('社区目录', 'Community Catalog'),
|
||||
benchmarks: 'Benchmark',
|
||||
logs: t('运行日志', 'Operation Logs'),
|
||||
media: t('音视频转写', 'Media Transcription'),
|
||||
}
|
||||
return titles[name] || 'NotesAgent'
|
||||
})
|
||||
@@ -38,32 +46,36 @@ const currentFileName = computed(() => {
|
||||
})
|
||||
|
||||
const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore.saveStatus === 'conflict')
|
||||
|
||||
function toggleFromTitlebar(event: MouseEvent) {
|
||||
if (desktop && !(event.target as HTMLElement).closest('button')) void toggleMaximizeWindow()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="titlebar">
|
||||
<div class="titlebar-left">
|
||||
<span class="vault-name">{{ workspaceStore.vaultName }}</span>
|
||||
<span class="title-separator">/</span>
|
||||
<span class="file-name" :class="{ dirty: isDirty }">
|
||||
<header class="titlebar" :class="{ desktop }" data-tauri-drag-region @dblclick="toggleFromTitlebar">
|
||||
<div class="titlebar-left" data-tauri-drag-region>
|
||||
<span v-if="workspaceStore.vaultName" class="vault-name" data-tauri-drag-region>{{ workspaceStore.vaultName }}</span>
|
||||
<span v-if="workspaceStore.vaultName" class="title-separator" data-tauri-drag-region>/</span>
|
||||
<span class="file-name" :class="{ dirty: isDirty }" data-tauri-drag-region>
|
||||
{{ currentFileName }}
|
||||
<span v-if="isDirty" class="dirty-dot" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="titlebar-center">
|
||||
<span class="app-name">NotesAgent</span>
|
||||
<div class="titlebar-center" data-tauri-drag-region>
|
||||
<span class="app-name" data-tauri-drag-region>NotesAgent</span>
|
||||
</div>
|
||||
<div class="titlebar-right">
|
||||
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')">
|
||||
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="16" />
|
||||
</button>
|
||||
<div class="window-controls">
|
||||
<span class="win-btn minimize">—</span>
|
||||
<span class="win-btn maximize">▢</span>
|
||||
<span class="win-btn close">✕</span>
|
||||
<div v-if="desktop" class="window-controls">
|
||||
<button class="win-btn minimize" type="button" :aria-label="t('最小化窗口', 'Minimize window')" @click="minimizeWindow">—</button>
|
||||
<button class="win-btn maximize" type="button" :aria-label="t('最大化或还原窗口', 'Maximize or restore window')" @click="toggleMaximizeWindow">▢</button>
|
||||
<button class="win-btn close" type="button" :aria-label="t('关闭窗口', 'Close window')" @click="requestWindowClose">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -187,6 +199,9 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
transition: background-color var(--motion-fast), color var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -122,7 +122,8 @@ async function openFolderPicker() {
|
||||
|
||||
<style scoped>
|
||||
.vault-entry {
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const native = vi.hoisted(() => ({
|
||||
enabled: false,
|
||||
minimize: vi.fn(),
|
||||
toggleMaximize: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => native.enabled, invoke: vi.fn() }))
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({
|
||||
minimize: native.minimize,
|
||||
toggleMaximize: native.toggleMaximize,
|
||||
close: native.close,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { minimizeWindow, requestWindowClose, toggleMaximizeWindow } from './windowControls'
|
||||
|
||||
beforeEach(() => {
|
||||
native.enabled = false
|
||||
native.minimize.mockReset()
|
||||
native.toggleMaximize.mockReset()
|
||||
native.close.mockReset()
|
||||
})
|
||||
|
||||
describe('桌面窗口控制', () => {
|
||||
it('Web 模式不模拟窗口操作', async () => {
|
||||
await minimizeWindow()
|
||||
await toggleMaximizeWindow()
|
||||
await requestWindowClose()
|
||||
expect(native.minimize).not.toHaveBeenCalled()
|
||||
expect(native.toggleMaximize).not.toHaveBeenCalled()
|
||||
expect(native.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('桌面模式调用当前 Tauri 窗口', async () => {
|
||||
native.enabled = true
|
||||
await minimizeWindow()
|
||||
await toggleMaximizeWindow()
|
||||
await requestWindowClose()
|
||||
expect(native.minimize).toHaveBeenCalledOnce()
|
||||
expect(native.toggleMaximize).toHaveBeenCalledOnce()
|
||||
expect(native.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/** 桌面窗口操作集中于此边界;Web 页面不会显示或模拟原生窗口行为。 */
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { isDesktop } from './desktop'
|
||||
|
||||
export async function minimizeWindow() {
|
||||
if (isDesktop()) await getCurrentWindow().minimize()
|
||||
}
|
||||
|
||||
export async function toggleMaximizeWindow() {
|
||||
if (isDesktop()) await getCurrentWindow().toggleMaximize()
|
||||
}
|
||||
|
||||
export async function requestWindowClose() {
|
||||
// close 触发 Rust CloseRequested;Host 会要求前端先保存,再由 lifecycle destroy。
|
||||
if (isDesktop()) await getCurrentWindow().close()
|
||||
}
|
||||
Reference in New Issue
Block a user