rebuild: 第五版重构 - 添加pinia
This commit is contained in:
parent
a708b4cdee
commit
d3c89d2c09
1874
REFACTORING_GUIDE.md
Normal file
1874
REFACTORING_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,8 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
1946
pnpm-lock.yaml
generated
Normal file
1946
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
src/composables/useEventBus.ts
Normal file
28
src/composables/useEventBus.ts
Normal 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>()
|
||||
@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { initPinia } from './stores'
|
||||
|
||||
// Import all CSS
|
||||
import './styles/base.css'
|
||||
@ -9,4 +10,5 @@ import './styles/apps.css'
|
||||
import './styles/apps2.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(initPinia())
|
||||
app.mount('#app')
|
||||
|
||||
55
src/stores/apps.ts
Normal file
55
src/stores/apps.ts
Normal 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
315
src/stores/fs.ts
Normal 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
11
src/stores/index.ts
Normal 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
64
src/stores/notify.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
37
src/stores/plugins/persist.ts
Normal file
37
src/stores/plugins/persist.ts
Normal 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
118
src/stores/settings.ts
Normal 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
89
src/stores/ui.ts
Normal 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
135
src/stores/wm.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@ -161,20 +161,5 @@ export function initSystem() {
|
||||
Sys.boot()
|
||||
}
|
||||
|
||||
// Expose globals for compatibility
|
||||
;(window as any).Sys = Sys
|
||||
;(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
|
||||
// 各模块通过 store/composable 访问,不再挂载到 window 全局
|
||||
// 如需在其他位置使用,请通过 import { useXxxStore } from '@/stores/xxx' 访问
|
||||
|
||||
29
src/types/app.ts
Normal file
29
src/types/app.ts
Normal 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
17
src/types/fs.ts
Normal 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
11
src/types/notify.ts
Normal 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
30
src/types/settings.ts
Normal 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
10
src/types/ui.ts
Normal 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
36
src/types/window.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user