rebuild: 第五版重构 - 添加pinia

This commit is contained in:
李岩岩 2026-07-23 17:47:38 +08:00
parent a708b4cdee
commit d3c89d2c09
20 changed files with 4811 additions and 17 deletions

1874
REFACTORING_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,8 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"mitt": "^3.0.1",
"pinia": "^4.0.2",
"vue": "^3.5.0" "vue": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {

1946
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
/**
* useEventBus mitt 线
* useBus.ts
*/
import mitt from 'mitt'
import type { WinState } from '@/types/window'
type Events = {
// 窗口事件
'wm:focus': WinState | null
'wm:changed': void
'wm:closed': { winId: string }
// 文件系统事件
'fs:changed': { op?: string; paths: string[]; dirty?: boolean }
// 系统事件
'apps:ready': void
'volume:changed': number
'trash:changed': void
'unlocked': void
'locked': void
// 通知事件
'notify:badges-updated': void
}
export const emitter = mitt<Events>()

View File

@ -1,5 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import { initPinia } from './stores'
// Import all CSS // Import all CSS
import './styles/base.css' import './styles/base.css'
@ -9,4 +10,5 @@ import './styles/apps.css'
import './styles/apps2.css' import './styles/apps2.css'
const app = createApp(App) const app = createApp(App)
app.use(initPinia())
app.mount('#app') app.mount('#app')

55
src/stores/apps.ts Normal file
View File

@ -0,0 +1,55 @@
/**
* appStore
* useApps.ts
*/
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import { useFSStore } from './fs'
import type { AppDefinition } from '@/types/app'
export const useAppStore = defineStore('apps', () => {
const registry = reactive<Record<string, AppDefinition>>({})
// 待打开的应用(由 AppLoader 消费)
const pendingOpen = ref<{ id: string; args?: any } | null>(null)
function register(def: AppDefinition) {
def.w = def.w || 720
def.h = def.h || 480
def.minW = def.minW || 320
def.minH = def.minH || 240
def.about = def.about || `${def.name} — macOS 网页版内置应用`
registry[def.id] = def
}
function get(id: string): AppDefinition | undefined {
return registry[id]
}
function open(id: string, args?: any) {
pendingOpen.value = { id, args }
}
function openPath(path: string) {
const fsStore = useFSStore()
const n = fsStore.node(path)
if (!n) {
// ui.alert 稍后接入
console.warn('[apps] 找不到项目:', path)
return
}
if (n.t === 'd') return open('finder', { path })
if (n.t === 'a') return open(n.app!)
const ext = (path.split('.').pop() || '').toLowerCase()
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'pdf'].includes(ext))
return open('preview', { path })
return open('textedit', { path })
}
function quit(id: string) {
// 由外部 wm 处理实际关闭
pendingOpen.value = { id, args: { quit: true } }
}
return { registry, pendingOpen, register, get, open, openPath, quit }
})

315
src/stores/fs.ts Normal file
View File

