macos-web/REFACTORING_GUIDE.md
2026-07-23 17:47:38 +08:00

63 KiB
Raw Blame History

macOS-web 纯 Vue 重构手册

目标:去除原生 JS 思路和实现,去掉 window 全局调用,将项目改造为纯 Vue 3 项目。 范围:全量重构,不考虑成本。


目录

  1. 现状诊断
  2. 目标架构
  3. 前端架构设计
  4. 重构总路线
  5. 阶段一:基础设施层
  6. 阶段二:系统 UI 组件化
  7. 阶段三:窗口管理器现代化
  8. 阶段四:应用层优化
  9. 阶段五:清理与收尾
  10. 附录:新旧对照表

1. 现状诊断

1.1 双渲染系统并存(核心问题)

当前渲染方式 问题
系统 UI 层(菜单栏/Dock/对话框/菜单/通知/Spotlight/控制中心/锁屏/屏保/启动界面) el() 命令式 DOM 构建 + $() 直接 DOM 查询 非 Vue绕过虚拟 DOM
窗口 chrome 层traffic lights/标题栏/resize handles el() 命令式 DOM 构建 非 Vue
应用层Finder/Calculator/Safari 等 33 个应用) Vue 3 SFC + h()/render() 手动挂载 部分合理
容器层DesktopArea/MenuBarArea 等) Vue 模板(仅作为 DOM 挂载锚点) 合理

1.2 原生 JS 使用分布

模块 原生 API 严重度
useSys.ts (~800 行) $, $$, el, addEventListener, matchMedia, MutationObserver, requestAnimationFrame, setInterval, Image(), document.activeElement, getBoundingClientRect 🔴 最重度
useWM.ts (~400 行) el(), addEventListener('pointermove/up'), document.getElementById, document.body.classList, setPointerCapture, getBoundingClientRect 🔴 重度
useUI.ts (~200 行) document.createElement/addEventListener/body.append/remove + window.innerWidth/innerHeight 🔴 全部原生
useNotify.ts (~200 行) $, $$, el, setTimeout, querySelector 🔴 重度
useSpotlight.ts (~150 行) $, $$, el, addEventListener, navigator.clipboard, scrollIntoView 🔴 重度
useApps.ts (~120 行) document.activeElement, document.execCommand, navigator.clipboard 🟡 中度
system/index.ts (~160 行) $, $$, el, document.addEventListener, 14 个 window 全局暴露 🔴 重度
utils/index.ts (~100 行) document.querySelector/querySelectorAll/createElement 🔴 基础依赖

1.3 关键反模式

graph TD
    A["window 全局暴露<br/>14 个全局变量"] --> B["隐式依赖,模块封装破坏"]
    C["懒引用 lazy ref<br/>let _Sys: any = null"] --> D["循环依赖 workaround<br/>类型不安全"]
    E["el() 命令式 DOM<br/>微型虚拟 DOM 替代品"] --> F["绕过 Vue 响应式<br/>6+ 模块重度依赖"]
    G["$('') 直接 DOM 查询"] --> H["绕过 Vue ref 系统"]
    I["bus.on/emit"] --> J["类型不安全<br/>非 Vue 标准模式"]
    K["33 个副作用 import"] --> L["无懒加载<br/>全量打包"]

1.4 核心循环依赖

useSys ←→ useWM ←→ useApps ←→ useFS

通过 setSysRef() / setSysForNotify() / setAppsRef() 等 lazy setter 打破。


2. 目标架构

2.1 总览

graph TD
    subgraph "Vue 应用入口"
        main.ts --> App.vue
    end

    subgraph "状态管理 Pinia"
        settingsStore["settingsStore<br/>(原 useSettings + useStore)"]
        fsStore["fsStore<br/>(原 useFS)"]
        appStore["appStore<br/>(原 useApps 注册表部分)"]
        wmStore["wmStore<br/>(原 useWM 状态部分)"]
        notifyStore["notifyStore<br/>(原 useNotify 数据部分)"]
        sysStore["sysStore<br/>(原 useSys 设置/电源部分)"]
    end

    subgraph "系统 UI 组件(纯 Vue SFC"
        MenuBar["&lt;MenuBar /&gt;"]
        Dock["&lt;Dock /&gt;"]
        DesktopIcons["&lt;DesktopIcons /&gt;"]
        Spotlight["&lt;Spotlight /&gt;"]
        NotificationCenter["&lt;NotificationCenter /&gt;"]
        ControlCenter["&lt;ControlCenter /&gt;"]
        LockScreen["&lt;LockScreen /&gt;"]
        Screensaver["&lt;Screensaver /&gt;"]
        BootScreen["&lt;BootScreen /&gt;"]
        ContextMenu["&lt;ContextMenu /&gt;"]
        DialogLayer["&lt;DialogLayer /&gt;"]
    end

    subgraph "窗口系统"
        WindowLayer["&lt;WindowLayer /&gt;"]
        WindowFrame["&lt;WindowFrame /&gt;<br/>(chrome: 标题栏/traffic lights/resize)"]
    end

    subgraph "应用层(懒加载)"
        AppLoader["&lt;AppLoader /&gt;<br/>defineAsyncComponent"]
        Finder["Finder.vue"]
        Safari["Safari.vue"]
        TxtEdit["...33 个应用"]
    end

    subgraph "组合式函数(纯逻辑,无 DOM"
        useKeyboard["useKeyboard全局快捷键"]
        useIdleWatch["useIdleWatch屏保计时"]
        useTheme["useTheme暗色模式/壁纸)"]
        useVolume["useVolume音量控制"]
        useEventBus["useEventBusmitt 替代 bus"]
    end

    App.vue --> MenuBar
    App.vue --> Dock
    App.vue --> DesktopIcons
    App.vue --> WindowLayer
    App.vue --> Spotlight
    App.vue --> NotificationCenter
    App.vue --> ControlCenter
    App.vue --> LockScreen
    App.vue --> Screensaver
    App.vue --> BootScreen
    App.vue --> ContextMenu
    App.vue --> DialogLayer

    WindowLayer --> WindowFrame
    WindowLayer --> AppLoader

2.2 核心原则

  1. 一切 UI 皆 Vue 组件:不再使用 el() 创建任何 DOM 元素
  2. 状态归 Pinia:跨模块共享状态走 Pinia store不挂 window
  3. 通信走 provide/inject + mitt:父子用 provide/inject跨层级用 mitt类型安全的事件总线
  4. 无直接 DOM 查询:用 Vue ref / template ref 替代 $() / $$()
  5. 应用懒加载defineAsyncComponent + 动态 import
  6. 仅保留必要的原生 APIpointer events 拖拽、localStoragenavigator.clipboard 等不可避免的部分,封装在专用 composable 中

3. 前端架构设计

3.1 组件层级树

graph TD
    subgraph "App.vue根组件"
        direction TB
        A[App.vue]
    end

    subgraph "Layer 1背景层 z-0"
        B1["&lt;OverlayBrightness /&gt;"]
        B2["&lt;OverlayNightShift /&gt;"]
        B3["&lt;DesktopArea /&gt;"]
    end

    subgraph "Layer 2内容层 z-1~99"
        C1["&lt;WindowLayer /&gt;"]
    end

    subgraph "Layer 3系统 UI 层 z-100~499"
        D1["&lt;MenuBar /&gt;"]
        D2["&lt;Dock /&gt;"]
    end

    subgraph "Layer 4浮层 z-500~899"
        E1["&lt;Spotlight /&gt;"]
        E2["&lt;ControlCenter /&gt;"]
        E3["&lt;NotificationCenter /&gt;"]
        E4["&lt;ContextMenu /&gt;"]
        E5["&lt;DialogLayer /&gt;"]
        E6["&lt;BannerContainer /&gt;"]
    end

    subgraph "Layer 5全屏覆盖层 z-900+"
        F1["&lt;LockScreen /&gt;"]
        F2["&lt;Screensaver /&gt;"]
        F3["&lt;BootScreen /&gt;"]
        F4["&lt;PowerOff /&gt;"]
    end

    A --> B1 & B2 & B3
    A --> C1
    A --> D1 & D2
    A --> E1 & E2 & E3 & E4 & E5 & E6
    A --> F1 & F2 & F3 & F4

    C1 --> C1A["&lt;WindowFrame /&gt; ×N"]
    C1A --> C1A1["&lt;TrafficLights /&gt;"]
    C1A --> C1A2["&lt;AppLoader /&gt;"]
    C1A2 --> C1A2A["应用 SFC<br/>(Finder/Safari/...)"]

