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

1875 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# macOS-web 纯 Vue 重构手册
> **目标**:去除原生 JS 思路和实现,去掉 `window` 全局调用,将项目改造为纯 Vue 3 项目。
> **范围**:全量重构,不考虑成本。
---
## 目录
1. [现状诊断](#1-现状诊断)
2. [目标架构](#2-目标架构)
3. [前端架构设计](#3-前端架构设计)
4. [重构总路线](#4-重构总路线)
5. [阶段一:基础设施层](#5-阶段一基础设施层)
6. [阶段二:系统 UI 组件化](#6-阶段二系统-ui-组件化)
7. [阶段三:窗口管理器现代化](#7-阶段三窗口管理器现代化)
8. [阶段四:应用层优化](#8-阶段四应用层优化)
9. [阶段五:清理与收尾](#9-阶段五清理与收尾)
10. [附录:新旧对照表](#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 关键反模式
```mermaid
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 总览
```mermaid
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. **仅保留必要的原生 API**`pointer events` 拖拽、`localStorage``navigator.clipboard` 等不可避免的部分,封装在专用 composable 中
---
## 3. 前端架构设计
### 3.1 组件层级树
```mermaid
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 数据流架构
```mermaid
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 路由)。应用加载使用以下策略:
```typescript
// 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 })
})
}
```
**加载状态处理**
```vue
<!-- 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 插件:持久化
```typescript
// 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 事件契约
```typescript
// 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 设计
```typescript
// 窗口级上下文 —— 由 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 组件分类与职责
```mermaid
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 三级策略兼顾首屏速度与体验 |
| 类型安全 | **全局禁止 `any`**eslint rule | 强制所有接口、store、事件使用精确类型 |
### 3.9 性能策略
```mermaid
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
```typescript
// 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.xxx``settingsStore.settings.xxx`
- `Sys.isDark()``settingsStore.isDark` (computed)
- `Sys.save()` → 自动 watch 持久化
#### 4.1.2 `fsStore` — 替代 useFS
```typescript
// 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 的状态部分
```typescript
// 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 注册表
```typescript
// 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 数据部分
```typescript
// 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
```bash
pnpm add mitt
```
```typescript
// 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.ts``buildMenubar()``el()` 构建整个菜单栏 DOM操作 `#menubar-left` / `#menubar-right`
**目标实现**
```vue
<!-- 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)._mbClock``ref` + `setInterval`
- `addEventListener('click')``@click`
- 菜单栏左侧应用菜单 → 根据 `wmStore.activeWin` 计算
### 5.2 `<Dock>` — 替代 Sys.renderDock()
```vue
<!-- 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()
```vue
<!-- 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**。
```vue
<!-- 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>
```
**全局菜单/对话框管理**
```typescript
// 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
```vue
<!-- 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.oninput``v-model` + `watch`
- `input.onkeydown``@keydown`
- `render()` 中的 `el()` 构建 → computed `results` + `v-for`
- `scrollIntoView``nextTick` + Vue 方式滚动
### 5.6 `<NotificationCenter>` / `<BannerContainer>` — 替代 useNotify 渲染
```vue
<!-- 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()
```vue
<!-- 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>` SFC`v-model` 双向绑定
- `cc.innerHTML = ''` 重建 → 响应式数据驱动
- 外点击关闭 → `onClickOutside` 指令/composable
---
## 7. 阶段三:窗口管理器现代化
### 6.1 `<WindowFrame>` — 替代 useWM 的窗口 chrome 构建
**原实现**`openWindow()` 中用 `el()` 创建 traffic lights、标题栏、body、resize handles全部命令式。
**目标实现**
```vue
<!-- 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>` — 独立组件
```vue
<!-- 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>` — 容器组件
```vue
<!-- 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
```typescript
// 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 }
}
```
```typescript
// 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`
```typescript
// 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` 中:
```typescript
render(win) {
const vnode = h(XxxComponent, { win })
render(vnode, win.body)
}
```
**目标实现**:用 `defineAsyncComponent` + `<component :is="">` 动态加载:
```vue
<!-- 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>
```
应用懒加载映射表:
```typescript
// 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`**
```typescript
export const FinderApp = { id:'finder', ..., render(win) { ... } }
Apps.register(FinderApp)
```
**改为纯数据定义**
```typescript
// 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 方法!
}
```
```typescript
// 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`
```typescript
// 原:直接访问 document
const editBase = () => {
const a = document.activeElement as any
const isText = a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable)
// ...
}
```
**改进**:接受 `isTextFocused: boolean` 参数,由调用方传入:
```typescript
// 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.ts``initSystem()` → 手动串联 `fs.init → Sys.init → Notify.init → Sys.renderDock → initDesktop → boot`
**新**:在 `App.vue``onMounted` 中:
```typescript
// 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).FS``useFSStore()`
- `(window as any).Notify``useNotifyStore()`
- `(window as any).Apps``useAppStore()`
- `bus.on/emit``emitter.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 天** |