@ -0,0 +1,315 @@
/**
* fsStore
* useFS.ts Pinia
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { emitter } from '@/composables/useEventBus'
import type { FSNode, FSEntry } from '@/types/fs'
// ---- 文件扩展名 → MIME 映射 ----
function mimeOf(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || ''
const map: Record<string, string> = {
txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css', js: 'text/javascript',
json: 'application/json', xml: 'text/xml', svg: 'image/svg+xml', csv: 'text/csv',
}
return map[ext] || 'text/plain'
}
// ---- 文件扩展名 → 图标映射 ----
export function iconFor(path: string): string {
const segs = path.split('/')
const name = segs[segs.length - 1] || ''
const ext = name.split('.').pop()?.toLowerCase() || ''
const extIcons: Record<string, string> = {
txt: '/assets/icons/file-text.svg', md: '/assets/icons/file-text.svg',
html: '/assets/icons/file-code.svg', css: '/assets/icons/file-code.svg',
js: '/assets/icons/file-code.svg', ts: '/assets/icons/file-code.svg',
json: '/assets/icons/file-code.svg', xml: '/assets/icons/file-code.svg',
png: '/assets/icons/file-image.svg', jpg: '/assets/icons/file-image.svg',
jpeg: '/assets/icons/file-image.svg', gif: '/assets/icons/file-image.svg',
webp: '/assets/icons/file-image.svg', svg: '/assets/icons/file-image.svg',
pdf: '/assets/icons/file-pdf.svg',
mp3: '/assets/icons/file-audio.svg', wav: '/assets/icons/file-audio.svg', ogg: '/assets/icons/file-audio.svg',
mp4: '/assets/icons/file-video.svg', mov: '/assets/icons/file-video.svg',
zip: '/assets/icons/file-archive.svg', tar: '/assets/icons/file-archive.svg', gz: '/assets/icons/file-archive.svg',
app: '/assets/icons/app-default.svg',
}
if (extIcons[ext]) return extIcons[ext]
return '/assets/icons/file-generic.svg'
}
export const useFSStore = defineStore('fs', () => {
const HOME = '/Users/guest'
const root = ref<FSNode | null>(null)
// ---- 懒引用(由外部注入,打破循环依赖) ----
let _Apps: any = null
let _AppStoreApp: any = null
function setAppsRef(apps: any, appStore: any) {
_Apps = apps
_AppStoreApp = appStore
}
function setAppStoreRef(a: any) { _AppStoreApp = a }
const TRASH = computed(() => HOME + '/.Trash')
// ---- 路径工具 ----
function normalize(p: string): string {
if (!p) return '/'
const parts: string[] = []
for (const seg of String(p).split('/')) {
if (!seg || seg === '.') continue
if (seg === '..') parts.pop()
else parts.push(seg)
}
return '/' + parts.join('/')
}
function join(...segs: string[]) { return normalize(segs.join('/')) }
function baseName(p: string) { p = normalize(p); return p === '/' ? '/' : p.slice(p.lastIndexOf('/') + 1) }
function dirName(p: string) { p = normalize(p); const i = p.lastIndexOf('/'); return i <= 0 ? '/' : p.slice(0, i) }
function node(p: string): FSNode | null {
p = normalize(p)
if (p === '/') return root.value
let cur: FSNode | null = root.value
for (const seg of p.slice(1).split('/')) {
if (!cur || cur.t !== 'd' || !cur.c![seg]) return null
cur = cur.c![seg]
}
return cur
}
function exists(p: string) { return !!node(p) }
function isDir(p: string) { return node(p)?.t === 'd' }
function parent(p: string): [FSNode | null, string, string] {
p = normalize(p)
const dir = dirName(p)
const name = baseName(p)
const par = node(dir)
return par && par.t === 'd' ? [par, name, dir] : [null, name, dir]
}
function assertDir(p: string): FSNode {
const n = node(p)
if (!n || n.t !== 'd') throw new Error('不是文件夹: ' + p)
return n
}
// ---- 持久化 ----
function loadFromStorage(): FSNode | null {
try {
const raw = localStorage.getItem('macos-web:fs')
if (!raw) return null
const v = JSON.parse(raw)
return v && v.t === 'd' ? v : null
} catch { return null }
}
function save() {
try { localStorage.setItem('macos-web:fs', JSON.stringify(root.value)) }
catch (e) { console.warn('[fs] 保存失败:', e) }
}
// ---- 初始化 & 种子数据 ----
function seed() {
const dirs = ['Desktop', 'Documents', 'Downloads', 'Pictures', 'Music', 'Applications', '.Trash']
dirs.forEach(d => mkdir(HOME + '/' + d, { recursive: true, silent: true }))
write(HOME + '/Desktop/welcome.txt', [
'欢迎使用 macOS 网页版!', '',
'这是一套在浏览器中运行的桌面模拟器。', '你可以:',
'· 双击打开「Sample Folder」和各个应用', '· 在访达、终端、文本编辑之间管理同一套虚拟文件',
'· 通过 Apple 菜单锁定、重启或关机', '· 在系统设置中更换壁纸、切换深色模式', '',
'所有数据都保存在浏览器本地,刷新后依然存在。'
].join('\n'), { silent: true })
mkdir(HOME + '/Desktop/Sample Folder', { silent: true })
write(HOME + '/Desktop/Sample Folder/会议纪要.txt', '周会纪要\n\n1. 桌面端体验优化\n2. 虚拟文件系统联调\n3. 下周发布预览版', { silent: true })
write(HOME + '/Desktop/Sample Folder/待办.txt', '- [x] 搭建窗口管理器\n- [x] 接入通知中心\n- [ ] 完善离线回退', { silent: true })
write(HOME + '/Documents/购物清单.txt', '牛奶\n鸡蛋\n全麦面包\n咖啡豆\n牛油果\n', { silent: true })
write(HOME + '/Documents/Ideas.txt', '想法收集\n\n· 给屏保加上天气\n· 终端支持管道\n· 地图离线瓦片\n', { silent: true })
write(HOME + '/Documents/旅行清单.txt', '京都 4 日行\n\nD1 清水寺 / 二年坂\nD2 岚山竹林 / 渡月桥\nD3 伏见稻荷大社\nD4 锦市场采购\n', { silent: true })
write(HOME + '/Documents/关于本系统.txt', 'macOS 网页版 v1.0\n\n纯 HTML/CSS/JavaScript 实现,无需构建。\n数据存储于 localStorage离线可用。', { silent: true })
write(HOME + '/Downloads/说明.txt', '此目录用于存放下载的文件。', { silent: true })
write(HOME + '/Pictures/壁纸说明.txt', '系统内置多张壁纸,可在「系统设置 墙纸」中切换。', { silent: true })
write(HOME + '/Music/曲目说明.txt', '音乐 App 已内置 6 首 Kevin MacLeod (CC-BY) 曲目。', { silent: true })
save()
}
function init() {
root.value = loadFromStorage()
if (!root.value || root.value.t !== 'd') {
root.value = { t: 'd', c: {}, mtime: Date.now() }
seed()
}
emitter.on('apps:ready', () => syncApps())
}
// ---- 应用同步 ----
function syncApps() {
if (!_Apps) return
const dir = node(HOME + '/Applications')
if (!dir) return
const installed = (id: string) => _AppStoreApp && _AppStoreApp.isInstalled(id)
const want = new Set<string>()
for (const app of Object.values(_Apps.registry) as any[]) {
if ((app as any).storeApp && !installed((app as any).id)) continue
want.add((app as any).id)
const name = (app as any).name + '.app'
if (!dir.c![name]) dir.c![name] = { t: 'a', app: (app as any).id, mtime: Date.now() }
}
let dirty = false
for (const [name, n] of Object.entries(dir.c!)) {
if ((n as FSNode).t === 'a' && (n as FSNode).app && !want.has((n as FSNode).app!)) {
const reg = _Apps.registry[(n as FSNode).app!]
if (reg && reg.storeApp) { delete dir.c![name]; dirty = true }
}
}
save()
emitter.emit('fs:changed', { op: 'sync', paths: [HOME + '/Applications'], dirty })
}
// ---- CRUD 操作 ----
function list(p: string, opts: { showHidden?: boolean } = {}): FSEntry[] {
const n = assertDir(p)
return Object.entries(n.c!)
.filter(([name]) => opts.showHidden || !name.startsWith('.'))
.map(([name, nd]) => ({ name, path: join(p, name), node: nd }))
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
}
function read(p: string): string {
const n = node(p)
if (!n) throw new Error('文件不存在: ' + p)
if (n.t !== 'f') throw new Error('不是文本文件: ' + p)
return n.data ?? ''
}
function write(p: string, data: string, opts: { mime?: string; silent?: boolean } = {}) {
const [par, name] = parent(p)
if (!par) throw new Error('父目录不存在: ' + p)
const now = Date.now()
if (par.c![name] && par.c![name].t === 'd') throw new Error('同名文件夹已存在: ' + name)
par.c![name] = { t: 'f', data: String(data ?? ''), mime: opts.mime || mimeOf(name), mtime: now }
save()
if (!opts.silent) emitter.emit('fs:changed', { op: 'write', paths: [normalize(p), dirName(p)] })
}
function mkdir(p: string, opts: { recursive?: boolean; silent?: boolean } = {}) {
p = normalize(p)
if (exists(p)) { if (!opts.recursive) throw new Error('已存在: ' + p); return }
if (opts.recursive) {
let cur = root.value!
let curPath = ''
for (const seg of p.slice(1).split('/')) {
curPath += '/' + seg
if (!cur.c![seg]) cur.c![seg] = { t: 'd', c: {}, mtime: Date.now() }
if (cur.c![seg].t !== 'd') throw new Error('路径冲突: ' + curPath)
cur = cur.c![seg]
}
save()
if (!opts.silent) emitter.emit('fs:changed', { op: 'mkdir', paths: [p, dirName(p)] })
return
}
const [par, name] = parent(p)
if (!par) throw new Error('父目录不存在: ' + p)
par.c![name] = { t: 'd', c: {}, mtime: Date.now() }
save()
if (!opts.silent) emitter.emit('fs:changed', { op: 'mkdir', paths: [p, dirName(p)] })
}
function renameItem(p: string, newName: string): string {
p = normalize(p); newName = String(newName || '').trim()
if (!newName || newName.includes('/')) throw new Error('名称无效')
const [par, name, dir] = parent(p)
if (!par || !par.c![name]) throw new Error('不存在: ' + p)
if (name === newName) return p
if (par.c![newName]) throw new Error('已存在同名项目: ' + newName)
par.c![newName] = par.c![name]; delete par.c![name]
par.c![newName].mtime = Date.now()
const np = join(dir, newName)
save(); emitter.emit('fs:changed', { op: 'rename', paths: [p, np, dir] })
return np
}
function uniqueName(dir: string, base: string): string {
const n = assertDir(dir)
if (!n.c![base]) return base
const dot = base.lastIndexOf('.')
const stem = dot > 0 ? base.slice(0, dot) : base
const ext = dot > 0 ? base.slice(dot) : ''
for (let i = 2; ; i++) { const cand = `${stem} ${i}${ext}`; if (!n.c![cand]) return cand }
}
function copy(src: string, dstDir: string): string {
src = normalize(src); dstDir = normalize(dstDir)
const sn = node(src); if (!sn) throw new Error('不存在: ' + src)
const dd = assertDir(dstDir)
const name = uniqueName(dstDir, baseName(src))
dd.c![name] = structuredClone(sn); dd.c![name].mtime = Date.now()
delete (dd.c![name] as any).origPath
save(); emitter.emit('fs:changed', { op: 'copy', paths: [src, join(dstDir, name), dstDir] })
return join(dstDir, name)
}
function move(src: string, dstDir: string): string {
src = normalize(src); dstDir = normalize(dstDir)
if (src === dstDir) throw new Error('不能移动到自身')
if (dstDir === src || dstDir.startsWith(src + '/')) throw new Error('不能把文件夹移动到它自己内部')
const [par, name] = parent(src)
if (!par || !par.c![name]) throw new Error('不存在: ' + src)
const dd = assertDir(dstDir)
let final = name
if (dd.c![final]) final = uniqueName(dstDir, name)
dd.c![final] = par.c![name]; delete par.c![name]
dd.c![final].mtime = Date.now()
const np = join(dstDir, final)
save(); emitter.emit('fs:changed', { op: 'move', paths: [src, np, dirName(src), dstDir] })
return np
}
function remove(p: string) {
p = normalize(p)
if (p === '/' || p === TRASH.value) throw new Error('不能删除该项目')
const [par, name, dir] = parent(p)
if (!par || !par.c![name]) throw new Error('不存在: ' + p)
delete par.c![name]
save(); emitter.emit('fs:changed', { op: 'remove', paths: [p, dir] })
}
function trash(p: string): string {
p = normalize(p)
if (p.startsWith(TRASH.value + '/')) { remove(p); return p }
const n = node(p); if (!n) throw new Error('不存在: ' + p)
;(n as any).origPath = p
const np = move(p, TRASH.value)
save()
emitter.emit('trash:changed')
return np
}
function emptyTrash() {
const t = node(TRASH.value)
if (t && t.c) Object.keys(t.c).forEach(k => delete t.c![k])
save()
emitter.emit('trash:changed')
emitter.emit('fs:changed', { op: 'trash:empty', paths: [TRASH.value] })
}
return {
HOME, TRASH, root,
setAppsRef, setAppStoreRef,
init, seed, save, syncApps,
normalize, join, baseName, dirName,
node, exists, isDir, assertDir,
list, read, write, mkdir, renameItem, uniqueName,
copy, move, remove, trash, emptyTrash,
}
})

11
src/stores/index.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Pinia &
*/
import { createPinia } from 'pinia'
import { persistPlugin } from './plugins/persist'
export function initPinia() {
const pinia = createPinia()
pinia.use(persistPlugin)
return pinia
}