z-index 分层规范

z-index 范围 组件 说明
背景层 0 OverlayBrightness, OverlayNightShift, DesktopArea 静态背景,无交互
内容层 199 WindowLayer → WindowFrame ×N 窗口动态分配 z-index
系统 UI 层 100499 MenuBar(100), Dock(200) 始终在窗口之上
浮层 500899 Spotlight(500), ControlCenter(510), NotificationCenter(510), ContextMenu(600), DialogLayer(700), BannerContainer(800) 临时弹出内容
全屏覆盖层 900+ LockScreen(900), Screensaver(950), BootScreen(999), PowerOff(998) 独占式全屏状态

3.2 数据流架构

flowchart LR
    subgraph "数据源"
        LS[localStorage]
        UA[用户操作]
        SYS[系统事件<br/>resize/keyboard/idle]
    end

    subgraph "Pinia Stores单一数据源"
        direction TB
        SS["settingsStore<br/>设置/外观/音量/壁纸"]
        FS["fsStore<br/>虚拟文件系统"]
        WS["wmStore<br/>窗口状态"]
        AS["appStore<br/>应用注册表"]
        NS["notifyStore<br/>通知数据"]
        US["uiStore<br/>菜单/对话框状态"]
    end

    subgraph "Composables纯逻辑"
        UK[useKeyboard]
        UI[useIdleWatch]
        UT[useTheme]
        UD[useDrag / useResize]
        UC[useClipboard]
    end

    subgraph "Vue 组件(视图层)"
        direction TB
        MB["&lt;MenuBar /&gt;"]
        DK["&lt;Dock /&gt;"]
        SP["&lt;Spotlight /&gt;"]
        CC["&lt;ControlCenter /&gt;"]
        NC["&lt;NotificationCenter /&gt;"]
        WF["&lt;WindowFrame /&gt;"]
        APPS["33 个应用组件"]
    end

    subgraph "跨组件通信"
        MITT["mitt 事件总线<br/>(wm:focus, fs:changed,<br/>volume:changed, apps:ready)"]
        PI["provide / inject<br/>(窗口级上下文)"]
    end

    LS -->|"watch 持久化"| SS
    LS --> FS
    LS --> NS

    UA -->|"@click / v-model"| SS
    UA --> WS
    UA --> AS
    UA --> US

    SYS --> UK
    SYS --> UI
    SYS --> UT

    SS -->|"reactive state"| MB
    SS --> DK
    SS --> CC
    SS --> NC
    SS --> WF
    SS --> APPS

    WS -->|"reactive state"| WF
    WS --> DK

    FS -->|"reactive state"| SP
    FS --> APPS

    NS -->|"reactive state"| NC
    NS --> DK

    AS -->|"registry lookup"| SP
    AS --> WF
    AS --> DK

    US -->|"reactive state"| MB
    US --> SP

    UK -->|"快捷键路由"| SS
    UK --> WS
    UK --> AS

    UI -->|"空闲超时"| SS

    UT -->|"外观变更"| SS

    MITT -->|"事件订阅"| WS
    MITT --> FS
    MITT --> DK
    MITT --> APPS

    PI -->|"窗口上下文"| APPS

核心数据流原则

  1. 单向数据流用户操作 → Store 更新 → 组件响应式重渲染
  2. Store 是唯一真相源:所有共享状态必须位于 Pinia store 中
  3. 组件不直接修改其他组件的状态:通过 store action 或事件总线
  4. 事件总线仅用于通知,不用于状态传递emitter.emit('fs:changed') 是"文件变了,你们自己去看",而非"文件变了,这是新数据"

3.3 路由与懒加载策略

本项目是桌面模拟器,不使用 vue-router无需 URL 路由)。应用加载使用以下策略:

// src/apps/loaders.ts
import { defineAsyncComponent, type AsyncComponentLoader } from 'vue'

/**
 * 应用懒加载映射表
 *
 * 策略:
 * - 核心应用Finder→ 立即加载Eager
 * - 系统应用(前 7 个)→ 空闲时预加载Idle Preload
 * - 其他应用 → 按需加载Lazy
 */

// 加载策略枚举
enum LoadStrategy {
  Eager,        // 立即加载
  IdlePreload,  // requestIdleCallback 预加载
  Lazy,         // 首次打开时加载
}

export const appLoaders: Record<string, {
  loader: AsyncComponentLoader
  strategy: LoadStrategy
}> = {
  // === 核心应用Eager启动即加载 ===
  finder: {
    loader: () => import('./finder/Finder.vue'),
    strategy: LoadStrategy.Eager,
  },

  // === 系统应用IdlePreload空闲时后台加载 ===
  settings: {
    loader: () => import('./settings/Settings.vue'),
    strategy: LoadStrategy.IdlePreload,
  },
  safari: {
    loader: () => import('./safari/Safari.vue'),
    strategy: LoadStrategy.IdlePreload,
  },
  mail: {
    loader: () => import('./mail/Mail.vue'),
    strategy: LoadStrategy.IdlePreload,
  },
  messages: {
    loader: () => import('./messages/Messages.vue'),
    strategy: LoadStrategy.IdlePreload,
  },
  music: {
    loader: () => import('./music/Music.vue'),
    strategy: LoadStrategy.IdlePreload,
  },
  photos: {
    loader: () => import('./photos/Photos.vue'),
    strategy: LoadStrategy.IdlePreload,
  },
  notes: {
    loader: () => import('./notes/Notes.vue'),
    strategy: LoadStrategy.IdlePreload,
  },

  // === 其他应用Lazy按需加载 ===
  calculator: {
    loader: () => import('./calculator/Calculator.vue'),
    strategy: LoadStrategy.Lazy,
  },
  calendar: {
    loader: () => import('./calendar/Calendar.vue'),
    strategy: LoadStrategy.Lazy,
  },
  // ... 其余 24 个应用
}

/**
 * 在 appStore.init() 之后调用,利用浏览器空闲时间预加载常用应用
 */
export function initPreload() {
  const idleLoaders = Object.values(appLoaders)
    .filter(a => a.strategy === LoadStrategy.IdlePreload)

  idleLoaders.forEach(({ loader }) => {
    requestIdleCallback(() => {
      // 触发异步加载但不等待结果
      ;(loader as () => Promise<any>)()
    }, { timeout: 3000 })
  })
}

加载状态处理

<!-- src/components/wm/AppLoader.vue -->
<template>
  <Suspense>
    <component :is="asyncComp" v-if="asyncComp" :win="win" />
    <template #fallback>
      <div class="app-loading">
        <img :src="win.icon" alt="" class="app-loading-icon" />
        <div class="app-loading-text">正在加载 {{ win.title }}</div>
      </div>
    </template>
  </Suspense>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { defineAsyncComponent } from 'vue'
import { appLoaders } from '@/apps/loaders'

const props = defineProps<{ win: WinState }>()

const asyncComp = computed(() => {
  const entry = appLoaders[props.win.appId]
  if (!entry) return null

  return defineAsyncComponent({
    loader: entry.loader,
    loadingComponent: { /* 加载中占位 */ },
    errorComponent: { /* 加载失败占位 */ },
    delay: 200,        // 200ms 后才显示 loading
    timeout: 10000,    // 10s 超时
  })
})
</script>

3.4 状态管理设计

3.4.1 Store 职责矩阵

Store 拥有的状态 不拥有的状态 持久化
settingsStore 用户设置、外观、音量、壁纸、登录项 窗口状态、通知数据 localStorage
fsStore 虚拟文件系统树、当前目录 应用注册信息 localStorage
wmStore 窗口列表、activeWin、z-index、cascade 窗口 DOM 元素 仅会话
appStore 应用注册表、打开队列 应用内部状态 仅会话
notifyStore 通知列表、已读状态 通知 DOM、横幅定时器 localStorage
uiStore 菜单/对话框显隐、位置、回调 菜单/对话框 DOM 仅会话

3.4.2 Store 间依赖规则

