feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -35,6 +35,6 @@ OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vau
|
||||
|
||||
## 前端与发布
|
||||
|
||||
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;当前预览版通过 Host 连接已在 `127.0.0.1:8000` 启动的 AI Core,连接失败时返回 CORE_UNAVAILABLE。随机端口、临时令牌和自动管理进程仍属于后续 Sidecar 交付。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。原生菜单事件和标题栏可见的“段落”菜单复用 `editor.import-note-properties`;仅在源码模式、已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。
|
||||
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;当前预览版通过 Host 连接已在 `127.0.0.1:8000` 启动的 AI Core,连接失败时返回 CORE_UNAVAILABLE。随机端口、临时令牌和自动管理进程仍属于后续 Sidecar 交付。桌面窗口关闭系统装饰,统一由应用标题栏提供拖动、最小化、最大化/还原及关闭;窗口命令只授予本地 `main` WebView。关闭请求先尝试保存,冲突或保存失败时保持窗口。标题栏下的完整应用菜单复用 `editorCommandService`,主题菜单直接调用统一主题 Store;功能页使用 `feature-page`、`feature-header`、`panel` 等主题扩展点,确保自定义主题同时作用于编辑器与应用页面。原生菜单事件和可见“段落”菜单复用 `editor.import-note-properties`;仅在源码模式、已打开笔记且无冲突时启用,转换作为单次编辑器历史事务执行。
|
||||
|
||||
`bundle.active=false`,无自动更新、无签名安装包。开发命令为前端 `pnpm dev` 与 `cargo run --features desktop`;生产 EXE 必须在 `frontend` 目录执行 `pnpm desktop:build`,由 Tauri CLI 先构建并嵌入前端资源。不要用普通 `cargo build --release` 代替该命令,否则 Rust Host 会更新但 WebView 仍可能携带旧资源。需要对应平台 C++ 工具链和 WebView;本次 Windows 已使用系统 `x86_64-pc-windows-gnu` 工具链构建,仍不替代 MSVC 或其他平台验证。
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
稳定的前端命令标识为 `editor.import-note-properties`。属性转换处理器、Tauri 原生菜单事件和无边框窗口内的可见“段落”菜单均分发同一命令,没有维护第二套转换逻辑。默认快捷键为 Windows/Linux 的 `Ctrl+Alt+P`、macOS 的 `⌘⌥P`;Rust Host 使用 `CmdOrCtrl+Alt+P` 注册 accelerator,仍由活动编辑器能力检查决定是否可用。
|
||||
|
||||
无边框桌面窗口在标题栏下方固定显示“文件 / 编辑 / 段落 / 视图”菜单行。保存使用 `Ctrl+S`;撤销和重做沿用编辑器已有的 `Ctrl+Z` 与 `Ctrl+Shift+Z`,菜单只调用相同编辑器命令,不额外注册按键监听,以免一次按键执行两次。
|
||||
无边框桌面窗口在标题栏下方固定显示“文件 / 编辑 / 段落 / 格式 / 视图 / 主题 / 帮助”菜单行。段落菜单提供正文、六级标题、列表、引用、代码块和属性导入;格式菜单复用编辑器已有的加粗、斜体、行内代码、代码块、公式及警告框命令;主题菜单列出当前已安装主题并可进入主题管理。保存使用 `Ctrl+S`;撤销和重做沿用编辑器已有的 `Ctrl+Z` 与 `Ctrl+Shift+Z`,菜单只调用相同编辑器命令,不额外注册按键监听,以免一次按键执行两次。
|
||||
|
||||
Markdown 格式(含警告框)与元数据共用 `editorCommandService` 的能力查询和命令分发接口,详见 [警告框与桌面编辑命令开发说明](../development/警告框与桌面编辑命令开发说明.md)。Host 根据 supported / enabled 显隐或禁用菜单,不能把已预留的命令 ID 当作已可执行能力;原生快捷键不绕过活动文档、只读和冲突检查。
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('桌面顶部段落菜单', () => {
|
||||
editor.currentFilePath = '/示例.md'
|
||||
editor.saveStatus = 'saved'
|
||||
const wrapper = mount(TitleBarMenu)
|
||||
expect(wrapper.text()).toContain('文件编辑段落视图')
|
||||
expect(wrapper.text()).toContain('文件编辑段落格式视图主题帮助')
|
||||
await wrapper.get('[data-menu="paragraph"] .menu-trigger').trigger('click')
|
||||
const item = wrapper.get('.import-properties')
|
||||
expect((item.element as HTMLButtonElement).disabled).toBe(false)
|
||||
@@ -51,7 +51,7 @@ describe('桌面顶部段落菜单', () => {
|
||||
it('标题命令传入对应级别并显示快捷键', async () => {
|
||||
const wrapper = mount(TitleBarMenu)
|
||||
await wrapper.get('[data-menu="paragraph"] .menu-trigger').trigger('click')
|
||||
const headings = wrapper.findAll('[data-menu="paragraph"] [role="menuitem"]').filter(item => item.text().startsWith('标题'))
|
||||
const headings = wrapper.findAll('[data-menu="paragraph"] [role="menuitem"]').filter(item => item.text().includes('级标题'))
|
||||
await headings[1].trigger('click')
|
||||
expect(execute).toHaveBeenCalledWith('editor.heading', 2)
|
||||
await wrapper.get('[data-menu="paragraph"] .menu-trigger').trigger('click')
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useEditorStore } from '@/stores/editor'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { executeEditorCommand, getEditorCommandCapabilities, type EditorCommandId } from '@/services/editorCommandService'
|
||||
import { t } from '@/i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
type MenuName = 'file' | 'edit' | 'paragraph' | 'view'
|
||||
type MenuName = 'file' | 'edit' | 'paragraph' | 'format' | 'view' | 'theme' | 'help'
|
||||
const editor = useEditorStore()
|
||||
const theme = useThemeStore()
|
||||
const router = useRouter()
|
||||
const open = ref<MenuName | null>(null)
|
||||
const bar = ref<HTMLElement | null>(null)
|
||||
const error = ref('')
|
||||
@@ -32,6 +34,8 @@ async function command(id: EditorCommandId, params?: unknown) {
|
||||
async function save() { await editor.save(); close() }
|
||||
function setMode(mode: 'source' | 'wysiwyg') { editor.setMode(mode); close() }
|
||||
function toggleTheme() { theme.toggleTheme(); close() }
|
||||
function applyTheme(id: string) { theme.applyTheme(id); close() }
|
||||
function navigate(path: string) { void router.push(path); close() }
|
||||
|
||||
onMounted(() => window.addEventListener('pointerdown', dismiss))
|
||||
onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
|
||||
@@ -42,6 +46,8 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
|
||||
<div class="menu-group" data-menu="file">
|
||||
<button class="menu-trigger" :aria-expanded="open === 'file'" aria-haspopup="menu" @click="toggle('file')" @keydown.down.prevent="focusFirst('file')">{{ t('文件', 'File') }}</button>
|
||||
<div v-if="open === 'file'" class="menu-popover" role="menu">
|
||||
<button role="menuitem" @click="navigate('/')"><span>{{ t('选择知识库…', 'Choose knowledge base…') }}</span></button>
|
||||
<span class="menu-separator" role="separator" />
|
||||
<button role="menuitem" :disabled="!editor.currentFilePath || ['saving','conflict','external_changed'].includes(editor.saveStatus)" @click="save">
|
||||
<span>{{ t('保存', 'Save') }}</span><kbd>Ctrl+S</kbd>
|
||||
</button>
|
||||
@@ -58,7 +64,7 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
|
||||
<button class="menu-trigger" :aria-expanded="open === 'paragraph'" aria-haspopup="menu" @click="toggle('paragraph')" @keydown.down.prevent="focusFirst('paragraph')">{{ t('段落', 'Paragraph') }}</button>
|
||||
<div v-if="open === 'paragraph'" class="menu-popover paragraph-menu" role="menu">
|
||||
<button role="menuitem" :disabled="!enabled('editor.paragraph')" @click="command('editor.paragraph')"><span>{{ t('正文', 'Body text') }}</span></button>
|
||||
<button v-for="level in 3" :key="level" role="menuitem" :disabled="!enabled('editor.heading')" @click="command('editor.heading', level)"><span>{{ t(`标题 ${level}`, `Heading ${level}`) }}</span></button>
|
||||
<button v-for="level in 6" :key="level" role="menuitem" :disabled="!enabled('editor.heading')" @click="command('editor.heading', level)"><span>{{ t(`${level} 级标题`, `Heading ${level}`) }}</span><kbd>Ctrl+{{ level }}</kbd></button>
|
||||
<span class="menu-separator" role="separator" />
|
||||
<button role="menuitem" :disabled="!enabled('editor.bullet-list')" @click="command('editor.bullet-list')"><span>{{ t('无序列表', 'Bullet list') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.ordered-list')" @click="command('editor.ordered-list')"><span>{{ t('有序列表', 'Ordered list') }}</span></button>
|
||||
@@ -72,13 +78,44 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
|
||||
<small v-if="!enabled('editor.import-note-properties')">{{ t('请在无冲突的 Markdown 源码笔记中使用', 'Available in a conflict-free Markdown source note') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-group" data-menu="format">
|
||||
<button class="menu-trigger" :aria-expanded="open === 'format'" aria-haspopup="menu" @click="toggle('format')" @keydown.down.prevent="focusFirst('format')">{{ t('格式', 'Format') }}</button>
|
||||
<div v-if="open === 'format'" class="menu-popover" role="menu">
|
||||
<button role="menuitem" :disabled="!enabled('editor.bold')" @click="command('editor.bold')"><span>{{ t('加粗', 'Bold') }}</span><kbd>Ctrl+B</kbd></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.italic')" @click="command('editor.italic')"><span>{{ t('斜体', 'Italic') }}</span><kbd>Ctrl+I</kbd></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.strikethrough')" @click="command('editor.strikethrough')"><span>{{ t('删除线', 'Strikethrough') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.inline-code')" @click="command('editor.inline-code')"><span>{{ t('行内代码', 'Inline code') }}</span></button>
|
||||
<span class="menu-separator" role="separator" />
|
||||
<button role="menuitem" :disabled="!enabled('editor.code-block')" @click="command('editor.code-block')"><span>{{ t('代码块', 'Code block') }}</span><kbd>Ctrl+Shift+K</kbd></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.math-block')" @click="command('editor.math-block')"><span>{{ t('公式块', 'Math block') }}</span><kbd>Ctrl+Shift+M</kbd></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.callout')" @click="command('editor.callout', { type: 'NOTE', body: t('提示内容', 'Callout content') })"><span>{{ t('警告框', 'Callout') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.horizontal-rule')" @click="command('editor.horizontal-rule')"><span>{{ t('水平分割线', 'Horizontal rule') }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-group" data-menu="view">
|
||||
<button class="menu-trigger" :aria-expanded="open === 'view'" aria-haspopup="menu" @click="toggle('view')" @keydown.down.prevent="focusFirst('view')">{{ t('视图', 'View') }}</button>
|
||||
<div v-if="open === 'view'" class="menu-popover" role="menu">
|
||||
<button role="menuitemradio" :aria-checked="editor.mode === 'wysiwyg'" @click="setMode('wysiwyg')"><span>✓ {{ t('写作模式', 'Writing mode') }}</span></button>
|
||||
<button role="menuitemradio" :aria-checked="editor.mode === 'source'" @click="setMode('source')"><span>✓ {{ t('源码模式', 'Source mode') }}</span></button>
|
||||
<span class="menu-separator" role="separator" />
|
||||
<button role="menuitem" @click="toggleTheme"><span>{{ theme.isDark ? t('浅色主题', 'Light theme') : t('深色主题', 'Dark theme') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.heading.fold-all')" @click="command('editor.heading.fold-all')"><span>{{ t('全部折叠标题', 'Fold all headings') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.heading.unfold-all')" @click="command('editor.heading.unfold-all')"><span>{{ t('全部展开标题', 'Unfold all headings') }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-group" data-menu="theme">
|
||||
<button class="menu-trigger" :aria-expanded="open === 'theme'" aria-haspopup="menu" @click="toggle('theme')" @keydown.down.prevent="focusFirst('theme')">{{ t('主题', 'Theme') }}</button>
|
||||
<div v-if="open === 'theme'" class="menu-popover" role="menu">
|
||||
<button v-for="item in theme.allThemes" :key="item.theme_id" role="menuitemradio" :aria-checked="theme.currentThemeId === item.theme_id" @click="applyTheme(item.theme_id)"><span>{{ theme.currentThemeId === item.theme_id ? '✓ ' : '' }}{{ item.name }}</span></button>
|
||||
<span class="menu-separator" role="separator" />
|
||||
<button role="menuitem" @click="toggleTheme"><span>{{ theme.isDark ? t('切换为浅色', 'Switch to light') : t('切换为深色', 'Switch to dark') }}</span></button>
|
||||
<button role="menuitem" @click="navigate('/themes')"><span>{{ t('管理主题…', 'Manage themes…') }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-group" data-menu="help">
|
||||
<button class="menu-trigger" :aria-expanded="open === 'help'" aria-haspopup="menu" @click="toggle('help')" @keydown.down.prevent="focusFirst('help')">{{ t('帮助', 'Help') }}</button>
|
||||
<div v-if="open === 'help'" class="menu-popover" role="menu">
|
||||
<button role="menuitem" @click="navigate('/logs')"><span>{{ t('运行日志', 'Operation logs') }}</span></button>
|
||||
<button role="menuitem" @click="navigate('/settings')"><span>{{ t('设置与诊断', 'Settings and diagnostics') }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="error" class="menu-error" role="alert">{{ error }}</span>
|
||||
|
||||
@@ -75,21 +75,23 @@ function toggleSource() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="community-page">
|
||||
<h1>社区目录</h1>
|
||||
<p>连接您选择的来源。安装后仍需独立启用和授权;关闭社区不影响本地编辑。</p>
|
||||
<section class="community-controls" aria-label="社区来源">
|
||||
<main class="community-page feature-page">
|
||||
<header class="feature-header">
|
||||
<div><h1>社区目录</h1><p>连接您选择的来源。安装后仍需独立启用和授权;关闭社区不影响本地编辑。</p></div>
|
||||
</header>
|
||||
<section class="community-panel panel" aria-label="社区来源与搜索">
|
||||
<div class="community-controls">
|
||||
<label>来源地址 <input v-model="url" placeholder="https://community.example.org" :disabled="busy" /></label>
|
||||
<button class="btn" :disabled="busy || !url.trim()" @click="inspectSource">检查来源与公钥</button>
|
||||
<label>已添加来源 <select v-model="selectedSource" @change="search"><option value="">请选择</option><option v-for="item in sources" :key="item.id" :value="item.id">{{ item.url }}{{ item.enabled ? '' : '(已停用)' }}</option></select></label>
|
||||
<button class="btn" :disabled="!selectedSource || busy" @click="toggleSource">启用 / 停用来源</button>
|
||||
</section>
|
||||
<section class="community-controls" aria-label="搜索目录">
|
||||
</div>
|
||||
<div class="community-controls">
|
||||
<label>关键词 <input v-model="query" @keydown.enter="search" /></label>
|
||||
<label>类别 <select v-model="kind"><option v-for="item in kinds" :key="item.id" :value="item.id">{{ item.label }}</option></select></label>
|
||||
<button class="btn btn-primary" :disabled="busy || !selectedSource" @click="search">搜索 / 刷新</button>
|
||||
<button v-if="busy" class="btn" @click="cancel">取消</button>
|
||||
</section>
|
||||
</div>
|
||||
<p v-if="busy" role="status">正在处理…</p>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<p v-if="notice" role="status">{{ notice }}</p>
|
||||
@@ -102,6 +104,7 @@ function toggleSource() {
|
||||
<span v-if="item.withdrawn">已撤回</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="candidates.length">
|
||||
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设、MCP 或模型运行配置。</p>
|
||||
<details v-for="item in candidates" :key="item.key"><summary>{{ item.key.replace('community-candidate:', '') }}</summary><pre>{{ item.value }}</pre><button class="btn" @click="removeCandidate(item.key)">删除候选</button></details>
|
||||
@@ -125,6 +128,10 @@ function toggleSource() {
|
||||
|
||||
<style scoped>
|
||||
.community-page { padding: var(--space-xl); overflow: auto; min-width: 0; }
|
||||
.feature-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-xl); }
|
||||
.feature-header h1, .feature-header p { margin: 0; }
|
||||
.feature-header p { margin-top: var(--space-xs); color: var(--color-text-secondary); }
|
||||
.community-panel { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-primary); }
|
||||
.community-controls { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-md); margin-block: var(--space-lg); }
|
||||
label { display: grid; gap: var(--space-xs); }
|
||||
input, select { color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); padding: var(--space-sm); }
|
||||
|
||||
Reference in New Issue
Block a user