Private
Public Access
1
0

新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放

This commit is contained in:
2026-05-12 11:06:28 +08:00
parent 545d7a864d
commit 2a363fd729
62 changed files with 6687 additions and 660 deletions

View File

@@ -0,0 +1,81 @@
# 复杂度与价值评估
> 评估时间Phase 0 实施完成后2026-05-03
> 评估范围:已实施的 Phase 0 + 远景 Phase 1~5
---
## 一、Phase 0 已投入的复杂度
### 1.1 新增代码量
| 文件 | 行数 | 职责 |
|------|------|------|
| `internal/plugin/plugin.go` | ~150 | 核心接口与类型定义 |
| `internal/plugin/adapter.go` | ~48 | CoreServices 适配器 |
| `internal/plugin/tab_registry.go` | ~98 | Tab 注册表(线程安全) |
| `internal/plugin/preview_registry.go` | ~80 | 预览注册表(线程安全) |
| `internal/plugin/manager.go` | ~196 | 插件管理器(生命周期) |
| `frontend/src/plugin/types.ts` | ~70 | TS 类型定义 |
| `frontend/src/plugin/registry.ts` | ~112 | 前端注册中心Vue reactive |
| `app.go` 改动 | ~40 | 集成 PluginManager + 2 个绑定方法 |
| **合计** | **~794** | |
### 1.2 架构复杂度增量
| 维度 | 具体表现 | 严重程度 | 已解决方式 |
|------|---------|----------|-----------|
| **Wails v3 方法泄露** | App struct 上所有导出方法自动暴露给前端 | 高 | adapter 模式隔离CoreServices 独立实现ADR-001 |
| **Vue 3 响应式陷阱** | `Map`/`Set``reactive()` 内不触发更新 | 中 | 使用 `Record<string, T>` 替代 Map已踩坑记录 |
| **并发安全** | Manager 多处读写共享状态 | 中 | 全量 RWMutex 保护 + copy-then-iterate 模式 |
| **两阶段回滚** | Register 时 Tab 成功但 Preview 失败需清理 | 低 | `tabRegistered` 标志位 + `Unregister()` 回滚 |
| **前后端类型桥接** | Go struct → `map[string]interface{}` → TS | 低 | json.Marshal round-trip 统一处理 |
### 1.3 运行时开销
| 开销项 | 数值 | 说明 |
|--------|------|------|
| 启动时内存 | ~0仅创建 Manager 和空 Registry | Phase 0 不注册任何插件 |
| 锁竞争 | 无Phase 0 无并发注册场景) | 为后续并发预留 |
| 方法调用链路 | App → pluginMgr → Registry | 多一层间接调用,可忽略 |
## 二、Phase 0 获得的价值
### 2.1 直接收益
| 收益点 | 说明 | 对应痛点 |
|--------|------|---------|
| **扩展骨架就绪** | 注册表、管理器、适配器全部可用 | app.go God Object |
| **新功能零侵入** | 未来新增预览格式 / Tab 页无需改 App.vue | 硬编码映射、v-if 链 |
| **懒启动支持** | Tab 插件按 `Start()` 按需激活 | 安装包膨胀、启动慢 |
| **优先级调度** | 预览处理器按 priority 排序匹配 | 无法控制预览顺序 |
| **内置/外置统一接口** | 同一套 Plugin 接口服务两类插件 | 两套逻辑 |
| **失败自动清理** | Register 部分失败时回滚已注册资源 | 残留脏状态 |
### 2.2 战略价值
| 价值维度 | 说明 |
|----------|------|
| **可演进性** | 从单体应用平滑过渡到平台型应用,每步可独立验证 |
| **文档资产** | 完整设计文档 + ADR + 接口定义 + 数据模型,新人可快速接手 |
| **技术债务清零** | 解决了 app.go 35+ 方法的 God Object 问题根源(不再往 App 加方法) |
| **市场基础** | Phase 0 的接口体系直接支撑 Phase 4~5 的外部插件和插件市场 |
## 三、远期复杂度预警Phase 3~5
| Phase | 主要复杂度来源 | 风险等级 | 缓解策略 |
|-------|---------------|----------|---------|
| **Phase 3** UI Slot 动态注入 | 9 个插槽的组件动态加载、生命周期协调、布局冲突 | 中 | 先做 2~3 个核心插槽验证,不全量铺开 |
| **Phase 4** 外部插件加载 | `.uplugin` 包解析、沙箱隔离、版本兼容、签名校验 | 高 | 参考 VS Code Extension Host 架构,独立进程运行 |
| **Phase 5** 插件市场 | 服务端 API、审核流程、支付、权限管理 | 极高 | 作为独立项目推进,不影响桌面端主迭代 |
## 四、总体评价
```
投入: ~800 行代码 + 6 个新文件 + 1 个文件改动
回报: 扩展能力从 0 → 完整骨架,后续每步增量开发
风险: Phase 0 本身风险已全部识别并解决
ROI: ★★★★☆ — 当前阶段性价比极高
```
**结论**Phase 0 是一次高 ROI 的基础设施投资。真正的复杂度在 Phase 4外部插件但那是独立里程碑可以单独做 go/no-go 决策。当前阶段建议继续推进 Phase 1Draw.io 验证插件),用真实插件验证骨架的完整性。