settingsStore  ← 无依赖(叶子 store
fsStore        ← 无依赖
notifyStore    ← 依赖 settingsStore权限检查
appStore       ← 依赖 wmStoreopen 时创建窗口)
wmStore        ← 依赖 settingsStore可用区域计算、appStore窗口应用信息
uiStore        ← 无依赖

规则:禁止 Store 间循环依赖。如果 A 需要 B 的数据而 B 也需要 A 的数据,应提取到第三个 Store C 或使用事件总线解耦。

3.4.3 Pinia 插件:持久化

// src/stores/plugins/persist.ts
import type { PiniaPluginContext } from 'pinia'

const PERSISTED_STORES = ['settings', 'fs', 'notify']

export function persistPlugin({ store }: PiniaPluginContext) {
  if (!PERSISTED_STORES.includes(store.$id)) return

  // 初始化:从 localStorage 恢复
  const saved = localStorage.getItem(`macos-web:${store.$id}`)
  if (saved) {
    try { store.$patch(JSON.parse(saved)) } catch { /* ignore */ }
  }

  // 自动持久化
  store.$subscribe((_, state) => {
    localStorage.setItem(`macos-web:${store.$id}`, JSON.stringify(state))
  }, { detached: true })
}

3.5 事件通信设计

3.5.1 通信方式选型

场景 方式 示例
父子组件通信 props + emits <WindowFrame :win="w" @close="onClose" />
祖先→后代(跨层级) provide / inject 窗口级上下文win 对象、clipboard 等)
兄弟/任意组件(通知类) mitt 事件总线 emitter.emit('fs:changed', { paths })
全局状态共享 Pinia store settingsStore.settings.darkMode
浏览器级事件 composable 封装 useKeyboard() 监听 keydown

3.5.2 mitt 事件契约

// src/composables/useEventBus.ts
import mitt from 'mitt'

// ⚠️ 所有事件必须在此声明类型,保证端到端类型安全
type Events = {
  // 窗口事件
  'wm:focus':    { winId: string; appId: string }
  'wm:changed':  void
  'wm:closed':   { winId: string }

  // 文件系统事件
  'fs:changed':  { paths: string[] }
  'trash:changed': void

  // 系统事件
  'apps:ready':  void
  'volume:changed': number
  'unlocked':    void
  'locked':      void

  // 应用间事件
  'spotlight:open':  void
  'spotlight:close': void
}

export const emitter = mitt<Events>()

3.5.3 provide / inject 设计

// 窗口级上下文 —— 由 WindowFrame 提供,所有应用组件注入

// src/composables/useWindowContext.ts
import { provide, inject, type InjectionKey, type Ref } from 'vue'

interface WindowContext {
  winId: string
  appId: string
  clipboard: Ref<string>       // 应用内剪贴板
  isActive: Ref<boolean>       // 窗口是否聚焦
  closeWindow: () => void
  setTitle: (title: string) => void
}

export const WIN_CTX_KEY: InjectionKey<WindowContext> = Symbol('winCtx')

export function provideWindowContext(ctx: WindowContext) {
  provide(WIN_CTX_KEY, ctx)
}

export function useWindowContext(): WindowContext {
  const ctx = inject(WIN_CTX_KEY)
  if (!ctx) throw new Error('useWindowContext() must be used inside a WindowFrame')
  return ctx
}

3.6 组件分类与职责

quadrantChart
    title 组件分类矩阵
    x-axis "有状态" --> "无状态"
    y-axis "有 DOM" --> "无 DOM"
    quadrant-1 "容器组件"
    quadrant-2 "展示组件"
    quadrant-3 "逻辑 composable"
    quadrant-4 "纯工具函数"
    "WindowFrame": [0.2, 0.85]
    "MenuBar": [0.25, 0.9]
    "Dock": [0.3, 0.9]
    "Spotlight": [0.25, 0.85]
    "ControlCenter": [0.2, 0.85]
    "Finder": [0.35, 0.8]
    "TrafficLights": [0.85, 0.55]
    "DockIcon": [0.75, 0.6]
    "Banner": [0.9, 0.5]
    "CCToggle": [0.85, 0.55]
    "useDrag": [0.95, 0.15]
    "useKeyboard": [0.9, 0.1]
    "useTheme": [0.85, 0.1]
    "useClipboard": [0.95, 0.05]
    "emitter": [0.98, 0.02]
    "stdMenus": [0.98, 0.02]
分类 特征 示例 规则
容器组件 连接 Store管理子组件状态有 DOM 输出 WindowFrame, MenuBar, Dock, Spotlight 可访问 Pinia store可 emit 事件
展示组件 纯 props 输入 + emits 输出,无 Store 依赖 TrafficLights, DockIcon, Banner, CCToggle 禁止直接访问 Pinia store
逻辑 composable 封装有状态逻辑,无 DOM 输出 useDrag, useKeyboard, useTheme, useClipboard 可访问 Pinia store不输出 DOM
纯工具函数 纯函数,无副作用,无状态 stdMenus, fmtDateCN, clamp 禁止访问任何 store 或 DOM

3.7 目录结构设计

src/
├── main.ts                          # 入口createApp + Pinia + 全局样式
├── App.vue                          # 根布局组件
│
├── stores/                          # Pinia 状态管理
│   ├── index.ts                     # createPinia + 插件注册
│   ├── plugins/
│   │   └── persist.ts               # 持久化插件
│   ├── settings.ts                  # useSettingsStore
│   ├── fs.ts                        # useFSStore
│   ├── wm.ts                        # useWMStore
│   ├── apps.ts                      # useAppStore
│   ├── notify.ts                    # useNotifyStore
│   └── ui.ts                        # useUIStore
│
├── composables/                     # 纯逻辑组合式函数(无 DOM
│   ├── useEventBus.ts               # mitt 实例 + 类型定义
│   ├── useWindowContext.ts          # provide/inject 窗口上下文
│   ├── useKeyboard.ts               # 全局快捷键
│   ├── useIdleWatch.ts              # 空闲计时 & 屏保触发
│   ├── useTheme.ts                  # 暗色模式 & 壁纸
│   ├── useVolume.ts                 # 音量 & 媒体注册
│   ├── useDrag.ts                   # 窗口拖拽pointer events 封装)
│   ├── useResize.ts                 # 窗口缩放pointer events 封装)
│   ├── useClipboard.ts              # 剪贴板操作封装
│   ├── useClickOutside.ts           # 外部点击检测
│   ├── useStdMenus.ts               # 标准菜单模板(纯函数)
│   └── useDockMag.ts                # Dock 放大效果
│
├── components/                      # Vue 组件
│   ├── system/                      # 系统 UI 组件
│   │   ├── MenuBar.vue              #   菜单栏(容器)
│   │   ├── MenuBarItem.vue          #   菜单栏单项(展示)
│   │   ├── Dock.vue                 #   程序坞(容器)
│   │   ├── DockIcon.vue             #   程序坞图标(展示)
│   │   ├── DesktopIcons.vue         #   桌面图标区(容器)
│   │   ├── DesktopIcon.vue          #   单个桌面图标(展示)
│   │   ├── Spotlight.vue            #   聚焦搜索(容器)
│   │   ├── SpotlightItem.vue        #   搜索结果项(展示)
│   │   ├── ControlCenter.vue        #   控制中心(容器)
│   │   ├── CCToggle.vue             #   控制中心开关(展示)
│   │   ├── CCSlider.vue             #   控制中心滑块(展示)
│   │   ├── NotificationCenter.vue   #   通知中心(容器)
│   │   ├── BannerContainer.vue      #   横幅容器(容器)
│   │   ├── Banner.vue               #   单个横幅(展示)
│   │   ├── LockScreen.vue           #   锁屏界面(容器)
│   │   ├── Screensaver.vue          #   屏保(容器)
│   │   ├── BootScreen.vue           #   启动动画(展示)
│   │   └── PowerOff.vue             #   关机提示(展示)
│   ├── wm/                          # 窗口管理组件
│   │   ├── WindowLayer.vue          #   窗口层容器(容器)
│   │   ├── WindowFrame.vue          #   窗口框架(容器)
│   │   ├── TrafficLights.vue        #   红绿灯按钮(展示)
│   │   └── AppLoader.vue            #   应用加载器(容器)
│   └── ui/                          # 通用 UI 组件
│       ├── ContextMenu.vue          #   右键/下拉菜单(容器)
│       ├── ContextMenuItem.vue      #   菜单项(展示)
│       ├── DialogLayer.vue          #   对话框层(容器)
│       ├── AlertDialog.vue          #   警告框(展示)
│       ├── ConfirmDialog.vue        #   确认框(展示)
│       └── PromptDialog.vue         #   输入框(展示)
│
├── apps/                            # 应用层
│   ├── loaders.ts                   # 懒加载映射 + 预加载策略
│   ├── finder/
│   │   ├── index.ts                 # AppDefinition纯数据
│   │   └── Finder.vue               # Finder SFC
│   ├── safari/
│   │   ├── index.ts
│   │   └── Safari.vue
│   └── ...(其余 31 个应用,结构相同)
│
├── styles/                          # 样式(不变)
│   ├── base.css
│   ├── desktop.css
│   ├── window.css
│   ├── apps.css
│   └── apps2.css
│
├── types/                           # 全局类型定义
│   ├── app.ts                       # AppDefinition, MenuItem
│   ├── window.ts                    # WinState, Rect
│   ├── fs.ts                        # FSNode, FSEntry
│   └── settings.ts                  # Settings, Wallpaper
│
└── utils/                           # 纯工具函数(无 DOM
    ├── format.ts                    # fmtDateCN, fmtTime, fmtBytes
    ├── math.ts                      # clamp
    ├── misc.ts                      # uid, debounce, esc
    └── index.ts                     # 统一导出

