12 KiB
12 KiB
u-desk 插件化架构设计方案
状态:调研完成,待实施 日期:2026-04-11
一、现状痛点
| 痛点 | 现状 | 影响 |
|---|---|---|
| app.go God Object | 958 行,67 个方法全在一个 struct 上 | 难以维护,新功能必须改核心文件 |
| App.vue 硬编码映射 | getComponent() 是 3 个 key 的字面量对象 |
新 Tab 必须改源码 |
| 文件预览 if/else 链 | FileEditorPanel.vue 有 ~12 层 v-if/v-else-if |
新增文件类型需改 5+ 处 |
| 大体积功能嵌入 | draw.io 等 ~12-15MB 功能无法按需加载 | 安装包膨胀 |
二、架构总览
+================================================================+
| u-desk Application |
+=================================================================
| Core (Go) Plugin Manager |
| - App facade - Registry / Lifecycle / Loader |
| - ConfigStore (SQLite) - TabRegistry |
| - EventBus (Wails Events) - PreviewRegistry |
+----------+----------+---------+-----------+----------+ |
| | | | | |
+-------+ +------+ +-----+ +--------+ +------+ +---+ |
|builtin: |builtin: |builtin | builtin: | future | future |
| file-sys| db-cli | md-edit| drawio | JS/WASM| Go .so |
+---------+---------+--------+-----------+--------+------------+
|
+==============================================================+|
| Frontend (Vue 3) ||
| PluginRegistry (TS) ||
| - TabProviders → 替代 App.vue 硬编码映射 ||
| - FilePreviewHandlers → 替代 FileEditorPanel if/else 链 ||
| - ComponentLoader → defineAsyncComponent 懒加载 ||
+==============================================================+
三、核心接口定义
3.1 后端插件接口(Go)
// 文件路径: internal/plugin/plugin.go
// PluginID 插件唯一标识
type PluginID string
// PluginMetadata 插件元数据
type PluginMetadata struct {
ID PluginID `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Author string `json:"author"`
TabKey string `json:"tab_key,omitempty"` // 提供的 Tab key
FileExtensions []string `json:"file_extensions,omitempty"` // 处理的文件扩展名
}
// PluginCapability 插件能力标志
type PluginCapability int
const (
CapabilityTabProvider PluginCapability = 1 << iota // 提供 Tab 页面
CapabilityFilePreview // 文件预览
)
// Plugin 核心插件接口
type Plugin interface {
Meta() PluginMetadata
Capabilities() PluginCapability
Init(ctx context.Context, core CoreServices) error
Start() error
Stop() error
}
// TabProvider Tab 提供者接口(可选)
type TabProvider interface {
TabDefinition() TabDef
TabComponentPath() string
}
// FilePreviewHandler 文件预览处理接口(可选)
type FilePreviewHandler interface {
CanPreview(filename string, mimeType string) bool
PreviewInfo(filename string) PreviewInfo
}
// PreviewInfo 预览元信息(发送给前端)
type PreviewInfo struct {
Type string `json:"type"` // "drawio", "image", "pdf"
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
NeedsContainer bool `json:"needs_container,omitempty"`
ContainerConfig map[string]string `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"`
}
3.2 前端插件接口(TypeScript)
// 文件路径: frontend/src/plugin/types.ts
/** 插件能力标志 */
export enum PluginCapability {
None = 0,
TabProvider = 1 << 0, // 提供 Tab
FilePreview = 1 << 1, // 文件预览
Settings = 1 << 2, // 设置页
}
/** Tab 插件定义 */
export interface TabPluginDefinition {
key: string
title: string
icon?: string
componentLoader: () => Promise<Component> // 异步组件加载器
defaultVisible?: boolean
order?: number
}
/** 文件预览处理器定义 */
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'
src?: string
htmlContent?: string
props?: Record<string, any>
}
四、插件管理器设计
4.1 后端 PluginManager
// internal/plugin/manager.go
type Manager struct {
mu sync.RWMutex
plugins map[PluginID]Plugin
core CoreServices
tabReg *TabRegistry
previewReg *PreviewRegistry
ctx context.Context
}
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) GetPluginInfos() []PluginMetadata // 获取插件列表
func (m *Manager) ResolvePreview(filename string) (*PreviewInfo, error)
func (m *Manager) Shutdown() error
4.2 前端注册中心
// frontend/src/plugin/registry.ts
import { reactive } from 'vue'
const state = reactive({
tabPlugins: new Map<string, TabPluginDefinition>(),
previewHandlers: [] as FilePreviewHandlerDefinition[],
})
export function registerTabPlugin(def: TabPluginDefinition): void
export function getTabComponent(key: string): (() => Promise<Component>) | null
export function getAllTabDefinitions(): TabPluginDefinition[]
export function registerPreviewHandler(handler: FilePreviewHandlerDefinition): void
export function resolvePreviewHandler(filename: string): FilePreviewHandlerDefinition | null
五、关键改造点
5.1 FileEditorPanel.vue 重构(最大收益)
改造前:12 层 v-if/v-else-if 链(image/video/audio/pdf/html/md/excel/word/csv/text/binary)
改造后:
<template>
<!-- iframe 类型(draw.io 等) -->
<iframe v-if="renderConfig?.type === 'iframe'" :src="renderConfig.src" />
<!-- Vue 组件类型 -->
<component v-else-if="previewComponent" :is="previewComponent" v-bind="previewProps" />
</template>
<script setup>
import { resolvePreviewHandler } from '@/plugin/registry'
const handler = computed(() =>
props.config.currentFileName ? resolvePreviewHandler(props.config.currentFileName) : null
)
const previewComponent = computed(() => handler.value?.getComponent?.())
const renderConfig = computed(() => handler.value?.getRenderConfig?.(filePath))
</script>
5.2 App.vue 改造
改造前:
const getComponent = (key) => ({ 'file-system': FileSystem, 'db-cli': DbCli }[key])
改造后:
import '@/plugin/built-in' // 副作用:执行内置插件注册
import { getTabComponent } from '@/plugin/registry'
const getComponent = (key) => {
const loader = getTabComponent(key)
return loader ? defineAsyncComponent(loader) : null
}
5.3 Draw.io 插件示例(首个验证插件)
后端 (internal/plugin/builtin/drawio_plugin.go):
type DrawIoPlugin struct{ server *http.Server }
func (p *DrawIoPlugin) Meta() PluginMetadata {
return PluginMetadata{
ID: "builtin-drawio", Name: "Draw.io 查看器",
FileExtensions: []string{"drawio", "dio"},
}
}
func (p *DrawIoPlugin) Capabilities() PluginCapability { return CapabilityFilePreview }
func (p *DrawIoPlugin) CanPreview(filename, _ string) bool {
ext := filepath.Ext(filename)
return ext == ".drawio" || ext == ".dio"
}
前端 (frontend/src/plugin/built-in/drawio-handler.ts):
registerPreviewHandler({
id: 'drawio-preview', priority: 95,
canHandle: (f) => /\.drawio?$/i.test(f),
getRenderConfig: (path) => ({
type: 'iframe',
src: `http://localhost:18765/drawio/index.html?chrome=0&lightbox=1&stealth=1#R${xmlContent}`
})
})
六、分阶段实施路径
Phase 0:基础设施搭建
不改现有功能,只建立骨架:
| 文件 | 内容 |
|---|---|
internal/plugin/plugin.go |
核心 Plugin/TabProvider/FilePreviewHandler 接口 |
internal/plugin/manager.go |
PluginManager 实现 |
internal/plugin/tab_registry.go |
Tab 注册表 |
internal/plugin/preview_registry.go |
预览处理器注册表 |
frontend/src/plugin/types.ts |
TS 类型定义 |
frontend/src/plugin/registry.ts |
前端注册中心 |
Phase 1:Draw.io 插件验证
用第一个真实插件验证整条链路:
| 步骤 | 文件 | 操作 |
|---|---|---|
| 1 | internal/plugin/builtin/drawio_plugin.go |
新建 DrawIoPlugin |
| 2 | frontend/src/plugin/built-in/drawio-handler.ts |
新建前端 handler |
| 3 | app.go |
小改:引入 pluginMgr,注册 DrawIoPlugin |
| 4 | FileEditorPanel.vue |
微改:if/else 末尾追加 drawio 分支 |
验证标准:打开 .drawio 文件 → 显示预览 → 其他文件不受影响
Phase 2:文件预览系统重构
将全部 12 种预览迁移到插件注册表:
- 在
preview-handlers.ts注册所有内置处理器 FileEditorPanel.vuetemplate 改为<component :is>/<iframe>filePreviewHandlers.js的 Excel/Word/CSV 逻辑拆分到对应组件
Phase 3:Tab 系统插件化
built-in/index.ts注册 3 个内置 TabApp.vuegetComponent 改为查 registry- KeepAlive include 动态化
Phase 4:app.go 瘦身(可选延后)
方法签名保留(Wails v2 绑定要求),实现委托给 pluginMgr。
七、新增/变更文件清单
新增:
internal/plugin/
plugin.go # 接口定义
manager.go # PluginManager
tab_registry.go # Tab 注册表
preview_registry.go # 预览注册表
builtin/
drawio_plugin.go # Draw.io 插件(Phase 1)
frontend/src/plugin/
types.ts # TS 接口
registry.ts # 注册中心
built-in/
drawio-handler.ts # Draw.io 前端 handler(Phase 1)
preview-handlers.ts # 全部内置预览注册(Phase 2)
index.ts # 内置 Tab 注册(Phase 3)
修改:
app.go # 引入 pluginMgr(Phase 1)
frontend/src/.../FileEditorPanel.vue # 追加插件入口 / 重写
frontend/src/App.vue # getComponent 改 registry
八、风险与应对
| 风险 | 应对策略 |
|---|---|
| Wails v2 无法动态绑定 API 方法 | App 上预留 PluginCall(id, method, params) 统一分发 |
| FileEditorPanel 改造影响面大 | Phase 1 只在 if/else 末尾追加;Phase 2 用 feature flag 切换新旧路径 |
| 包体积膨胀(draw.io ~12MB) | 条件编译 go build tags 或未来外部下载缓存 |
| 过度抽象增加复杂度 | YAGNI 原则:只在确实需要扩展点时才加接口 |
九、Wails v3 兼容性预留
Wails v3 (alpha.74) 主要变化对插件架构的影响:
| 维度 | v2 | v3 | 影响 |
|---|---|---|---|
| 绑定方式 | struct 方法自动生成 | 手动注册 handler | 更有利于插件化 |
| 前端调用 | window.go.main.App.Xxx() |
window.go.invoke() |
需适配层 |
| 事件系统 | runtime.EventsEmit |
类似但 API 不同 | 需抽象层 |
建议在 eventbus 中封装一层 Wails 版本抽象,切换 v3 时只需替换底层实现。