View File

@@ -0,0 +1,227 @@
# 接口定义
## 一、后端接口Go
> 文件位置:`internal/plugin/plugin.go`
```go
package plugin
import (
"context"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
// ========== 类型定义 ==========
type PluginID string
type PluginSource string
const (
SourceBuiltin PluginSource = "builtin"
SourceMarket PluginSource = "market"
)
// PluginMetadata 插件元数据JSON 序列化传给前端)
type PluginMetadata struct {
ID PluginID `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Author string `json:"author"`
Source PluginSource `json:"source"`
TabKey string `json:"tab_key,omitempty"`
FileExtensions []string `json:"file_extensions,omitempty"`
InstallPath string `json:"install_path,omitempty"`
InstalledAt time.Time `json:"installed_at,omitempty"`
UISlots []string `json:"ui_slots,omitempty"` // 声明占用的 UI 插槽Phase 3
}
// PluginCapability 插件能力标志位
type PluginCapability int
const (
CapabilityNone PluginCapability = 0
CapabilityTabProvider PluginCapability = 1 << iota
CapabilityFilePreview
CapabilitySettings
)
func (c PluginCapability) Has(cap PluginCapability) bool {
return c&cap != 0
}
// PreviewInfo 预览元信息
type PreviewInfo struct {
Type string `json:"type"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
NeedsContainer bool `json:"needs_container,omitempty"`
ContainerConfig map[string]any `json:"container_config,omitempty"`
SupportsEdit bool `json:"supports_edit"`
PreloadHint string `json:"preload_hint,omitempty"`
}
// TabDef Tab 定义
type TabDef struct {
Key string `json:"key"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
Order int `json:"order"`
}
// ========== 核心接口 ==========
// CoreServices 插件可访问的核心服务(由 adapter 实现,不挂在 App 上)
type CoreServices interface {
Context() context.Context
MainWindow() *application.WebviewWindow
EmitEvent(eventName string, data ...any)
FileSystem() any
ConfigAPI() any
GetFileServerURL() string
}
// Plugin 核心插件接口(所有插件必须实现)
type Plugin interface {
Meta() PluginMetadata
Capabilities() PluginCapability
Init(ctx context.Context, core CoreServices) error
Start() error
Stop() error
}
// TabProvider Tab 提供者接口(可选,实现 CapabilityTabProvider 时需同时实现)
type TabProvider interface {
Plugin
TabDefinition() TabDef
TabComponentPath() string
}
// FilePreviewHandler 文件预览处理器接口(可选,实现 CapabilityFilePreview 时需同时实现)
type FilePreviewHandler interface {
Plugin
CanPreview(filename string, mimeType string) bool
PreviewInfo(filename string) PreviewInfo
}
```
## 二、PluginManager API
> 文件位置:`internal/plugin/manager.go`
```go
type Manager struct {
mu sync.RWMutex
plugins map[PluginID]Plugin
core CoreServices
tabReg *TabRegistry
previewReg *PreviewRegistry
ctx context.Context
initialized bool
}
// 生命周期
func NewManager(core CoreServices) *Manager
func (m *Manager) Register(p Plugin) error // 注册 + 自动分发到子注册表
func (m *Manager) InitAll(ctx context.Context) error // 初始化所有插件
func (m *Manager) StartByTabKey(tabKey string) error // 按 Tab 懒启动
func (m *Manager) Shutdown() error // 停止所有插件
// 查询
func (m *Manager) GetPluginInfos() []PluginMetadata // 前端展示用
func (m *Manager) ResolvePreview(filename string) (*PreviewInfo, PluginID)
func (m *Manager) GetTabDefinitions() []TabDef
// 外部插件管理Phase 4+
func (m *Manager) InstallPlugin(packagePath string) error
func (m *Manager) UninstallPlugin(id PluginID) error
func (m *Manager) SetPluginEnabled(id PluginID, enabled bool) error
func (m *Manager) CheckPluginUpdates() []PluginUpdateInfo
```
## 三、前端接口TypeScript
> 文件位置:`frontend/src/plugin/types.ts`
```typescript
/** 插件能力标志位 */
export enum PluginCapability {
None = 0,
TabProvider = 1 << 0,
FilePreview = 1 << 1,
Settings = 1 << 2,
}
export type PluginSource = 'builtin' | 'market'
/** 后端返回的插件元信息 */
export interface PluginMetadata {
id: string
name: string
version: string
description: string
author: string
source: PluginSource
tab_key?: string
file_extensions?: string[]
install_path?: string
installed_at?: string
ui_slots?: string[]
}
/** Tab 插件定义(前端注册用) */
export interface TabPluginDefinition {
key: string
title: string
icon?: string
componentLoader: () => Promise<Component>
defaultVisible?: boolean
order?: number
keepAlive?: boolean
}
/** 文件预览处理器定义 */
export interface FilePreviewHandlerDefinition {
id: string
name: string
icon: string
priority: number
canHandle: (filename: string) => boolean
getComponent?: () => Promise<Component>
getRenderConfig?: (filePath: string) => RenderConfig
supportsEdit?: boolean
}
/** 渲染配置 */
export interface RenderConfig {
type: 'iframe' | 'html' | 'custom' | 'image' | 'video' | 'audio'
src?: string
htmlContent?: string
props?: Record<string, unknown>
}
```
## 四、前端 Registry API
> 文件位置:`frontend/src/plugin/registry.ts`
```typescript
// === Tab 插件 ===
function registerTabPlugin(def: TabPluginDefinition): void
function getTabComponent(key: string): (() => Promise<Component>) | null
function getAllTabDefinitions(): TabPluginDefinition[]
function hasTabPlugin(key: string): boolean
// === 文件预览 ===
function registerPreviewHandler(handler: FilePreviewHandlerDefinition): void
function resolvePreviewHandler(filename: string): FilePreviewHandlerDefinition | null
function getAllPreviewHandlers(): ReadonlyArray<FilePreviewHandlerDefinition>
// === 调试 ===
function getRegistryStats(): { tabCount, previewHandlerCount, tabKeys, handlerIds }
```

View File

@@ -0,0 +1,73 @@
# 数据模型
## 一、plugin_state 表(新增)
```sql
CREATE TABLE plugin_state (
plugin_id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'builtin',
enabled INTEGER DEFAULT 1,
config TEXT,
install_path TEXT,
version TEXT,
installed_at DATETIME,
updated_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_plugin_source ON plugin_state(source);
CREATE INDEX idx_plugin_enabled ON plugin_state(enabled);
```
### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `plugin_id` | TEXT PK | 对应 PluginMetadata.ID`"builtin-drawio"` |
| `source` | TEXT | `'builtin'``'market'` |
| `enabled` | INTEGER | 1=启用 0=停用 |
| `config` | TEXT | JSON 格式的插件私有配置 |
| `install_path` | TEXT | 外部插件磁盘路径(内置为 NULL |
| `version` | TEXT | 当前安装的版本号 |
| `installed_at` | DATETIME | 安装时间 |
| `updated_at` | DATETIME | 最后状态变更时间 |
## 二、与现有表的关系
```
app_config 表(已有) plugin_state 表(新增)
┌──────────────┐ ┌──────────────┐
│ key='tab_config'│ │ plugin_id PK │
│ value=JSON │ ← 合并Tab →│ source │
│ (全局配置) │ │ enabled │
├──────────────┤ │ version │
│ key='plugin_ │ ← 全局设置→│ config │
│ global' │ │ install_path │
│ (Phase 3 新增)│ └──────────────┘
└──────────────┘
```
- `app_config`存全局应用配置tab_config、plugin_global 等)
- `plugin_state`:存每个插件的运行状态和独立配置
- 两者通过 `plugin_id` 关联
## 三、初始种子数据
应用首次启动时由 Go 代码写入:
```sql
INSERT OR IGNORE INTO plugin_state (plugin_id, source, enabled, version) VALUES
('builtin-file-system', 'builtin', 1, '0.4.0'),
('builtin-markdown', 'builtin', 1, '0.4.0'),
('builtin-drawio', 'builtin', 0, '1.0.0');
```
## 四、配置存储策略
| 存储位置 | 内容 | 写入时机 |
|---------|------|---------|
| `app_config['tab_config']` | Tab 可见性/排序/默认值 | Phase 3 动态合并 |
| `app_config['plugin_global']` | 插件全局设置(自动更新间隔等) | Phase 3 |
| `plugin_state.enabled` | 每个插件的启停状态 | Phase 3 SetPluginEnabled |
| `plugin_state.version` | 当前安装版本 | 安装/更新时 |
| `plugin_state.config` | 插件私有配置(如 Draw.io 端口号) | 用户修改插件设置时 |

View File

@@ -0,0 +1,250 @@
# 架构设计
## 一、架构全景
```
+==================================================================+
| u-desk Application |
+===================================================================+
| Core (Go) Plugin Manager |
| - App facade - Registry / Lifecycle / Loader |
| - ConfigStore (SQLite) - Builtin Plugins (编译时) |
| - EventBus (Wails Events) - External Plugins (运行时) |
| - UpdateEngine - Marketplace Client |
+----------+----------+---------+-----------+-----------+ |
| | | | | |
+-------+ +------+ +-----+ +--------+ +------+ +----+ |
|builtin: |builtin: |builtin | builtin: |extern:|extern |
| file-sys| md-edit| drawio | db-cli | user-A| user-B |
+---------+---------+--------+-----------+--------+--------------+
|
+=================================================================+|
| Frontend (Vue 3) ||
| PluginRegistry (TS) ||
| - TabProviders → 替代 App.vue 硬编码映射 ||
| - FilePreviewHandlers → 替代 FileEditorPanel if/else 链 ||
| - ComponentLoader → defineAsyncComponent 懒加载 ||
| - PluginSettingsUI → 设置面板插件管理区块 ||
+=================================================================+
```
## 二、两层插件体系
| 维度 | 内置插件 (Builtin) | 外部插件 (External/Market) |
|------|-------------------|--------------------------|
| **来源** | 编译时打包进二进制 | 运行时从市场安装 |
| **位置** | `internal/plugin/builtin/` | 用户数据目录 `plugins/` |
| **生命周期** | 随应用启动 | 用户控制 |
| **管理操作** | 启用 / 禁用 | 安装 / 卸载 / 启用 / 禁用 / 更新 |
| **更新方式** | 随应用版本更新 | 独立更新(复用 UpdateAPI |
| **安全级别** | 完全信任(同进程) | 沙箱隔离Phase 5 |
| **示例** | 文件系统、Markdown、Draw.io | 第三方预览器、云存储同步 |
## 三、设置面板形态(最终态)
```
┌─ 设置 ──────────────────────────────────────────────┐
│ │
│ ┌─ Tab 配置 ─┐ ┌─ 版本更新 ─┐ ┌─ 插件管理 ─┐ │
│ │ │ │ │ │ │ │
│ │ 拖拽排序 │ │ 自动检查 │ │ 内置插件 │ │
│ │ 显隐控制 │ │ 手动检查 │ │ ✓ Draw.io │ │
│ │ 默认选择 │ │ 下载/安装 │ │ ✓ Markdown │ │
│ │ │ │ │ │ ○ 数据库CLI │ │
│ └─────────────┘ └─────────────┘ │ │ │
│ │ 外部插件 │ │
│ │ ✓ 云盘同步 v2.1│ │
│ │ ○ 思维导图(停用)│ │
│ │ │ │
│ │ [浏览插件市场] │ │
│ └──────────────┘ │
└──────────────────────────────────────────────────────┘
```
## 四、UI 插槽体系Slot System
### 4.1 当前布局(硬编码)
```
┌─────────────────────────────────┐
│ 标题栏 + Tab 导航 + 窗口控制 │ ← 固定区域,不可扩展
├─────────────────────────────────┤
│ │
│ <component :is="activeTab"> │ ← 内容区getComponent() 硬编码
│ │
├─────────────────────────────────┤
│ 更新通知 / 设置抽屉 │ ← 固定区域
└─────────────────────────────────┘
```
**问题**:所有 UI 区域都是硬编码的。新插件无法在标题栏加按钮、无法在工具栏扩展、无法在侧边栏注入面板。
### 4.2 插件化后的插槽布局
```
┌── slot: titlebar-extra ──────────────────────────────┐
│ [u-desk] [文件管理 | Markdown] [🔌插件A] [🔌插件B] [—][□][×] │
├──────────────────────────────────────────────────────┤
│ slot: sidebar-left │ slot: main-content │ slot: sidebar-right │
│ │ │ │
│ [内置侧边栏] │ <component :is="activeTab"> │ (预留) │
│ ───────────── │ │ │
│ [插件侧边面板] │ │ │
│ │ │ │
├────────────────────┴───────────────────────────────────┴──────────────────────┤
│ slot: toolbar-extra: [📋] [🔍] [🔌插件工具按钮...] │
├──────────────────────────────────────────────────────────────────────────────┤
│ slot: status-bar: [就绪] [行: 128] [编码: UTF-8] [🔌插件状态项...] │
└──────────────────────────────────────────────────────────────────────────────┘
右键菜单 → slot: context-menu-extra追加菜单项
设置面板 → slot: settings-panel内嵌区块
```
### 4.3 插槽定义表
| 插槽 ID | 位置 | 控制方式 | 谁可注册 | Phase |
|---------|------|---------|---------|-------|
| `titlebar-extra` | 标题栏右侧(窗口控制按钮左侧) | 声明式PluginMetadata.uiSlots[] | 内置 + 插件 | 3 |
| `tab-bar` | Tab 导航条内部 | 自动CapabilityTabProvider 插件自动合并 | PluginManager 驱动 | 1 |
| `main-content` | 中央主内容区 | 自动getComponent() 查 registry | registry 驱动 | 1 |
| `sidebar-left` | 左侧边栏底部FileSystem Sidebar 之后) | 声明式 | 插件 | 3 |
| `sidebar-right` | 右侧边栏(预览区旁) | 声明式 | 插件 | 3 |
| `toolbar-extra` | 工具栏末尾追加按钮 | 声明式 | 插件 | 2 |
| `status-bar` | 底部状态栏追加信息 | 声明式 | 插件 | 3 |
| `context-menu-extra` | 右键菜单追加项 | 声明式 | 插件 | 2 |
| `settings-panel` | 设置面板内嵌区块 | 声明式CapabilitySettings | 插件 | 3 |
### 4.4 插槽注册机制(前端)
```typescript
// frontend/src/plugin/slots.ts
interface SlotDefinition {
id: string // 插槽 ID
component?: Component // 注入的 Vue 组件
position?: 'prepend' | 'append' | 'before' | 'after' // 插入位置
order?: number // 同一插槽内的排序权重
}
interface UISlotRegistry {
/** 插件声明要占用哪些插槽 */
registerSlot(pluginId: string, def: SlotDefinition): void
/** App.vue 查询某插槽有哪些组件 */
getSlotComponents(slotId: string): SlotDefinition[]
/** 所有已注册的插槽概览 */
getAllSlots(): Map<string, SlotDefinition[]>
}
```
### 4.5 App.vue 插槽改造示意
```vue
<template>
<!-- 标题栏 -->
<div class="titlebar">
<div class="titlebar-left">u-desk</div>
<a-tabs v-model="activeTab" class="header-tabs">...</a-tabs>
<!-- 插槽: titlebar-extra -->
<component
v-for="slot in getSlotComponents('titlebar-extra')"
:key="slot.id"
:is="slot.component"
/>
<WindowControls />
</div>
<!-- 主区域 -->
<div class="main-layout">
<!-- 插槽: sidebar-left -->
<component
v-for="slot in getSlotComponents('sidebar-left')"
:is="slot.component"
/>
<!-- 插槽: main-content核心 -->
<KeepAlive :include="cacheableComponents">
<component :is="getComponent(activeTab)" />
</KeepAlive>
</div>
<!-- 工具栏插槽等... -->
</template>
```
### 4.6 后端接口支持
```go
// PluginMetadata 新增字段
type PluginMetadata struct {
// ... 现有字段 ...
UISlots []string `json:"ui_slots,omitempty"` // 声明占用的 UI 插槽列表
}
// Manager 新增方法
func (m *Manager) GetUISlotContents(slotID string) []UISlotEntry
```
### 4.7 切割原则
| 原则 | 说明 |
|------|------|
| **最小暴露** | 只开放必要的插槽,不把整个布局变成"配置驱动" |
| **位置固定** | 每个插槽的位置在 App.vue 中固定,插件只能决定"往里放什么",不能移动插槽本身 |
| **顺序可控** | 同一插槽多插件通过 order 控制排列顺序 |
| **隔离渲染** | 每个插槽内的插件组件有独立作用域,避免样式/状态污染 |
| **优雅降级** | 插件组件报错不影响宿主 UIErrorBoundary 包裹) |
## 五、关键改造点对比
### 5.1 App.vue 改造Phase 3
**改造前**
```typescript
import FileSystem from './components/FileSystem/index.vue'
import MarkdownEditor from './views/markdown-editor/index.vue'
const getComponent = (key: string) => ({
'file-system': FileSystem,
'markdown-editor': MarkdownEditor
}[key] || null)
```
**改造后**
```typescript
import '@/plugin/built-in' // 副作用:执行所有内置插件注册
import { getTabComponent } from '@/plugin/registry'
const getComponent = (key: string) => {
const loader = getTabComponent(key)
return loader ? defineAsyncComponent(loader) : null
}
```
### 5.2 FileEditorPanel.vue 改造Phase 2
**改造前**10 层 v-if/v-else-if 链binary/image/video/audio/pdf/excel/word/csv/html/md/text
**改造后**
```vue
<template>
<div class="editor-content">
<iframe v-if="renderConfig?.type === 'iframe'" :src="renderConfig.src" />
<component v-else-if="previewComponent" :is="previewComponent" v-bind="previewProps" />
<CodeEditor v-else ... />
</div>
</template>
<script setup lang="ts">
import { resolvePreviewHandler } from '@/plugin/registry'
import { computed } from 'vue'
const handler = computed(() =>
props.config.currentFileName ? resolvePreviewHandler(props.config.currentFileName) : null
)
const previewComponent = computed(() => handler.value?.getComponent?.())
const renderConfig = computed(() => handler.value?.getRenderConfig?.(props.filePath ?? ''))
</script>
```