3.8 关键技术决策

决策点 选择 理由
路由方案 不使用 vue-router 桌面模拟器无 URL 路由需求,应用切换由 wmStore 窗口管理 + AppLoader 动态组件实现
状态管理 Pinia(非 Vuex Vue 3 官方推荐,完整 TS 支持,模块化设计
跨组件通信 mitt + provide/inject mitt 用于全局通知provide/inject 用于窗口级上下文,避免过度使用全局事件
CSS 方案 纯 CSS不变 项目样式已成熟,无需引入 Tailwind/UnoCSS减少重构范围
构建工具 Vite不变 当前已使用 Vite保留配置
测试框架 Vitest不变 当前已使用 Vitest
窗口拖拽/缩放 原生 Pointer Events + composable 封装 这是性能关键路径Vue 事件系统不适合高频 pointermove
持久化 Pinia watch + localStorage 简单可靠,无需引入额外的持久化库
应用加载 defineAsyncComponent + 分级策略 Eager/IdlePreload/Lazy 三级策略兼顾首屏速度与体验
类型安全 全局禁止 anyeslint rule 强制所有接口、store、事件使用精确类型

3.9 性能策略

flowchart TB
    subgraph "首屏加载"
        A1["仅加载 Eager 应用Finder"]
        A2["系统 UI 组件同步渲染"]
        A3["requestIdleCallback 预加载 7 个常用应用"]
    end

    subgraph "窗口操作"
        B1["打开应用 → 检查是否已加载"]
        B2["未加载 → Suspense + loading 占位"]
        B3["已加载 → 立即显示 &lt;component :is=&gt;"]
    end

    subgraph "关闭优化"
        C1["关闭窗口 → 组件 v-if 卸载"]
        C2["已加载的异步 chunk 保留在浏览器缓存"]
        C3["再次打开 → 不重新下载 JS"]
    end

    subgraph "内存管理"
        D1["窗口关闭 → clearInterval/timeout"]
        D2["onUnmounted → 移除事件监听"]
        D3["Pinia store 按需持久化,不存储 DOM 引用"]
    end

    A1 --> A2 --> A3
    B1 --> B2
    B1 --> B3
    C1 --> C2 --> C3

4. 重构总路线

Phase 1: 基础设施层      Phase 2: 系统 UI 组件化     Phase 3: WM 现代化
─────────────────────    ────────────────────────    ────────────────
Pinia stores             MenuBar → SFC               WindowFrame → SFC
mitt 事件总线             Dock → SFC                  TrafficLights → SFC
消除 window 全局暴露      DesktopIcons → SFC          拖拽/缩放 composable
消除 lazy ref             Spotlight → SFC             resize handles → SFC
                         NotificationCenter → SFC
                         ControlCenter → SFC         Phase 5: 清理
                         LockScreen → SFC            ───────────
                         Screensaver → SFC           删除 utils/index.ts
                         BootScreen → SFC            删除 system/index.ts
                         ContextMenu → SFC           删除 el() 函数
                         Dialog/Alert/Confirm → SFC  删除 $/$$
                                                     全局类型检查

                         Phase 4: 应用层优化
                         ─────────────────────
                         defineAsyncComponent
                         移除 (window as any).Sys
                         移除 h()/render() 手动挂载
                         stdMenus 纯函数化

5. 阶段一:基础设施层

4.1 建立 Pinia Stores

4.1.1 settingsStore — 替代 useSettings + useStore

// src/stores/settings.ts
import { defineStore } from 'pinia'
import { reactive, watch } from 'vue'

export const WALLPAPERS = [ /* 原数据 */ ]
export const DEFAULT_SETTINGS = { /* 原数据 */ }

export const useSettingsStore = defineStore('settings', () => {
  const settings = reactive(structuredClone(DEFAULT_SETTINGS))

  // 持久化 watch替代 store.set/load
  watch(settings, (v) => {
    localStorage.setItem('macos-web:settings', JSON.stringify(v))
  }, { deep: true })

  // 计算属性
  const isDark = computed(() => { /* 原 Sys.isDark() */ })
  const wallpaperSrc = computed(() => { /* 原 Sys.wallpaperSrc() */ })

  // 方法
  function applyAppearance() { /* 操作 document.body.classList */ }
  function applyWallpaper() { /* ... */ }

  return { settings, isDark, wallpaperSrc, applyAppearance, applyWallpaper }
})

改动要点

  • store.get/set → Pinia $state + watch 持久化
  • Sys.settings.xxxsettingsStore.settings.xxx
  • Sys.isDark()settingsStore.isDark (computed)
  • Sys.save() → 自动 watch 持久化

4.1.2 fsStore — 替代 useFS

// src/stores/fs.ts
export const useFSStore = defineStore('fs', () => {
  const root = ref<FSNode | null>(null)
  const HOME = '/Users/guest'
  const TRASH = computed(() => HOME + '/.Trash')

  function init() { /* 原 fs.init() */ }
  function list(path: string) { /* ... */ }
  function node(path: string) { /* ... */ }
  function mkdir(path: string) { /* ... */ }
  function rename(path: string, newName: string) { /* ... */ }
  function trash(path: string) { /* ... */ }
  function emptyTrash() { /* ... */ }

  // 用 mitt 替代 bus.emit('fs:changed')
  function notifyChange(paths: string[]) {
    emitter.emit('fs:changed', { paths })
  }

  return { root, HOME, TRASH, init, list, node, mkdir, rename, trash, emptyTrash }
})

改动要点

  • fs.node(path)fsStore.node(path),响应式数据
  • bus.emit('fs:changed')emitter.emit('fs:changed')mitt
  • 文件系统树用 ref / reactive 保持响应式

4.1.3 wmStore — 替代 useWM 的状态部分

// src/stores/wm.ts
export const useWMStore = defineStore('wm', () => {
  const windows = reactive<WinState[]>([])
  const activeWin = ref<WinState | null>(null)
  const zTop = ref(100)
  const cascadeCount = ref(0)

  function addWindow(win: WinState) { windows.push(win) }
  function removeWindow(id: string) { /* splice */ }
  function focus(win: WinState) { /* 更新 z-index + activeWin */ }

  return { windows, activeWin, zTop, cascadeCount, addWindow, removeWindow, focus }
})

改动要点

  • WM windows/activeWin/zTop → Pinia store
  • 窗口 DOM 创建逻辑分离到 <WindowFrame> 组件

4.1.4 appStore — 替代 useApps 注册表

// src/stores/apps.ts
export const useAppStore = defineStore('apps', () => {
  const registry = reactive<Record<string, AppDefinition>>({})

  function register(def: AppDefinition) { registry[def.id] = def }
  function get(id: string) { return registry[id] }

  // open 方法:不再手动调用 wm.openWindow + h() + render()
  // 而是设置一个"待打开"信号,由 <AppLoader> 组件消费
  const pendingOpen = ref<{ id: string; args?: any } | null>(null)

  function open(id: string, args?: any) {
    pendingOpen.value = { id, args }
  }

  return { registry, register, get, pendingOpen, open }
})

4.1.5 notifyStore — 替代 useNotify 数据部分

// src/stores/notify.ts
export const useNotifyStore = defineStore('notify', () => {
  const notifications = ref<Notification[]>([])

  function send(n: Notification) { /* push + 持久化 */ }
  function markRead(id: string) { /* ... */ }
  function remove(id: string) { /* ... */ }
  function clearAll() { /* ... */ }
  function badgeCount(appId: string) { /* computed */ }

  return { notifications, send, markRead, remove, clearAll, badgeCount }
})

4.2 引入 mitt 替代 useBus

pnpm add mitt
// src/composables/useEventBus.ts
import mitt from 'mitt'

type Events = {
  'wm:focus': WinState
  'wm:changed': void
  'fs:changed': { paths: string[] }
  'apps:ready': void
  'volume:changed': number
  'trash:changed': void
  // ... 所有事件类型
}

export const emitter = mitt<Events>()

替代映射

原 bus 用法 新 mitt 用法
bus.on('wm:focus', fn) emitter.on('wm:focus', fn)
bus.emit('wm:focus', win) emitter.emit('wm:focus', win)
bus.off('wm:focus', fn) emitter.off('wm:focus', fn)

4.3 消除 window 全局暴露

删除 system/index.ts 中的所有 (window as any) 赋值

替代方案:

  • 模块间引用 → Pinia store / provide-inject
  • App 组件引用 → 通过 useAppStore().get(id) 获取
  • 工具函数引用 → 直接 import

4.4 消除 lazy ref

删除所有 let _Xxx: any = null + setXxxRef() 模式

原懒引用的用途和替代方案:

原用途 替代
setSysRef(wm) — WM 需要 Sys.settings WM 直接 import useSettingsStore
setSysForNotify(sys) — Notify 需要 Sys.settings Notify 直接 import useSettingsStore
setSysForSpotlight(sys) — Spotlight 需要 Sys Spotlight 直接 import 对应 store
setAppsRef(apps) — FS 需要 Apps 注册表 FS 直接 import useAppStore
setAppStoreRef(a) — Sys 需要 AppStore 延迟访问 useAppStore().get('appstore')

解决循环依赖的根本方法:拆分模块。例如 useSys.ts 应该拆分为:

  • stores/settings.ts — 设置管理
  • composables/useKeyboard.ts — 全局快捷键
  • composables/useIdleWatch.ts — 空闲计时/屏保
  • composables/useTheme.ts — 外观/壁纸应用
  • <MenuBar> / <Dock> / <LockScreen> 等 Vue 组件

6. 阶段二:系统 UI 组件化

5.1 <MenuBar> — 替代 Sys.buildMenubar()

原实现useSys.tsbuildMenubar()el() 构建整个菜单栏 DOM操作 #menubar-left / #menubar-right

目标实现

<!-- src/components/system/MenuBar.vue -->
<template>
  <header id="menubar" :class="{ hidden: !unlocked, autohide: fullscreenWin }">
    <div id="menubar-left">
      <!-- Apple 菜单 -->
      <MenuBarItem
        is-apple
        @click="openAppleMenu"
      />
      <!-- 当前应用名 + 应用菜单 -->
      <MenuBarItem
        v-for="menu in appMenus"
        :key="menu.label"
        :label="menu.label"
        :items="menu.items"
        @click="toggleMenu(menu)"
      />
    </div>
    <div id="menubar-right">
      <MenuBarItem icon="battery" @click="toggleControlCenter" />
      <MenuBarItem icon="wifi" @click="toggleControlCenter" />
      <MenuBarItem icon="spotlight" @click="spotlightStore.toggle()" />
      <MenuBarItem icon="control-center" @click="toggleControlCenter" />
      <MenuBarItem
        id="mb-clock"
        :label="clockText"
        @click="toggleNotificationCenter"
      />
    </div>
  </header>
</template>

<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import { useAppStore } from '@/stores/apps'
import { useWMStore } from '@/stores/wm'
import MenuBarItem from './MenuBarItem.vue'

const settingsStore = useSettingsStore()
const appStore = useAppStore()
const wmStore = useWMStore()

const clockText = ref('')
let clockTimer: ReturnType<typeof setInterval>

function tickClock() {
  clockText.value = fmtMenuClock(new Date(), settingsStore.settings.h24)
}

onMounted(() => {
  tickClock()
  clockTimer = setInterval(tickClock, 1000)
})
onUnmounted(() => clearInterval(clockTimer))
</script>

改动要点

  • el() 构建 DOM → Vue 模板循环
  • (this as any)._mbClockref + setInterval
  • addEventListener('click')@click
  • 菜单栏左侧应用菜单 → 根据 wmStore.activeWin 计算

5.2 <Dock> — 替代 Sys.renderDock()

<!-- src/components/system/Dock.vue -->
<template>
  <div id="dock-hotzone" :class="dockPosition" />
  <nav id="dock" :class="dockClasses" @contextmenu="onBgContextMenu">
    <DockIcon
      v-for="appId in dockItems"
      :key="appId"
      :app="appStore.get(appId)"
      :badge="notifyStore.badgeCount(appId)"
      :running="wmStore.windowsForApp(appId).length > 0"
      @click="onDockClick(appId)"
      @contextmenu="onIconContextMenu($event, appId)"
    />
    <div class="dock-sep" />
    <DockIcon
      app-id="__trash"
      :icon="trashIcon"
      name="废纸篓"
      @click="appStore.open('finder', { path: fsStore.TRASH })"
      @contextmenu="onTrashContextMenu"
    />
  </nav>
</template>

改动要点

  • dock.innerHTML = '' + el() 循环构建 → v-for 组件
  • ic.addEventListener('click')@click emit
  • ic.addEventListener('contextmenu')@contextmenu emit
  • Dock 放大动画用 CSS + 少量 pointer 事件(封装 composable
  • updateDockDots() → 基于 wmStore.windows 的 computed

5.3 <DesktopIcons> — 替代 Sys.renderDesktopIcons()

<!-- src/components/system/DesktopIcons.vue -->
<template>
  <div id="desktop-icons">
    <DesktopIcon
      v-for="item in desktopItems"
      :key="item.path"
      :item="item"
      :position="positions[item.name]"
      @dblclick="appStore.openPath(item.path)"
      @contextmenu="onIconContextMenu($event, item)"
    />
  </div>
</template>

改动要点

  • 每个桌面图标是一个独立的 <DesktopIcon> SFC
  • 拖拽移动用 pointer events保留原生封装在 composable 中)
  • 双击/右键 → emit 到父组件处理

5.4 <ContextMenu> / <DialogLayer> — 替代 useUI

这是最关键的变革之一:将动态创建的弹出菜单和对话框改为 Vue 组件 + Teleport

<!-- src/components/system/ContextMenu.vue -->
<template>
  <Teleport to="body">
    <div
      v-if="visible"
      class="menu-pop"
      :style="{ left: x + 'px', top: y + 'px' }"
      role="menu"
    >
      <template v-for="item in items" :key="item.label">
        <div v-if="item.sep" class="menu-sep" />
        <div
          v-else
          class="menu-item"
          :class="{ disabled: item.disabled, hover: hovered === item.label }"
          @click="onClick(item)"
          @pointerenter="hovered = item.label; onHover(item)"
        >
          <span class="mi-check">{{ item.checked ? '✓' : '' }}</span>
          <span class="mi-label">{{ item.label }}</span>
          <span v-if="item.key" class="mi-key">{{ item.key }}</span>
          <span v-if="item.submenu" class="mi-sub"></span>
        </div>
      </template>
    </div>
  </Teleport>
</template>

全局菜单/对话框管理

// src/stores/ui.ts
export const useUIStore = defineStore('ui', () => {
  // 弹出菜单
  const menuVisible = ref(false)
  const menuItems = ref<MenuItem[]>([])
  const menuPosition = ref({ x: 0, y: 0 })
  const menuOnClose = ref<(() => void) | null>(null)

  function showMenu(items: MenuItem[], x: number, y: number, onClose?: () => void) {
    closeMenu()
    menuItems.value = items
    menuPosition.value = { x, y }
    menuVisible.value = true
    menuOnClose.value = onClose ?? null
  }
  function closeMenu() {
    menuOnClose.value?.()
    menuVisible.value = false
  }

  // 对话框
  const dialogVisible = ref(false)
  const dialogConfig = ref({ /* title, msg, buttons, ... */ })
  let dialogResolve: ((v: any) => void) | null = null

  function showDialog(config: DialogConfig): Promise<any> {
    return new Promise(resolve => {
      dialogConfig.value = config
      dialogVisible.value = true
      dialogResolve = resolve
    })
  }

  return { menuVisible, menuItems, menuPosition, showMenu, closeMenu,
           dialogVisible, dialogConfig, showDialog, /* ... */ }
})

改动要点

  • ui.menu(items, x, y)uiStore.showMenu(items, x, y)
  • ui.dialog({...})uiStore.showDialog({...}),返回 Promise 不变
  • ui.alert/confirm/prompt → 基于 showDialog 的语法糖
  • 菜单嵌套submenu用递归组件
  • 全局 pointerdown 关闭菜单 → 在 <ContextMenu> 组件内部用 onClickOutside

5.5 <Spotlight> — 替代 useSpotlight

<!-- src/components/system/Spotlight.vue -->
<template>
  <Teleport to="#spotlight-anchor">
    <div v-if="visible" id="spotlight" role="dialog" @click.self="close">
      <div id="spotlight-box">
        <div id="spotlight-input-row">
          <svg><!-- 搜索图标 --></svg>
          <input
            ref="inputRef"
            v-model="query"
            type="text"
            placeholder="聚焦搜索"
            @keydown="onKeydown"
          />
        </div>
        <div id="spotlight-results">
          <SpotlightItem
            v-for="(item, idx) in results"
            :key="idx"
            :item="item"
            :selected="idx === selectedIdx"
            @click="item.action(); close()"
            @hover="selectedIdx = idx"
          />
        </div>
      </div>
    </div>
  </Teleport>
</template>

改动要点

  • HTML 结构在模板中定义(当前已在 SpotlightArea.vue 中有静态模板,但内容仍由 JS 动态填充)
  • input.oninputv-model + watch
  • input.onkeydown@keydown
  • render() 中的 el() 构建 → computed results + v-for
  • scrollIntoViewnextTick + Vue 方式滚动

5.6 <NotificationCenter> / <BannerContainer> — 替代 useNotify 渲染

<!-- src/components/system/BannerContainer.vue -->
<template>
  <div id="banner-container">
    <TransitionGroup name="banner">
      <Banner
        v-for="notif in activeBanners"
        :key="notif.id"
        :notif="notif"
        @click="onBannerClick(notif)"
      />
    </TransitionGroup>
  </div>
</template>

改动要点

  • el() 创建横幅 DOM → <Banner> SFC
  • setTimeout 自动消失 → watch + setTimeout,配合 <TransitionGroup>
  • 通知中心 nc.innerHTML = '' 重建 → computed 列表 + v-for

5.7 <LockScreen> / <Screensaver> / <BootScreen> / <PowerOff>

这些容器组件当前已有 Vue 模板(如 LockScreenArea.vue),但内部内容(锁屏按钮、屏保幻灯片等)全由 el() 动态构建。需改为:

  • <LockScreen> 模板中包含完整的 HTML 结构,用 v-if/v-show 控制显示
  • <Screensaver> 中的时钟/幻灯片 → 用计算属性 + v-if 选择类型
  • <BootScreen> 中的进度条动画 → CSS transition + v-show

5.8 <ControlCenter> — 替代 Sys.renderControlCenter()

<!-- src/components/system/ControlCenter.vue -->
<template>
  <Teleport to="#control-center-anchor">
    <div v-if="visible" id="control-center">
      <div class="cc-card">
        <CCToggle icon="wifi" label="Wi-Fi" v-model="settings.wifi" />
        <CCToggle icon="bluetooth" label="蓝牙" v-model="settings.bluetooth" />
        <CCToggle icon="airdrop" label="隔空投送" v-model="settings.airdrop" />
      </div>
      <div class="cc-card span2">
        <div class="cc-title">显示器</div>
        <CCSlider v-model="settings.brightness" :min="0.2" :max="1" />
      </div>
      <!-- ... -->
    </div>
  </Teleport>
</template>

改动要点

  • ccToggle() 函数生成 DOM → <CCToggle> SFCv-model 双向绑定
  • cc.innerHTML = '' 重建 → 响应式数据驱动
  • 外点击关闭 → onClickOutside 指令/composable

7. 阶段三:窗口管理器现代化

6.1 <WindowFrame> — 替代 useWM 的窗口 chrome 构建

原实现openWindow() 中用 el() 创建 traffic lights、标题栏、body、resize handles全部命令式。

目标实现

<!-- src/components/wm/WindowFrame.vue -->
<template>
  <div
    ref="winEl"
    class="window"
    :class="[win.state, { inactive: !isActive, opening: isNew }]"
    :style="winStyle"
    @pointerdown="onFocus"
  >
    <!-- 标题栏 -->
    <div class="win-titlebar" @pointerdown="onDragStart" @dblclick="onToggleZoom">
      <TrafficLights
        :win="win"
        @close="$emit('close', win.id)"
        @minimize="$emit('minimize', win.id)"
        @maximize="$emit('toggleFullscreen', win.id)"
      />
      <div class="win-title">
        <img :src="win.icon" alt="" />
        <span class="t">{{ win.title }}</span>
      </div>
    </div>

    <!-- 应用内容区域 -->
    <div ref="bodyEl" class="win-body">
      <slot />
    </div>

    <!-- Resize Handles -->
    <template v-if="!win.noResize">
      <div
        v-for="dir in resizeDirections"
        :key="dir"
        :class="`rz ${dir}`"
        @pointerdown.stop="onResizeStart($event, dir)"
      />
    </template>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import TrafficLights from './TrafficLights.vue'
import { useDrag } from '@/composables/useDrag'
import { useResize } from '@/composables/useResize'

const props = defineProps<{
  win: WinState
  isActive: boolean
}>()

const emit = defineEmits(['close', 'minimize', 'toggleFullscreen', 'focus'])

const winEl = ref<HTMLElement>()
const bodyEl = ref<HTMLElement>()

// 拖拽逻辑封装为 composable
const { onDragStart } = useDrag(winEl, props.win)

// 缩放逻辑封装为 composable
const { onResizeStart } = useResize(winEl, props.win)

const winStyle = computed(() => ({
  left: props.win.rect.x + 'px',
  top: props.win.rect.y + 'px',
  width: props.win.rect.w + 'px',
  height: props.win.rect.h + 'px',
  zIndex: props.win.zIndex,
}))

function onFocus() {
  emit('focus', props.win.id)
}

// 暴露 body 元素给父组件(供应用挂载)
defineExpose({ bodyEl })
</script>

6.2 <TrafficLights> — 独立组件

<!-- src/components/wm/TrafficLights.vue -->
<template>
  <div class="traffic-lights">
    <button class="tl close" aria-label="关闭" @click.stop="$emit('close')">
      <svg viewBox="0 0 8 8"><!-- X --></svg>
    </button>
    <button class="tl min" aria-label="最小化" @click.stop="$emit('minimize')">
      <svg viewBox="0 0 8 8"><!--  --></svg>
    </button>
    <button class="tl max" aria-label="全屏" @click.stop="$emit('maximize')">
      <svg viewBox="0 0 8 8"><!--  --></svg>
    </button>
  </div>
</template>

6.3 <WindowLayer> — 容器组件

<!-- src/components/wm/WindowLayer.vue -->
<template>
  <div id="window-layer">
    <WindowFrame
      v-for="win in sortedWindows"
      :key="win.id"
      :win="win"
      :is-active="win.id === wmStore.activeWin?.id"
      @focus="wmStore.focus(win.id)"
      @close="wmStore.close(win.id)"
      @minimize="wmStore.minimize(win.id)"
      @toggle-fullscreen="wmStore.toggleFullscreen(win.id)"
    >
      <!-- 动态加载应用组件 -->
      <AppLoader :win="win" />
    </WindowFrame>
  </div>
</template>

6.4 拖拽/缩放 composable

拖拽和缩放必须用原生 pointer events这是性能关键路径不可用 Vue 事件替代),但封装为纯 composable