64
src/stores/notify.ts Normal file
View File

@ -0,0 +1,64 @@
/**
* notifyStore
* useNotify.ts //
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Notification } from '@/types/notify'
export const useNotifyStore = defineStore('notify', () => {
const notifications = ref<Notification[]>([])
// ---- 持久化 ----
function loadFromStorage(): Notification[] {
try {
const raw = localStorage.getItem('macos-web:notify')
return raw ? JSON.parse(raw) : []
} catch { return [] }
}
function save() {
try {
localStorage.setItem('macos-web:notify', JSON.stringify(notifications.value.slice(-60)))
} catch { /* ignore */ }
}
function init() {
notifications.value = loadFromStorage()
}
// ---- 操作 ----
function send(n: Notification) {
notifications.value.push(n)
save()
}
function markRead(id: string) {
const n = notifications.value.find(x => x.id === id)
if (n && !n.read) { n.read = true; save() }
}
function remove(id: string) {
notifications.value = notifications.value.filter(x => x.id !== id)
save()
}
function clearAll() {
notifications.value = []
save()
}
function badgeCount(appId: string): number {
return notifications.value.filter(n => n.appId === appId && !n.read).length
}
function unreadCount(): number {
return notifications.value.filter(n => !n.read).length
}
return {
notifications, init, save,
send, markRead, remove, clearAll,
badgeCount, unreadCount,
}
})

