1875 lines
63 KiB
Markdown
1875 lines
63 KiB
Markdown
# 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["<MenuBar />"]
|
||
Dock["<Dock />"]
|
||
DesktopIcons["<DesktopIcons />"]
|
||
Spotlight["<Spotlight />"]
|
||
NotificationCenter["<NotificationCenter />"]
|
||
ControlCenter["<ControlCenter />"]
|
||
LockScreen["<LockScreen />"]
|
||
Screensaver["<Screensaver />"]
|
||
BootScreen["<BootScreen />"]
|
||
ContextMenu["<ContextMenu />"]
|
||
DialogLayer["<DialogLayer />"]
|
||
end
|
||
|
||
subgraph "窗口系统"
|
||
WindowLayer["<WindowLayer />"]
|
||
WindowFrame["<WindowFrame /><br/>(chrome: 标题栏/traffic lights/resize)"]
|
||
end
|
||
|
||
subgraph "应用层(懒加载)"
|
||
AppLoader["<AppLoader /><br/>defineAsyncComponent"]
|
||
Finder["Finder.vue"]
|
||
Safari["Safari.vue"]
|
||
TxtEdit["...33 个应用"]
|
||
end
|
||
|
||
subgraph "组合式函数(纯逻辑,无 DOM)"
|
||
useKeyboard["useKeyboard(全局快捷键)"]
|
||
useIdleWatch["useIdleWatch(屏保计时)"]
|
||
useTheme["useTheme(暗色模式/壁纸)"]
|
||
useVolume["useVolume(音量控制)"]
|
||
useEventBus["useEventBus(mitt 替代 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["<OverlayBrightness />"]
|
||
B2["<OverlayNightShift />"]
|
||
B3["<DesktopArea />"]
|
||
end
|
||
|
||
subgraph "Layer 2:内容层 z-1~99"
|
||
C1["<WindowLayer />"]
|
||
end
|
||
|
||
subgraph "Layer 3:系统 UI 层 z-100~499"
|
||
D1["<MenuBar />"]
|
||
D2["<Dock />"]
|
||
end
|
||
|
||
subgraph "Layer 4:浮层 z-500~899"
|
||
E1["<Spotlight />"]
|
||
E2["<ControlCenter />"]
|
||
E3["<NotificationCenter />"]
|
||
E4["<ContextMenu />"]
|
||
E5["<DialogLayer />"]
|
||
E6["<BannerContainer />"]
|
||
end
|
||
|
||
subgraph "Layer 5:全屏覆盖层 z-900+"
|
||
F1["<LockScreen />"]
|
||
F2["<Screensaver />"]
|
||
F3["<BootScreen />"]
|
||
F4["<PowerOff />"]
|
||
end
|
||
|
||
A --> B1 & B2 & B3
|
||
A --> C1
|
||
A --> D1 & D2
|
||
A --> E1 & E2 & E3 & E4 & E5 & E6
|
||
A --> F1 & F2 & F3 & F4
|
||
|
||
C1 --> C1A["<WindowFrame /> ×N"]
|
||
C1A --> C1A1["<TrafficLights />"]
|
||
C1A --> C1A2["<AppLoader />"]
|
||
C1A2 --> C1A2A["应用 SFC<br/>(Finder/Safari/...)"]
|
||
```
|
||
|
||
**z-index 分层规范**:
|
||
|
||
| 层 | z-index 范围 | 组件 | 说明 |
|
||
|---|---|---|---|
|
||
| 背景层 | 0 | OverlayBrightness, OverlayNightShift, DesktopArea | 静态背景,无交互 |
|
||
| 内容层 | 1–99 | WindowLayer → WindowFrame ×N | 窗口动态分配 z-index |
|
||
| 系统 UI 层 | 100–499 | MenuBar(100), Dock(200) | 始终在窗口之上 |
|
||
| 浮层 | 500–899 | 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["<MenuBar />"]
|
||
DK["<Dock />"]
|
||
SP["<Spotlight />"]
|
||
CC["<ControlCenter />"]
|
||
NC["<NotificationCenter />"]
|
||
WF["<WindowFrame />"]
|
||
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 ← 依赖 wmStore(open 时创建窗口)
|
||
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["已加载 → 立即显示 <component :is=>"]
|
||
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 天** |
|