// src/composables/useDrag.ts
export function useDrag(elRef: Ref<HTMLElement | undefined>, win: WinState) {
  function onDragStart(e: PointerEvent) {
    if (win.state === 'fullscreen') return
    const el = elRef.value!
    el.setPointerCapture(e.pointerId)

    const startX = e.clientX
    const startY = e.clientY
    const origX = win.rect.x
    const origY = win.rect.y

    function onMove(e: PointerEvent) {
      win.rect.x = origX + e.clientX - startX
      win.rect.y = origY + e.clientY - startY
    }

    function onUp() {
      el.removeEventListener('pointermove', onMove)
      el.removeEventListener('pointerup', onUp)
    }

    el.addEventListener('pointermove', onMove)
    el.addEventListener('pointerup', onUp)
  }

  return { onDragStart }
}
// src/composables/useResize.ts
export function useResize(elRef: Ref<HTMLElement | undefined>, win: WinState) {
  function onResizeStart(e: PointerEvent, dir: string) {
    // 类似 useDrag但修改 width/height/x/y
  }
  return { onResizeStart }
}

6.5 窗口关闭流程现代化

close() 方法的 Promise + confirm 流程保持不变,但 closePromise 管理移到 wmStore

// stores/wm.ts
async function close(winId: string): Promise<string> {
  const win = windows.find(w => w.id === winId)
  if (!win) return 'alreadyClosed'

  // 如果有确认回调
  if (win.confirmClose) {
    return new Promise(resolve => {
      win.confirmClose!(
        () => { doClose(win); resolve('closed') },
        () => resolve('cancelled'),
      )
    })
  }

  doClose(win)
  return 'closed'
}