View File

@ -0,0 +1,37 @@
/**
* Pinia persist
* store localStorage
*/
import type { PiniaPluginContext } from 'pinia'
const PREFIX = 'macos-web:'
/** 需要持久化的 store id 列表 */
const PERSISTED = ['settings', 'fs', 'notify']
export function persistPlugin({ store }: PiniaPluginContext) {
if (!PERSISTED.includes(store.$id)) return
// 1. 初始化:从 localStorage 恢复
try {
const raw = localStorage.getItem(PREFIX + store.$id)
if (raw) {
const parsed = JSON.parse(raw)
if (parsed) store.$patch(parsed)
}
} catch { /* ignore */ }
// 2. 自动保存
store.$subscribe((_, state) => {
try {
localStorage.setItem(PREFIX + store.$id, JSON.stringify(state))
} catch (e) { console.warn(`[persist] ${store.$id} 保存失败:`, e) }
}, { detached: true, deep: true })
}
/** 清除所有持久化数据 */
export function clearAllPersisted() {
Object.keys(localStorage)
.filter(k => k.startsWith(PREFIX))
.forEach(k => localStorage.removeItem(k))
}

118
src/stores/settings.ts Normal file
View File

@ -0,0 +1,118 @@
/**
* settingsStore
* useSettings.ts + useStore.ts
*/
import { defineStore } from 'pinia'
import { reactive, computed } from 'vue'
import type { SystemSettings, Wallpaper } from '@/types/settings'
export const WALLPAPERS: Wallpaper[] = [
{ id: 'monterey', name: 'Monterey 抽象', src: '/assets/wallpapers/monterey.jpg' },
{ id: 'sonoma', name: 'Sonoma 落日', src: '/assets/wallpapers/sonoma.jpg' },
{ id: 'ventura', name: 'Ventura 霞光', src: '/assets/wallpapers/ventura.jpg' },
{ id: 'bigsur', name: 'Big Sur 海岸', src: '/assets/wallpapers/big-sur-day.jpg', dark: '/assets/wallpapers/big-sur-night.jpg' },
{ id: 'sequoia', name: 'Sequoia 山谷', src: '/assets/wallpapers/sequoia.jpg' },
{ id: 'galaxy', name: '银河', src: '/assets/wallpapers/galaxy.jpg' },
]
export const DEFAULT_SETTINGS: SystemSettings = {
appearance: 'light', wallpaper: 'monterey', accent: '#0a84ff',
reduceTransparency: false, reduceMotion: false, increaseContrast: false,
dockSize: 54, dockMagnify: true, dockMagnifyLevel: 1.6, dockPosition: 'bottom', dockAutohide: false,
brightness: 1, nightShift: false, nightShiftStrength: 0.4,
volume: 0.6, muted: false,
wifi: true, bluetooth: true, airdrop: false, focus: false, vpn: false,
userName: '客人用户', avatar: '/assets/icons/avatar.svg',
passwordEnabled: false, password: '',
screensaverType: 'off', screensaverDelay: 5,
h24: false, language: 'zh-Hans', region: '中国', firstDayMonday: true,
searchEngine: 'bing', computerName: 'MacBook Pro',
siriApps: true, siriFiles: true, siriSettings: true,
loginItems: {}, notifAllow: {},
notificationsEnabled: true,
timezone: 'local',
wifiNetwork: '家庭网络 5G',
btDevices: {},
kbRepeat: 7, kbDelay: 4, mouseSpeed: 5,
finderView: 'icon', finderSort: 'name',
}
export const useSettingsStore = defineStore('settings', () => {
// ---- 状态 ----
const settings = reactive<SystemSettings>(structuredClone(DEFAULT_SETTINGS))
// ---- 计算属性 ----
const isDark = computed(() => {
const a = settings.appearance
if (a === 'dark') return true
if (a === 'auto') return matchMedia('(prefers-color-scheme: dark)').matches
return false
})
const wallpaperSrc = computed(() => {
const w = WALLPAPERS.find(wp => wp.id === settings.wallpaper) || WALLPAPERS[0]
return (isDark.value && w.dark) ? w.dark : w.src
})
// ---- 初始化 ----
function init(saved?: Partial<SystemSettings>) {
if (saved) Object.assign(settings, structuredClone(DEFAULT_SETTINGS), saved)
}
// ---- 方法 ----
function set<K extends keyof SystemSettings>(key: K, value: SystemSettings[K]) {
settings[key] = value
}
function applyAppearance() {
const b = document.body
b.classList.toggle('dark', isDark.value)
b.classList.toggle('reduce-transparency', !!settings.reduceTransparency)
b.classList.toggle('reduce-motion', !!settings.reduceMotion)
b.classList.toggle('increase-contrast', !!settings.increaseContrast)
applyWallpaper()
}
function applyWallpaper() {
const src = wallpaperSrc.value
const desk = document.getElementById('desktop')
const lock = document.getElementById('lockscreen-bg')
if (desk) desk.style.backgroundImage = `url("${src}")`
if (lock) lock.style.backgroundImage = `url("${src}")`
// 壁纸预加载错误回退
const probe = new Image()
probe.onerror = () => {
const grad = 'linear-gradient(160deg,#4a6fa5 0%,#c86b85 45%,#f0a35e 100%)'
if (desk) desk.style.backgroundImage = grad
if (lock) lock.style.backgroundImage = grad
}
probe.src = src
}
function applyBrightness() {
const el = document.getElementById('overlay-brightness')
if (el) el.style.opacity = String((1 - settings.brightness) * 0.55)
}
function applyNightShift() {
const el = document.getElementById('overlay-nightshift')
if (el) el.style.opacity = settings.nightShift ? String(settings.nightShiftStrength * 0.32) : '0'
}
function applyAccent() {
document.documentElement.style.setProperty('--accent', settings.accent)
}
function applyAll() {
applyAppearance()
applyBrightness()
applyNightShift()
applyAccent()
}
return {
settings, isDark, wallpaperSrc,
init, set,
applyAppearance, applyWallpaper, applyBrightness, applyNightShift, applyAccent, applyAll,
}
})