8. 阶段四:应用层优化

7.1 <AppLoader> — 替代手动 h() + render()

原实现:每个 app 的 index.ts 中:

render(win) {
  const vnode = h(XxxComponent, { win })
  render(vnode, win.body)
}

目标实现:用 defineAsyncComponent + <component :is=""> 动态加载:

<!-- src/components/wm/AppLoader.vue -->
<template>
  <component
    v-if="appComponent"
    :is="appComponent"
    :win="win"
  />
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/stores/apps'

const props = defineProps<{ win: WinState }>()
const appStore = useAppStore()

// 动态懒加载应用组件
const appComponent = computed(() => {
  const appId = props.win.appId
  const loader = appLoaders[appId]
  return loader || null
})
</script>

应用懒加载映射表:

// src/apps/loaders.ts
import { defineAsyncComponent } from 'vue'

export const appLoaders: Record<string, any> = {
  finder:     defineAsyncComponent(() => import('./finder/Finder.vue')),
  safari:     defineAsyncComponent(() => import('./safari/Safari.vue')),
  calculator: defineAsyncComponent(() => import('./calculator/Calculator.vue')),
  settings:   defineAsyncComponent(() => import('./settings/Settings.vue')),
  // ... 33 个应用
}

7.2 应用注册简化

index.ts

export const FinderApp = { id:'finder', ..., render(win) { ... } }
Apps.register(FinderApp)

改为纯数据定义

// src/apps/finder/index.ts
import type { AppDefinition } from '@/stores/apps'

export const finderDef: AppDefinition = {
  id: 'finder',
  name: '访达',
  icon: '/assets/icons/finder.png',
  w: 800, h: 550,
  minW: 480, minH: 300,
  singleton: true,
  menus(win) { return stdMenus('finder', { /* ... */ }) },
  // 不再需要 render 方法!
}
// src/stores/apps.ts
import { finderDef } from '@/apps/finder'

// 在 store 初始化时注册
function init() {
  register(finderDef)
  register(safariDef)
  // ... 33 个应用
}

关键变化:每个 app 不再包含 render(win) 方法,而是由 <AppLoader> 根据 appId 查找对应的异步组件来渲染。

7.3 移除应用中的 (window as any)

当前多个应用访问 (window as any).Sys / (window as any).Notify 等:

文件 全局引用 替代方案
finder/Finder.vue (window as any).__finderClipboard Pinia store 或 provide/inject
safari/Safari.vue window.open(url, '_blank') 允许(无替代方案)
calculator/Calculator.vue props.win.el?.addEventListener @keydown 在模板中处理
多个应用 bus.on('fs:changed') emitter.on('fs:changed')
多个应用 ui.alert/confirm/prompt uiStore.showDialog()
多个应用 wm.setTitle(props.win, ...) wmStore.setTitle(winId, title)
多个应用 props.win.appState = st 通过 win.data 或 provide

7.4 stdMenus() 纯函数化

原实现使用 document.activeElement / document.execCommand

// 原:直接访问 document
const editBase = () => {
  const a = document.activeElement as any
  const isText = a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable)
  // ...
}

改进:接受 isTextFocused: boolean 参数,由调用方传入:

// src/composables/useStdMenus.ts
export function stdMenus(appDef: AppDef, options: StdMenuOptions = {}) {
  return function (win: WinState | null) {
    return [
      {
        label: '编辑',
        items: () => [
          { label: '剪切', key: '⌘X', action: () => clipboard.cut() },
          { label: '拷贝', key: '⌘C', action: () => clipboard.copy() },
          { label: '粘贴', key: '⌘V', action: () => clipboard.paste() },
          // ...
        ]
      },
      // ...
    ]
  }
}

剪贴板操作封装为独立的 useClipboard composable内部使用 navigator.clipboard API


9. 阶段五:清理与收尾

8.1 删除文件

删除:
  src/utils/index.ts          — el/$/$$ 函数,完全由 Vue 替代
  src/system/index.ts         — 启动序列逻辑分散到各初始化的 store/composable
  src/composables/useBus.ts   — 由 mitt 替代
  src/composables/useStore.ts — 由 Pinia 持久化插件替代
  src/composables/useUI.ts    — 由 <ContextMenu>/<DialogLayer> + uiStore 替代
  src/composables/useSys.ts   — 拆分为多个 store + composable + Vue 组件

8.2 重构后的目录结构

src/
├── main.ts
├── App.vue
├── stores/                          # Pinia stores新增
│   ├── settings.ts                  # 设置管理
│   ├── fs.ts                        # 虚拟文件系统
│   ├── wm.ts                        # 窗口管理器状态
│   ├── apps.ts                      # 应用注册表
│   ├── notify.ts                    # 通知数据
│   └── ui.ts                        # 菜单/对话框状态
├── composables/                     # 纯逻辑 composable无 DOM
│   ├── useKeyboard.ts               # 全局快捷键
│   ├── useIdleWatch.ts              # 空闲计时/屏保
│   ├── useTheme.ts                  # 外观/壁纸
│   ├── useVolume.ts                 # 音量/媒体控制
│   ├── useDrag.ts                   # 窗口拖拽pointer events 封装)
│   ├── useResize.ts                 # 窗口缩放pointer events 封装)
│   ├── useClipboard.ts              # 剪贴板操作封装
│   ├── useStdMenus.ts               # 标准菜单模板(纯函数)
│   ├── useEventBus.ts               # mitt 事件总线实例
│   └── useClickOutside.ts           # 外点击检测
├── components/
│   ├── system/                      # 系统 UI 组件
│   │   ├── MenuBar.vue
│   │   ├── MenuBarItem.vue
│   │   ├── Dock.vue
│   │   ├── DockIcon.vue
│   │   ├── DesktopIcons.vue
│   │   ├── DesktopIcon.vue
│   │   ├── Spotlight.vue
│   │   ├── SpotlightItem.vue
│   │   ├── ControlCenter.vue
│   │   ├── CCToggle.vue
│   │   ├── CCSlider.vue
│   │   ├── NotificationCenter.vue
│   │   ├── Banner.vue
│   │   ├── BannerContainer.vue
│   │   ├── LockScreen.vue
│   │   ├── Screensaver.vue
│   │   ├── BootScreen.vue
│   │   └── PowerOff.vue
│   ├── wm/                          # 窗口管理组件
│   │   ├── WindowLayer.vue
│   │   ├── WindowFrame.vue
│   │   ├── TrafficLights.vue
│   │   └── AppLoader.vue
│   └── ui/                          # 通用 UI 组件
│       ├── ContextMenu.vue
│       ├── ContextMenuItem.vue
│       ├── DialogLayer.vue
│       ├── AlertDialog.vue
│       ├── ConfirmDialog.vue
│       └── PromptDialog.vue
├── apps/                            # 应用层(基本不变,去掉 render 方法)
│   ├── loaders.ts                   # 懒加载映射表(新增)
│   ├── finder/
│   │   ├── index.ts                 # 仅数据定义AppDefinition
│   │   └── Finder.vue
│   ├── calculator/
│   │   ├── index.ts
│   │   └── Calculator.vue
│   └── ...33 个应用)
├── styles/                          # 不变
└── types/                           # 类型定义(从各模块提取)
    ├── app.ts
    ├── window.ts
    ├── fs.ts
    └── settings.ts