89
src/stores/ui.ts Normal file
View File

@ -0,0 +1,89 @@
/**
* uiStore /
* useUI.ts
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { MenuItem } from '@/types/app'
import type { DialogConfig } from '@/types/ui'
export const useUIStore = defineStore('ui', () => {
// ---- 弹出菜单 ----
const menuVisible = ref(false)
const menuItems = ref<MenuItem[]>([])
const menuPosition = ref({ x: 0, y: 0 })
const menuSub = ref(false)
let menuOnClose: (() => void) | null = null
function showMenu(items: MenuItem[], x: number, y: number, opts: { sub?: boolean; onClose?: () => void } = {}) {
hideMenu()
menuItems.value = items
menuPosition.value = { x, y }
menuSub.value = !!opts.sub
menuVisible.value = true
menuOnClose = opts.onClose ?? null
}
function hideMenu() {
menuOnClose?.()
menuOnClose = null
menuVisible.value = false
}
function showContextMenu(items: MenuItem[], e: MouseEvent) {
e.preventDefault(); e.stopPropagation()
showMenu(items, e.clientX, e.clientY)
}
// ---- 对话框 ----
const dialogVisible = ref(false)
const dialogConfig = ref<DialogConfig>({ title: '', msg: '' })
let dialogResolve: ((v: string | boolean) => void) | null = null
function showDialog(config: DialogConfig): Promise<string | boolean> {
return new Promise(resolve => {
dialogConfig.value = config
dialogVisible.value = true
dialogResolve = resolve
})
}
function resolveDialog(value: string | boolean) {
dialogVisible.value = false
dialogResolve?.(value)
dialogResolve = null
}
async function alert(title: string, msg: string, icon?: string): Promise<boolean> {
return showDialog({ icon, title, msg, buttons: ['好'] }).then(() => true)
}
async function confirm(
title: string, msg: string,
opts: { ok?: string; danger?: boolean; icon?: string } = {}
): Promise<boolean> {
const result = await showDialog({
icon: opts.icon, title, msg,
ok: opts.ok || '确定', danger: opts.danger,
})
return result === 'ok' || result === true
}
async function prompt(title: string, msg: string, defaultValue?: string): Promise<string | null> {
const result = await showDialog({
title, msg: msg + (defaultValue ? `\n\n默认值${defaultValue}` : ''),
ok: '确定',
})
return typeof result === 'string' ? result : null
}
return {
// menu
menuVisible, menuItems, menuPosition, menuSub,
showMenu, hideMenu, showContextMenu,
// dialog
dialogVisible, dialogConfig,
showDialog, resolveDialog,
alert, confirm, prompt,
}
})

135
src/stores/wm.ts Normal file
View File

@ -0,0 +1,135 @@
/**
* wmStore
* useWM.ts windows / activeWin / zTop / cascade
* DOM // useWM.ts
*/
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import { emitter } from '@/composables/useEventBus'
import { useSettingsStore } from './settings'
import type { Rect, WinState, WinStateType } from '@/types/window'
export const useWMStore = defineStore('wm', () => {
const windows: WinState[] = reactive([])
const activeWin = ref<WinState | null>(null)
const zTop = ref(100)
const cascadeCount = ref(0)
// ---- 辅助 ----
function usableRect(): Rect {
const s = useSettingsStore().settings
const r: Rect = { x: 0, y: 30, w: window.innerWidth, h: window.innerHeight - 30 }
if (!s.dockAutohide) {
const dockH = (s.dockSize || 48) + 26
if (s.dockPosition === 'bottom') r.h -= dockH
else if (s.dockPosition === 'left') { r.x += dockH; r.w -= dockH }
else r.w -= dockH
}
return r
}
function windowsForApp(appId: string): WinState[] {
return windows.filter(w => w.appId === appId)
}
// ---- 窗口操作 ----
function addWindow(win: WinState) {
windows.push(win)
}
function removeWindow(winId: string) {
const idx = windows.findIndex(w => w.id === winId)
if (idx >= 0) windows.splice(idx, 1)
}
function focus(winId: string) {
const win = windows.find(w => w.id === winId)
if (!win) return
if (win.state === 'minimized') restore(winId)
if (activeWin.value?.id === winId) {
if (win.el) win.el.style.zIndex = String(++zTop.value)
return
}
if (activeWin.value) activeWin.value.el?.classList.add('inactive')
activeWin.value = win
win.el?.classList.remove('inactive')
if (win.el) win.el.style.zIndex = String(++zTop.value)
emitter.emit('wm:focus', win)
emitter.emit('wm:changed')
}
function minimize(winId: string) {
const win = windows.find(w => w.id === winId)
if (!win || win.state === 'fullscreen') return
;(win as any).stateBeforeMin = win.state
win.state = 'minimized'
win.el?.classList.add('minimized')
if (activeWin.value?.id === winId) {
const rest = windows.filter(w => w.state !== 'minimized')
activeWin.value = null
if (rest.length) focus(rest[rest.length - 1].id)
else emitter.emit('wm:focus', null)
}
emitter.emit('wm:changed')
}
function restore(winId: string) {
const win = windows.find(w => w.id === winId)
if (!win) return
win.state = ((win.stateBeforeMin && win.stateBeforeMin !== 'minimized') ? win.stateBeforeMin : 'normal') as WinStateType
win.el?.classList.remove('minimized')
focus(winId)
emitter.emit('wm:changed')
}
function applyRect(win: WinState) {
const r = win.rect
if (win.el) Object.assign(win.el.style, {
left: r.x + 'px', top: r.y + 'px',
width: r.w + 'px', height: r.h + 'px',
})
}
function toggleZoom(winId: string) {
const win = windows.find(w => w.id === winId)
if (!win || win.state === 'fullscreen') return
if (win.state === 'minimized') restore(winId)
if (win.state === 'zoomed') {
win.rect = { ...win.prevRect! }
win.state = 'normal'
} else {
win.prevRect = { ...win.rect }
const u = usableRect()
win.rect = { x: u.x + 4, y: u.y + 4, w: u.w - 8, h: u.h - 8 }
win.state = 'zoomed'
}
applyRect(win)
}
function setTitle(winId: string, title: string) {
const win = windows.find(w => w.id === winId)
if (!win) return
win.title = title
if (win.titleEl) {
const t = win.titleEl.querySelector('.t')
if (t) t.textContent = title
}
}
function clampAll() {
const u = usableRect()
for (const w of windows) {
if (w.state === 'fullscreen' || w.state === 'minimized') continue
w.rect.x = Math.max(u.x, Math.min(w.rect.x, u.x + u.w - 80))
w.rect.y = Math.max(u.y, Math.min(w.rect.y, u.y + u.h - 40))
applyRect(w)
}
}
return {
windows, activeWin, zTop, cascadeCount,
usableRect, windowsForApp,
addWindow, removeWindow, focus, minimize, restore,
applyRect, toggleZoom, setTitle, clampAll,
}
})

View File

@ -161,20 +161,5 @@ export function initSystem() {
Sys.boot() Sys.boot()
} }
// Expose globals for compatibility // 各模块通过 store/composable 访问,不再挂载到 window 全局
;(window as any).Sys = Sys // 如需在其他位置使用,请通过 import { useXxxStore } from '@/stores/xxx' 访问
;(window as any).Notify = Notify
;(window as any).Spotlight = Spotlight
;(window as any).WM = wm
;(window as any).UI = ui
;(window as any).FS = fs
;(window as any).Apps = Apps
;(window as any).Bus = bus
;(window as any).Store = store
;(window as any).WALLPAPERS = WALLPAPERS
;(window as any).$ = $
;(window as any).$$ = $$
;(window as any).el = el
;(window as any).esc = esc
;(window as any).clamp = clamp
;(window as any).iconImg = iconImg

29
src/types/app.ts Normal file
View File

@ -0,0 +1,29 @@
// ============ 应用 ============
export interface MenuItem {
label?: string
key?: string
checked?: boolean
disabled?: boolean
submenu?: MenuItem[]
action?: () => void
sep?: boolean
icon?: string
}
export interface AppDefinition {
id: string
name: string
icon: string
w?: number
h?: number
minW?: number
minH?: number
singleton?: boolean
about?: string
noResize?: boolean
storeApp?: boolean
onArgs?: (args: any, win: any) => void
menus?: (win: any) => MenuItem[]
render?: (win: any, args?: any) => void
}