8.3 启动流程重构

system/index.tsinitSystem() → 手动串联 fs.init → Sys.init → Notify.init → Sys.renderDock → initDesktop → boot

:在 App.vueonMounted 中:

// App.vue
onMounted(async () => {
  // 1. 初始化 stores
  const fsStore = useFSStore()
  await fsStore.init()

  const settingsStore = useSettingsStore()
  settingsStore.init()

  const appStore = useAppStore()
  appStore.init()  // 注册所有 33 个应用

  const notifyStore = useNotifyStore()
  notifyStore.init()

  // 2. 初始化全局 composable
  useKeyboard()
  useIdleWatch()
  useTheme()

  // 3. 触发启动动画
  const sysStore = useSysStore()
  sysStore.boot()

  // 4. 通知应用就绪
  emitter.emit('apps:ready')
})

8.4 测试适配

测试文件(tests/cases/ 下 47 个)需要相应更新:

  • (window as any).Sys / (window as any).WM → 通过 Pinia store 操作
  • (window as any).FSuseFSStore()
  • (window as any).NotifyuseNotifyStore()
  • (window as any).AppsuseAppStore()
  • bus.on/emitemitter.on/emit

建议在 tests/setup.ts 中创建一个测试辅助工具,提供简化的 store 访问。


10. 附录:新旧对照表

9.1 文件映射

原文件 重构后 说明
src/utils/index.ts 删除 el()/$/$$ 由 Vue 模板/ref 替代;格式化函数移入 src/utils/format.ts
src/system/index.ts 删除 启动逻辑分散到 store 初始化和 App.vue
src/composables/useBus.ts 删除 src/composables/useEventBus.ts (mitt) 替代
src/composables/useStore.ts 删除 由 Pinia 持久化 watch 替代
src/composables/useUI.ts src/stores/ui.ts + src/components/ui/*.vue 拆为状态 store + Vue 组件
src/composables/useSys.ts 拆分为 7 个模块 见下一节
src/composables/useWM.ts src/stores/wm.ts + src/components/wm/*.vue + src/composables/useDrag.ts + src/composables/useResize.ts 状态/UI/拖拽分离
src/composables/useNotify.ts src/stores/notify.ts + src/components/system/Banner.vue + src/components/system/NotificationCenter.vue 数据与视图分离
src/composables/useSpotlight.ts src/stores/spotlight.ts + src/components/system/Spotlight.vue 数据与视图分离
src/composables/useApps.ts src/stores/apps.ts + src/composables/useStdMenus.ts 注册表 + 菜单模板分离
src/composables/useFS.ts src/stores/fs.ts 直接迁移到 Pinia
src/composables/useSettings.ts src/stores/settings.ts 合并 useStore 到 Pinia

9.2 useSys.ts 拆分

原负责内容 目标位置
settings / save / set / applyAll / isDark / wallpaperSrc / applyAppearance / applyWallpaper / applyBrightness / applyNightShift / applyVolume / registerMedia src/stores/settings.ts
boot / showLock / closeOverlays / powerOff / restart / logout / sleep / forceQuitDialog src/stores/sys.ts
globalKeys src/composables/useKeyboard.ts
initIdleWatch / resetIdle / showScreensaver / hideScreensaver src/composables/useIdleWatch.ts
buildMenubar / wifiSvg / tickClock / openAppleMenu / setActiveApp src/components/system/MenuBar.vue
dockPinned / dockItems / renderDock / effDockSize / layoutDock / dockPeek / dockHide / dockResetMag / initDockMag / updateDockDots / dockClick / dockIconMenu / dockBgMenu src/components/system/Dock.vue + src/composables/useDockMag.ts
toggleControlCenter / ccToggle / renderControlCenter src/components/system/ControlCenter.vue
toggleNotificationCenter src/components/system/MenuBar.vue(时钟点击)
resetAll src/stores/settings.ts

9.3 API 对照

原 API 新 API
Sys.settings.xxx settingsStore.settings.xxx
Sys.isDark() settingsStore.isDark (computed)
Sys.set('key', val) settingsStore.settings.key = val
Sys.renderDesktopIcons() emitter.emit('fs:changed') → 自动更新
fs.node(path) fsStore.node(path)
fs.list(path) fsStore.list(path)
bus.emit('fs:changed', data) emitter.emit('fs:changed', data)
ui.menu(items, x, y) uiStore.showMenu(items, x, y)
ui.dialog({...}) uiStore.showDialog({...})
ui.alert(title, msg) uiStore.alert(title, msg)
ui.confirm(title, msg) uiStore.confirm(title, msg)
wm.openWindow(opts) wmStore.openWindow(opts)
wm.close(win) wmStore.close(winId)
wm.focus(win) wmStore.focus(winId)
wm.activeWin.value wmStore.activeWin
Apps.open('finder', args) appStore.open('finder', args)
Apps.get('finder') appStore.get('finder')
Apps.register(def) appStore.register(def)
Notify.send({...}) notifyStore.send({...})
Notify.badgeCount('mail') notifyStore.badgeCount('mail')
Spotlight.toggle() spotlightStore.toggle()

9.4 保留的原生 API

以下原生 API 是必须保留的(封装在专用 composable 中):

API 用途 封装位置
PointerEvent (pointerdown/move/up/capture) 窗口拖拽、缩放、桌面图标拖拽 useDrag.ts / useResize.ts
localStorage 持久化(由 Pinia watch 自动处理) 各 store 内部
matchMedia('prefers-color-scheme: dark') 系统暗色模式检测 useTheme.ts
navigator.clipboard 剪贴板操作 useClipboard.ts
window.open(url, '_blank') Safari 外链打开 useSafari.ts
requestAnimationFrame 启动动画 <BootScreen> 组件内
Image() 壁纸预加载 useTheme.ts

11. 执行建议

10.1 执行顺序

  1. 安装依赖pnpm add pinia mitt
  2. 阶段一:创建所有 Pinia stores + mitt 事件总线,确保与原功能等价(此时还依赖 el() 等旧 API
  3. 阶段二:逐个将系统 UI 组件化,每完成一个就用新组件替换旧的 el() 渲染,保持可运行
  4. 阶段三:窗口管理器重构
  5. 阶段四:应用层优化 + lazy load
  6. 阶段五:清理删除旧文件,更新测试

10.2 关键原则

  • 每步保持可运行:不要一次性改完所有东西再测试;每个组件替换后立即验证
  • 先测试后重构:在重构前确保现有 47 个测试用例全部通过,重构后逐批更新测试
  • 类型安全优先:所有新增模块用完整 TypeScript 类型定义,消除 any
  • CSS 不变:现有 styles/*.css 无需修改CSS 类名保持不变

10.3 预估工作量

阶段 描述 预估
阶段一 Pinia stores + mitt 1-2 天
阶段二 系统 UI 组件化MenuBar/Dock/Spotlight/CC/通知/锁屏/屏保/启动/对话框/右键菜单 — 共 10+ 个组件) 3-5 天
阶段三 WM 重构WindowFrame/TrafficLights/AppLoader + drag/resize composable 1-2 天
阶段四 33 个应用适配(去 render 方法、去 window 引用、lazy load 2-3 天
阶段五 清理 + 测试更新 1-2 天
总计 8-14 天