17
src/types/fs.ts Normal file
View File

@ -0,0 +1,17 @@
// ============ 虚拟文件系统 ============
export interface FSNode {
t: 'd' | 'f' | 'a'
c?: Record<string, FSNode>
data?: string
mtime: number
mime?: string
app?: string
origPath?: string
}
export interface FSEntry {
name: string
path: string
node: FSNode
}

11
src/types/notify.ts Normal file
View File

@ -0,0 +1,11 @@
// ============ 通知 ============
export interface Notification {
id: string
appId: string
title: string
body: string
ts: number
read: boolean
icon: string
}

30
src/types/settings.ts Normal file
View File

@ -0,0 +1,30 @@
// ============ 系统设置 ============
export interface Wallpaper {
id: string
name: string
src: string
dark?: string
}
export interface SystemSettings {
appearance: string; wallpaper: string; accent: string
reduceTransparency: boolean; reduceMotion: boolean; increaseContrast: boolean
dockSize: number; dockMagnify: boolean; dockMagnifyLevel: number; dockPosition: string; dockAutohide: boolean
brightness: number; nightShift: boolean; nightShiftStrength: number
volume: number; muted: boolean
wifi: boolean; bluetooth: boolean; airdrop: boolean; focus: boolean; vpn: boolean
userName: string; avatar: string
passwordEnabled: boolean; password: string
screensaverType: string; screensaverDelay: number
h24: boolean; language: string; region: string; firstDayMonday: boolean
searchEngine: string; computerName: string
siriApps: boolean; siriFiles: boolean; siriSettings: boolean
loginItems: Record<string, boolean>; notifAllow: Record<string, boolean>
notificationsEnabled: boolean
timezone: string
wifiNetwork: string
btDevices: Record<string, any>
kbRepeat: number; kbDelay: number; mouseSpeed: number
finderView: string; finderSort: string
}

10
src/types/ui.ts Normal file
View File

@ -0,0 +1,10 @@
// ============ 对话框 ============
export interface DialogConfig {
icon?: string
title: string
msg: string
buttons?: string[]
ok?: string
danger?: boolean
}

36
src/types/window.ts Normal file
View File

@ -0,0 +1,36 @@
// ============ 窗口状态 ============
export interface Rect {
x: number
y: number
w: number
h: number
}
export type WinStateType = 'normal' | 'minimized' | 'zoomed' | 'fullscreen'
export interface WinState {
id: string
appId: string
app: any
title: string
icon: string
rect: Rect
prevRect: Rect | null
minW: number
minH: number
state: WinStateType
onClose: (() => void) | null
data: any
noResize: boolean
el: HTMLElement | null
body: HTMLElement | null
titleEl: HTMLElement | null
timers: ReturnType<typeof setInterval | typeof setTimeout>[]
appState?: any
confirmClose?: (done: () => void, cancel: () => void) => void
closePromise?: Promise<string> | null
_closed?: boolean
stateBeforeMin?: string
stateBeforeFs?: string
}