diff --git a/src/App.vue b/src/App.vue index 6b4f0f4..39d5d96 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,90 +1,42 @@ diff --git a/src/apps/bear/index.ts b/src/apps/bear/index.ts index 0680027..11df2b3 100644 --- a/src/apps/bear/index.ts +++ b/src/apps/bear/index.ts @@ -1,22 +1,15 @@ -// 熊掌记 — Markdown 笔记 (Store App) -import { el } from '../../utils' -import { fs as FS } from '../../composables/useFS' -import { wm as WM } from '../../composables/useWM' +// 熊掌记 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' +import BearComponent from './Bear.vue' export const BearApp = { id: 'bear', name: '熊掌记', icon: '/assets/icons/bear.png', storeApp: true, w: 720, h: 500, minW: 480, minH: 320, menus(win: any) { return stdMenus(this, { file: [{ label: '保存', key: '⌘S', action: () => win?.appState?.save() }] }) }, render(win: any) { - const st = win.appState = { path: FS.HOME + '/Documents/熊掌记.md' } - win.body.classList.add('te-body') - const ta = el('textarea', { class: 'te-area', placeholder: '# 标题\n\n用 Markdown 记录想法…' }) as HTMLTextAreaElement - win.body.append(el('div', { class: 'bear-tagbar' }, el('span', { class: 'bear-tag', text: '#灵感' }), el('span', { class: 'bear-tag', text: '#工作' })), ta) - try { if (FS.exists(st.path)) ta.value = FS.read(st.path); else ta.value = '# 欢迎使用熊掌记\n\n这是一篇 **Markdown** 笔记。\n' } catch (e) {} - st.save = () => { FS.write(st.path, ta.value, { mime: 'text/markdown' }); WM.setTitle(win, '熊掌记') } - ta.addEventListener('input', () => WM.setTitle(win, '熊掌记', true)) - win.el.addEventListener('keydown', (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); st.save() } }) + const vnode = h(BearComponent, { win }) + render(vnode, win.body) } } Apps.register(BearApp) diff --git a/src/apps/calculator/Calc.vue b/src/apps/calculator/Calc.vue new file mode 100644 index 0000000..d454941 --- /dev/null +++ b/src/apps/calculator/Calc.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/apps/calculator/index.ts b/src/apps/calculator/index.ts index 5e6e3dd..08674c6 100644 --- a/src/apps/calculator/index.ts +++ b/src/apps/calculator/index.ts @@ -1,64 +1,15 @@ -// 计算器应用 — 从 js/apps.js 手动转写 -import { el } from '../../utils' +// 计算器应用 — 从 js/apps.js 手动转写,Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' +import CalcComponent from './Calc.vue' export const CalculatorApp = { id: 'calculator', name: '计算器', icon: '/assets/icons/calculator.png', w: 260, h: 400, minW: 240, minH: 380, noResize: false, menus() { return stdMenus(this) }, render(win: any) { - const st = win.appState = { disp: '0', acc: null as number | null, op: null as string | null, fresh: true, err: false } - win.body.classList.add('calc-body') - const disp = el('div', { class: 'calc-disp', text: '0', role: 'status' }) - const grid = el('div', { class: 'calc-grid' }) - win.body.append(disp, grid) - const show = (v: string) => { st.disp = v; disp.textContent = v; disp.style.fontSize = (v.length > 9 ? '30px' : v.length > 6 ? '42px' : '54px') } - const calc = (a: number, b: number, op: string) => { switch (op) { case '+': return a + b; case '-': return a - b; case '*': return a * b; case '/': return b === 0 ? NaN : a / b } return 0 } - const fmt = (n: number) => { if (!isFinite(n)) return '错误'; const r = Math.round(n * 1e10) / 1e10; return String(r).slice(0, 12) } - const inputDigit = (d: string) => { - if (st.err) { st.acc = null; st.op = null; st.err = false } - if (st.fresh) { show(d === '.' ? '0.' : d); st.fresh = false } - else if (st.disp.replace(/[-.]/g, '').length < 12) { - if (d === '.' && st.disp.includes('.')) return - show(st.disp === '0' && d !== '.' ? d : st.disp + d) - } - } - const setOp = (op: string) => { if (st.err) return; if (st.op && !st.fresh) equals(); st.acc = parseFloat(st.disp); st.op = op; st.fresh = true } - const equals = () => { - if (st.op == null || st.acc == null) return - const r = calc(st.acc, parseFloat(st.disp), st.op) - show(fmt(r)); st.acc = null; st.op = null; st.fresh = true - if (!isFinite(r)) st.err = true - } - const clearAll = () => { st.acc = null; st.op = null; st.err = false; st.fresh = true; show('0') } - const negate = () => { if (!st.err && st.disp !== '0') show(st.disp.startsWith('-') ? st.disp.slice(1) : '-' + st.disp) } - const percent = () => { if (!st.err) { show(fmt(parseFloat(st.disp) / 100)); st.fresh = true } } - const keys: [string, string, () => void][] = [ - ['AC', 'fn', clearAll], ['±', 'fn', negate], ['%', 'fn', percent], ['÷', 'op', () => setOp('/')], - ['7', '', () => inputDigit('7')], ['8', '', () => inputDigit('8')], ['9', '', () => inputDigit('9')], ['×', 'op', () => setOp('*')], - ['4', '', () => inputDigit('4')], ['5', '', () => inputDigit('5')], ['6', '', () => inputDigit('6')], ['−', 'op', () => setOp('-')], - ['1', '', () => inputDigit('1')], ['2', '', () => inputDigit('2')], ['3', '', () => inputDigit('3')], ['+', 'op', () => setOp('+')], - ['0', 'zero', () => inputDigit('0')], ['.', '', () => inputDigit('.')], ['=', 'op', equals], - ] - for (const [label, cls, fn] of keys) { - const b = el('button', { class: 'calc-key ' + cls, text: label }) - b.addEventListener('click', () => { fn(); grid.querySelectorAll('.calc-key').forEach(x => x.classList.remove('press')) }) - grid.append(b) - } - win.el.addEventListener('keydown', (e: KeyboardEvent) => { - if ((e.target as Element).matches('input,textarea')) return - const k = e.key - if (/^[0-9.]$/.test(k)) inputDigit(k) - else if (k === '+') setOp('+'); else if (k === '-') setOp('-') - else if (k === '*') setOp('*'); else if (k === '/') { e.preventDefault(); setOp('/') } - else if (k === 'Enter' || k === '=') { e.preventDefault(); equals() } - else if (k === 'Escape' || k.toLowerCase() === 'c') clearAll() - else if (k === 'Backspace') { if (!st.fresh && !st.err) show(st.disp.length > 1 ? st.disp.slice(0, -1) : '0') } - else if (k === '%') percent() - else return - e.stopPropagation() - }) - ;(win.body as HTMLElement).tabIndex = 0 - } + const vnode = h(CalcComponent, { win }) + render(vnode, win.body) + }, } Apps.register(CalculatorApp) diff --git a/src/apps/calendar/Calendar.vue b/src/apps/calendar/Calendar.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/calendar/Calendar.vue @@ -0,0 +1 @@ + diff --git a/src/apps/calendar/index.ts b/src/apps/calendar/index.ts index cced2b9..2fc77c7 100644 --- a/src/apps/calendar/index.ts +++ b/src/apps/calendar/index.ts @@ -1,7 +1,9 @@ +import { h, render } from 'vue' // 日历应用 — 从 js/apps.js 手动转写 import { el, uid, localDateKey } from '../../utils' import { store as Store } from '../../composables/useStore' import { Apps, stdMenus } from '../../composables/useApps' +import CalendarComponent from './Calendar.vue' import { Notify } from '../../composables/useNotify' const WEEK_CN = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] diff --git a/src/apps/contacts/Contacts.vue b/src/apps/contacts/Contacts.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/contacts/Contacts.vue @@ -0,0 +1 @@ + diff --git a/src/apps/contacts/index.ts b/src/apps/contacts/index.ts index d24d596..d28e157 100644 --- a/src/apps/contacts/index.ts +++ b/src/apps/contacts/index.ts @@ -1,8 +1,10 @@ +import { h, render } from 'vue' // 通讯录应用 import { el, uid, debounce } from '../../utils' import { store as Store } from '../../composables/useStore' import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' +import ContactsComponent from './Contacts.vue' export const ContactsApp = { id: 'contacts', name: '通讯录', icon: '/assets/icons/contacts.png', diff --git a/src/apps/facetime/FaceTime.vue b/src/apps/facetime/FaceTime.vue new file mode 100644 index 0000000..c2da5d5 --- /dev/null +++ b/src/apps/facetime/FaceTime.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/apps/facetime/index.ts b/src/apps/facetime/index.ts index 831572d..1c179fa 100644 --- a/src/apps/facetime/index.ts +++ b/src/apps/facetime/index.ts @@ -1,7 +1,7 @@ -// FaceTime 通话应用 -import { el } from '../../utils' +// FaceTime 通话应用 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' -import { ContactsApp } from '../contacts/index' +import FaceTimeComponent from './FaceTime.vue' export const FaceTimeApp = { id: 'facetime', name: 'FaceTime 通话', icon: '/assets/icons/facetime.png', @@ -9,12 +9,8 @@ export const FaceTimeApp = { menus() { return stdMenus(this) }, onArgs(args: any, win: any) { if (args.call) win.appState?.startCall(args.call) }, render(win: any, args: any) { - const st = win.appState = { phase: 'idle', contact: null as any, _t: null as any } - win.body.classList.add('ft-body'); const stage = el('div', { class: 'ft-stage' }); win.body.append(stage) - const renderIdle = () => { stage.innerHTML = ''; stage.append(el('div', { class: 'ft-title', text: '选择联系人开始视频通话' })); const grid = el('div', { class: 'ft-grid' }); ContactsApp.store.get().forEach((c: any) => { const cell = el('div', { class: 'ft-cell' }, el('span', { class: 'contact-big-avatar', text: c.name.slice(0, 1) }), el('div', { text: c.name }), el('button', { class: 'btn primary', text: '📹 呼叫', onclick: () => st.startCall(c) })); grid.append(cell) }); stage.append(grid) } - st.startCall = (c: any) => { st.contact = c; st.phase = 'calling'; renderCall(); st._t = setTimeout(() => { if (st.phase === 'calling') { st.phase = 'connected'; renderCall() } }, 2500) } - const renderCall = () => { const c = st.contact; stage.innerHTML = ''; const video = el('div', { class: 'ft-video' + (st.phase === 'connected' ? ' live' : '') }, el('span', { class: 'contact-big-avatar ft-avatar', text: c.name.slice(0, 1) })); let secs = 0; const status = el('div', { class: 'ft-status', text: st.phase === 'calling' ? `正在呼叫 ${c.name}…` : st.phase === 'connected' ? `${c.name} · 00:00` : '通话已结束' }); const selfView = el('div', { class: 'ft-self' }, el('span', { text: '🧑' })); const muteBtn = el('button', { class: 'ft-ctl', html: '🎙', title: '静音' }); const camBtn = el('button', { class: 'ft-ctl', html: '📷', title: '关闭视频' }); const endBtn = el('button', { class: 'ft-ctl end', html: '📞', title: '结束通话' }); let timer: any = null; if (st.phase === 'connected') { timer = setInterval(() => { secs++; status.textContent = `${c.name} · ${String(Math.floor(secs / 60)).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}` }, 1000); win.timers.push(timer) }; muteBtn.addEventListener('click', () => muteBtn.classList.toggle('off')); camBtn.addEventListener('click', () => { selfView.classList.toggle('hidden'); camBtn.classList.toggle('off') }); endBtn.addEventListener('click', () => { clearInterval(timer); clearTimeout(st._t); st.phase = 'ended'; status.textContent = '通话已结束'; video.classList.remove('live'); setTimeout(renderIdle, 1200) }); stage.append(video, status, st.phase !== 'ended' ? selfView : null, el('div', { class: 'ft-controls' }, muteBtn, camBtn, endBtn)) } - win.onClose = () => clearTimeout(st._t); renderIdle(); if (args.call) st.startCall(args.call) - } + const vnode = h(FaceTimeComponent, { win }) + render(vnode, win.body) + }, } Apps.register(FaceTimeApp) diff --git a/src/apps/finder/Finder.vue b/src/apps/finder/Finder.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/finder/Finder.vue @@ -0,0 +1 @@ + diff --git a/src/apps/finder/index.ts b/src/apps/finder/index.ts index a535562..6d7aac1 100644 --- a/src/apps/finder/index.ts +++ b/src/apps/finder/index.ts @@ -1,3 +1,4 @@ +import { h, render } from 'vue' // Finder 应用 — 从 js/apps.js 手动转写 import { el, iconImg, fmtBytes, debounce } from '../../utils' import { bus as Bus } from '../../composables/useBus' @@ -5,6 +6,7 @@ import { fs as FS } from '../../composables/useFS' import { wm as WM } from '../../composables/useWM' import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' +import FinderComponent from './Finder.vue' // Sys 引用(运行时可用,由 system/index.ts 暴露为 window.Sys) const Sys = () => (window as any).Sys diff --git a/src/apps/github/GitHub.vue b/src/apps/github/GitHub.vue new file mode 100644 index 0000000..5f941c9 --- /dev/null +++ b/src/apps/github/GitHub.vue @@ -0,0 +1,20 @@ + diff --git a/src/apps/github/index.ts b/src/apps/github/index.ts index acb3b5d..f918ca1 100644 --- a/src/apps/github/index.ts +++ b/src/apps/github/index.ts @@ -1,16 +1,15 @@ -// GitHub Desktop (Store App) -import { el } from '../../utils' +// GitHub Desktop (Store App) — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' +import GitHubComponent from './GitHub.vue' export const GitHubApp = { id: 'github', name: 'GitHub Desktop', icon: '/assets/icons/github.png', storeApp: true, w: 780, h: 540, minW: 560, minH: 380, menus() { return stdMenus(this) }, render(win: any) { - win.body.classList.add('gh-body') - win.body.append( - el('div', { class: 'gh-head' }, el('div', { class: 'gh-repo', text: 'moonshot / macos-web' }), el('div', { class: 'gh-badges' }, el('span', { class: 'gh-badge', text: '★ 1024' }), el('span', { class: 'gh-badge', text: '⑂ 128' }))), - el('div', { class: 'gh-readme' }, el('h2', { text: 'macos-web' }), el('p', { text: '纯静态 macOS 桌面模拟器:原生 HTML/CSS/JavaScript,无构建链。' }), el('pre', { class: 'sf-code', text: 'git clone https://example.com/macos-web\ncd macos-web\n# 双击 index.html 即可运行' }), el('h3', { text: '特性' }), el('p', { text: '· 完整窗口管理器\n· localStorage 虚拟文件系统\n· 20+ 内置应用\n· 离线优先,优雅回退' }))) + const vnode = h(GitHubComponent, { win }) + render(vnode, win.body) } } Apps.register(GitHubApp) diff --git a/src/apps/keynote/Keynote.vue b/src/apps/keynote/Keynote.vue new file mode 100644 index 0000000..950d04b --- /dev/null +++ b/src/apps/keynote/Keynote.vue @@ -0,0 +1,71 @@ + + + diff --git a/src/apps/keynote/index.ts b/src/apps/keynote/index.ts index 8657b07..232bf6c 100644 --- a/src/apps/keynote/index.ts +++ b/src/apps/keynote/index.ts @@ -1,24 +1,15 @@ -// Keynote 讲演 — 简易幻灯片 (Store App) -import { el } from '../../utils' +// Keynote 讲演 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' +import KeynoteComponent from './Keynote.vue' export const KeynoteApp = { id: 'keynote', name: 'Keynote 讲演', icon: '/assets/icons/keynote.png', storeApp: true, w: 800, h: 560, minW: 560, minH: 400, menus(win: any) { return stdMenus(this, { view: [{ label: '播放', key: '⌘P', action: () => win?.appState?.play() }] }) }, - slides: [ - { title: 'macOS 网页版', sub: '在浏览器中运行的桌面体验', bg: ['#4a6fa5', '#c86b85'] }, - { title: '虚拟文件系统', sub: '访达 · 终端 · 文本编辑 共享同一套文件', bg: ['#0a84ff', '#64d2ff'] }, - { title: '窗口管理器', sub: '拖动 · 缩放 · 全屏 · 最小化', bg: ['#bf5af2', '#ff9f0a'] }, - { title: '谢谢观看', sub: 'Made with ❤️', bg: ['#32d74b', '#0a84ff'] }, - ], render(win: any) { - const st = win.appState = { idx: 0 }; win.body.classList.add('keynote-body') - const side = el('div', { class: 'kn-side' }), stage = el('div', { class: 'kn-stage' }); win.body.append(side, stage) - const renderSide = () => { side.innerHTML = ''; this.slides.forEach((s, i) => { const th = el('div', { class: 'kn-thumb' + (i === st.idx ? ' sel' : ''), style: { background: `linear-gradient(135deg,${s.bg[0]},${s.bg[1]})` } }, el('span', { text: s.title })); th.addEventListener('click', () => { st.idx = i; renderAll() }); side.append(th) }) } - const renderStage = () => { const s = this.slides[st.idx]; stage.innerHTML = ''; stage.append(el('div', { class: 'kn-slide', style: { background: `linear-gradient(135deg,${s.bg[0]},${s.bg[1]})` } }, el('div', { class: 'kn-title', text: s.title }), el('div', { class: 'kn-sub', text: s.sub }))) } - st.play = () => { let i = 0; const ov = el('div', { class: 'kn-play', tabindex: '0' }); const show = () => { const s = this.slides[i]; ov.style.background = `linear-gradient(135deg,${s.bg[0]},${s.bg[1]})`; ov.innerHTML = ''; ov.append(el('div', { class: 'kn-title big', text: s.title }), el('div', { class: 'kn-sub big', text: s.sub })) }; ov.addEventListener('click', () => { i++; i >= this.slides.length ? ov.remove() : show() }); ov.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') ov.remove(); if (e.key === 'ArrowRight' || e.key === ' ') { i++; i >= this.slides.length ? ov.remove() : show() } }); win.body.append(ov); show(); ov.focus() } - const renderAll = () => { renderSide(); renderStage() }; win.el.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { st.idx = Math.min(this.slides.length - 1, st.idx + 1); renderAll() }; if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { st.idx = Math.max(0, st.idx - 1); renderAll() } }); renderAll() + const vnode = h(KeynoteComponent, { win }) + render(vnode, win.body) } } Apps.register(KeynoteApp) diff --git a/src/apps/launchpad/Launchpad.vue b/src/apps/launchpad/Launchpad.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/launchpad/Launchpad.vue @@ -0,0 +1 @@ + diff --git a/src/apps/launchpad/index.ts b/src/apps/launchpad/index.ts index d5561f6..7dd303d 100644 --- a/src/apps/launchpad/index.ts +++ b/src/apps/launchpad/index.ts @@ -1,6 +1,8 @@ +import { h, render } from 'vue' // 启动台应用 — 从 js/apps3.js 手动转写 import { el, iconImg } from '../../utils' import { Apps, stdMenus } from '../../composables/useApps' +import LaunchpadComponent from './Launchpad.vue' import { ui as UI } from '../../composables/useUI' let _AppStoreApp: any = null diff --git a/src/apps/mail/Mail.vue b/src/apps/mail/Mail.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/mail/Mail.vue @@ -0,0 +1 @@ + diff --git a/src/apps/mail/index.ts b/src/apps/mail/index.ts index 6317dc4..ba4c705 100644 --- a/src/apps/mail/index.ts +++ b/src/apps/mail/index.ts @@ -1,7 +1,9 @@ +import { h, render } from 'vue' // 邮件应用 import { el, $$, uid, relTime, debounce } from '../../utils' import { store as Store } from '../../composables/useStore' import { Apps, stdMenus } from '../../composables/useApps' +import MailComponent from './Mail.vue' import { Notify } from '../../composables/useNotify' export const MailApp = { diff --git a/src/apps/maps/Maps.vue b/src/apps/maps/Maps.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/maps/Maps.vue @@ -0,0 +1 @@ + diff --git a/src/apps/maps/index.ts b/src/apps/maps/index.ts index e1a54fd..526cd1a 100644 --- a/src/apps/maps/index.ts +++ b/src/apps/maps/index.ts @@ -1,6 +1,8 @@ +import { h, render } from 'vue' // 地图应用 — 从 js/apps3.js 手动转写 import { el, clamp, debounce } from '../../utils' import { Apps, stdMenus } from '../../composables/useApps' +import MapsComponent from './Maps.vue' const MAP_POIS = [ { name: '天安门广场', x: 0.50, y: 0.52, kind: 'landmark' }, diff --git a/src/apps/messages/Messages.vue b/src/apps/messages/Messages.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/messages/Messages.vue @@ -0,0 +1 @@ + diff --git a/src/apps/messages/index.ts b/src/apps/messages/index.ts index b64d9bd..59447e3 100644 --- a/src/apps/messages/index.ts +++ b/src/apps/messages/index.ts @@ -1,8 +1,10 @@ +import { h, render } from 'vue' // 信息应用 import { el, uid, relTime } from '../../utils' import { store as Store } from '../../composables/useStore' import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' +import MessagesComponent from './Messages.vue' import { Notify } from '../../composables/useNotify' export const MessagesApp = { diff --git a/src/apps/music/Music.vue b/src/apps/music/Music.vue new file mode 100644 index 0000000..7652e55 --- /dev/null +++ b/src/apps/music/Music.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/apps/music/index.ts b/src/apps/music/index.ts index 583408b..947c2ba 100644 --- a/src/apps/music/index.ts +++ b/src/apps/music/index.ts @@ -1,17 +1,7 @@ -// 音乐应用 — 从 js/apps2.js 手动转写 -import { el, iconImg, fmtDur } from '../../utils' -import { bus as Bus } from '../../composables/useBus' +// 音乐应用 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' -const Sys = () => (window as any).Sys - -const MUSIC_TRACKS = [ - { file: 'Monkeys Spinning Monkeys', title: 'Monkeys Spinning Monkeys', album: 'Hidden Agenda', dur: 22 }, - { file: 'Fluffing a Duck', title: 'Fluffing a Duck', album: 'Curious', dur: 32 }, - { file: 'Carefree', title: 'Carefree', album: 'Life of Riley', dur: 59 }, - { file: 'Daily Beetle', title: 'Daily Beetle', album: 'Daily Beetle', dur: 38 }, - { file: 'Sneaky Snitch', title: 'Sneaky Snitch', album: 'Mysterioso', dur: 25 }, - { file: 'Wallpaper', title: 'Wallpaper', album: 'Wallpaper', dur: 28 }, -] +import MusicComponent from './Music.vue' export const MusicApp = { id: 'music', name: '音乐', icon: '/assets/icons/music.png', @@ -20,84 +10,8 @@ export const MusicApp = { return stdMenus(this, { file: [{ label: '播放', key: '空格', action: () => win?.appState?.toggle() }] }) }, render(win: any) { - const S = Sys() - const st = win.appState = { idx: -1, playing: false, failed: false } - win.body.classList.add('music-body') - const side = el('div', { class: 'photos-side music-side' }, - el('div', { class: 'fb-side-title', text: '资料库' }), - el('div', { class: 'fb-side-item sel' }, el('span', { class: 'fb-side-ico', text: '🎵' }), el('span', { text: '歌曲' })), - el('div', { class: 'fb-side-item' }, el('span', { class: 'fb-side-ico', text: '💿' }), el('span', { text: '专辑' })), - el('div', { class: 'fb-side-item' }, el('span', { class: 'fb-side-ico', text: '🎤' }), el('span', { text: '艺人' })), - el('div', { class: 'music-credit', text: '音乐:Kevin MacLeod\nincompetech.com\nCC-BY 4.0' })) - const listEl = el('div', { class: 'music-list' }) - const main = el('div', { class: 'music-main' }, listEl) - const artEl = el('div', { class: 'music-art' }, iconImg(this.icon, '', '')) - const titleEl = el('div', { class: 'music-now-title', text: '未在播放' }) - const subEl = el('div', { class: 'music-now-sub', text: 'Kevin MacLeod' }) - const prevBtn = el('button', { class: 'music-ctl', html: '⏮', title: '上一首' }) - const playBtn = el('button', { class: 'music-ctl play', html: '▶', title: '播放/暂停' }) - const nextBtn = el('button', { class: 'music-ctl', html: '⏭', title: '下一首' }) - const curT = el('span', { class: 'music-time', text: '0:00' }) - const durT = el('span', { class: 'music-time', text: '--:--' }) - const seek = el('input', { type: 'range', class: 'slider music-seek', min: '0', max: '100', value: '0' }) as HTMLInputElement - const bar = el('div', { class: 'music-bar' }, - el('div', { class: 'music-now' }, artEl, el('div', null, titleEl, subEl)), - el('div', { class: 'music-center' }, - el('div', { class: 'music-ctls' }, prevBtn, playBtn, nextBtn), - el('div', { class: 'music-seek-row' }, curT, seek, durT)), - el('div', { class: 'music-vol' }, (() => { - const v = el('input', { type: 'range', class: 'slider', min: '0', max: '100', value: String(Math.round(S.settings.volume * 100)), title: '音量(全局)' }) as HTMLInputElement - v.style.width = '76px'; v.style.setProperty('--fill', (S.settings.volume * 100) + '%') - v.addEventListener('input', () => { S.settings.volume = +v.value / 100; S.settings.muted = +v.value === 0; v.style.setProperty('--fill', v.value + '%'); S.applyVolume(); S.save() }) - ;(win as any)._volUnsub = Bus.on('volume:changed', (gv: number) => { v.value = String(Math.round(gv * 100)); v.style.setProperty('--fill', (gv * 100) + '%') }) - return v - })())) - win.body.append(main, bar) - const audio = new Audio() - S.registerMedia(audio) - audio.preload = 'metadata' - win.onClose = () => { audio.pause(); audio.src = ''; S.unregisterMedia(audio); (win as any)._volUnsub?.() } - - const renderList = () => { - listEl.innerHTML = '' - listEl.append(el('div', { class: 'music-row music-head' }, el('span', { text: '' }), el('span', { text: '标题' }), el('span', { text: '专辑' }), el('span', { text: '时长' }))) - MUSIC_TRACKS.forEach((t: any, i: number) => { - const row = el('div', { class: 'music-row' + (i === st.idx ? ' sel' : '') }, - el('span', { class: 'music-note', text: i === st.idx && st.playing ? '♪' : String(i + 1) }), - el('span', { class: 'music-t' }, el('b', { text: t.title }), el('small', { text: 'Kevin MacLeod' })), - el('span', { class: 'music-album', text: t.album }), el('span', { class: 'music-dur', text: fmtDur(t.dur) })) - row.addEventListener('click', () => playAt(i)); listEl.append(row) - }) - } - const setNow = () => { - const t = MUSIC_TRACKS[st.idx]; titleEl.textContent = t ? t.title : '未在播放' - subEl.textContent = t ? `Kevin MacLeod — ${t.album}` : 'Kevin MacLeod' - artEl.classList.toggle('spin', st.playing) - } - const playAt = (i: number) => { - st.idx = i; st.failed = false; const t = MUSIC_TRACKS[i] - audio.src = `/assets/audio/${encodeURIComponent(t.file)}.mp3` - audio.play().then(() => { st.playing = true; syncUI() }).catch(() => { st.playing = false; st.failed = true; syncUI() }) - renderList(); setNow() - } - st.toggle = () => { - if (st.idx < 0) return playAt(0) - if (audio.paused) audio.play().then(() => { st.playing = true; syncUI() }).catch(() => { st.failed = true; syncUI() }) - else { audio.pause(); st.playing = false; syncUI() } - } - const syncUI = () => { playBtn.innerHTML = st.playing ? '⏸' : '▶'; if (st.failed) { titleEl.textContent = '无法播放'; subEl.textContent = '音频文件缺失或加载失败' }; renderList(); setNow() } - audio.addEventListener('timeupdate', () => { - if (!audio.duration) return; curT.textContent = fmtDur(audio.currentTime); durT.textContent = fmtDur(audio.duration) - seek.value = String((audio.currentTime / audio.duration) * 100); seek.style.setProperty('--fill', seek.value + '%') - }) - audio.addEventListener('ended', () => playAt((st.idx + 1) % MUSIC_TRACKS.length)) - audio.addEventListener('error', () => { st.playing = false; st.failed = true; syncUI() }) - seek.addEventListener('input', () => { if (audio.duration) { audio.currentTime = (+seek.value / 100) * audio.duration; seek.style.setProperty('--fill', seek.value + '%') } }) - prevBtn.addEventListener('click', () => playAt((st.idx - 1 + MUSIC_TRACKS.length) % MUSIC_TRACKS.length)) - nextBtn.addEventListener('click', () => playAt((st.idx + 1) % MUSIC_TRACKS.length)) - playBtn.addEventListener('click', () => st.toggle()) - win.el.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === ' ' && !(e.target as Element).matches('input,textarea')) { e.preventDefault(); st.toggle() } }) - renderList() - } + const vnode = h(MusicComponent, { win }) + render(vnode, win.body) + }, } Apps.register(MusicApp) diff --git a/src/apps/news/News.vue b/src/apps/news/News.vue new file mode 100644 index 0000000..d8b0538 --- /dev/null +++ b/src/apps/news/News.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/apps/news/index.ts b/src/apps/news/index.ts index 9403f11..1fa2e00 100644 --- a/src/apps/news/index.ts +++ b/src/apps/news/index.ts @@ -1,18 +1,15 @@ -// Apple News (Store App) -import { el } from '../../utils' +// Apple News (Store App) — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' +import NewsComponent from './News.vue' export const NewsApp = { id: 'news', name: 'Apple News', icon: '/assets/icons/news.png', storeApp: true, w: 760, h: 540, minW: 520, minH: 360, menus() { return stdMenus(this) }, render(win: any) { - win.body.classList.add('news-body'); - [['macOS 网页版 1.0 正式发布', '科技', '完全在浏览器中运行的桌面体验:窗口管理、虚拟文件系统、20+ 款应用,全部离线可用。'], - ['本地优先软件为何卷土重来', '观点', '当云服务成为默认,选择把数据留在本地反而成了一种新的自由。'], - ['浏览器平台能力年度盘点', '技术', '从通知到文件系统访问,Web 与原生应用的边界正在消融。'], - ['开源素材生态探访', '社区', 'CC-BY 音乐、CC0 视频与社区图标包,让个人项目也能拥有完整质感。']] - .forEach(([t, c, d]) => { win.body.append(el('div', { class: 'news-card' }, el('div', { class: 'news-cat', text: c }), el('div', { class: 'news-title', text: t }), el('div', { class: 'news-desc', text: d }))) }) + const vnode = h(NewsComponent, { win }) + render(vnode, win.body) } } Apps.register(NewsApp) diff --git a/src/apps/notes/Notes.vue b/src/apps/notes/Notes.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/notes/Notes.vue @@ -0,0 +1 @@ + diff --git a/src/apps/notes/index.ts b/src/apps/notes/index.ts index f28dd1f..a84ec47 100644 --- a/src/apps/notes/index.ts +++ b/src/apps/notes/index.ts @@ -1,8 +1,10 @@ +import { h, render } from 'vue' // 备忘录应用 — 从 js/apps.js 手动转写 import { el, uid, debounce, relTime } from '../../utils' import { store as Store } from '../../composables/useStore' import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' +import NotesComponent from './Notes.vue' export const NotesApp = { id: 'notes', name: '备忘录', icon: '/assets/icons/notes.png', diff --git a/src/apps/photos/Photos.vue b/src/apps/photos/Photos.vue new file mode 100644 index 0000000..43996eb --- /dev/null +++ b/src/apps/photos/Photos.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/apps/photos/index.ts b/src/apps/photos/index.ts index f29b8bf..a7106e2 100644 --- a/src/apps/photos/index.ts +++ b/src/apps/photos/index.ts @@ -1,57 +1,15 @@ -// 照片应用 — 从 js/apps2.js 手动转写 -import { el, iconImg } from '../../utils' +// 照片应用 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' -import { WALLPAPERS } from '../../composables/useSettings' +import PhotosComponent from './Photos.vue' export const PhotosApp = { id: 'photos', name: '照片', icon: '/assets/icons/photos.png', w: 820, h: 560, minW: 480, minH: 320, menus() { return stdMenus(this, { view: [] }) }, - photos() { return WALLPAPERS.map((w: any) => ({ src: w.src, name: w.name, date: '2026年7月' })) }, render(win: any) { - const st = win.appState = { idx: 0 } - win.body.classList.add('photos-body') - const side = el('div', { class: 'photos-side' }, - el('div', { class: 'fb-side-title', text: '图库' }), - el('div', { class: 'fb-side-item sel' }, el('span', { class: 'fb-side-ico', text: '🖼' }), el('span', { text: '所有照片' })), - el('div', { class: 'fb-side-item' }, el('span', { class: 'fb-side-ico', text: '⭐' }), el('span', { text: '个人收藏' })), - el('div', { class: 'fb-side-item' }, el('span', { class: 'fb-side-ico', text: '🕘' }), el('span', { text: '最近项目' }))) - const grid = el('div', { class: 'photos-grid' }) - win.body.append(side, grid) - const items = this.photos() - for (let i = 0; i < items.length; i++) { - const p = items[i] - const cell = el('div', { class: 'photo-cell', tabindex: '0' }) - const im = iconImg(p.src, '', p.name) - im.addEventListener('error', () => { cell.classList.add('photo-missing'); cell.innerHTML = ''; cell.append(el('div', { class: 'photo-fallback', text: '🌄' })) }, { once: true }) - cell.append(im, el('div', { class: 'photo-name', text: p.name })) - cell.addEventListener('click', () => openLightbox(i)) - grid.append(cell) - } - const openLightbox = (i: number) => { - st.idx = i - const lb = el('div', { class: 'lightbox', tabindex: '0' }) - const counter = el('div', { class: 'lb-counter' }) - const im = el('img', { class: 'lb-img', alt: '' }) - const close = () => lb.remove() - const show = () => { - const p = items[st.idx] - im.src = p.src; (im as any).onerror = () => { im.remove(); lb.append(el('div', { class: 'lb-fallback', text: '图片不可用' })) } - counter.textContent = `${p.name} — ${st.idx + 1} / ${items.length}` - } - const nav = (d: number) => { st.idx = (st.idx + d + items.length) % items.length; show() } - lb.append( - el('button', { class: 'lb-btn lb-close', html: '✕', title: '关闭', onclick: close }), - el('button', { class: 'lb-btn lb-prev', html: '‹', title: '上一张', onclick: (e: Event) => { e.stopPropagation(); nav(-1) } }), - im, counter, - el('button', { class: 'lb-btn lb-next', html: '›', title: '下一张', onclick: (e: Event) => { e.stopPropagation(); nav(1) } })) - lb.addEventListener('click', (e: Event) => { if (e.target === lb) close() }) - lb.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') close(); if (e.key === 'ArrowLeft') nav(-1); if (e.key === 'ArrowRight') nav(1) - }) - win.body.append(lb) - show(); lb.focus() - } + const vnode = h(PhotosComponent, { win }) + render(vnode, win.body) } } Apps.register(PhotosApp) diff --git a/src/apps/podcasts/Podcasts.vue b/src/apps/podcasts/Podcasts.vue new file mode 100644 index 0000000..1e1782c --- /dev/null +++ b/src/apps/podcasts/Podcasts.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/apps/podcasts/index.ts b/src/apps/podcasts/index.ts index 5339d9d..bdb7775 100644 --- a/src/apps/podcasts/index.ts +++ b/src/apps/podcasts/index.ts @@ -1,35 +1,15 @@ -// 播客 (Store App) — 复用 MUSIC_TRACKS -import { el, $$, fmtDur } from '../../utils' +// 播客 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' -const Sys = () => (window as any).Sys - -const MUSIC_TRACKS = [ - { file: 'Monkeys Spinning Monkeys', title: 'Monkeys Spinning Monkeys', album: 'Hidden Agenda', dur: 22 }, - { file: 'Fluffing a Duck', title: 'Fluffing a Duck', album: 'Curious', dur: 32 }, - { file: 'Carefree', title: 'Carefree', album: 'Life of Riley', dur: 59 }, - { file: 'Daily Beetle', title: 'Daily Beetle', album: 'Daily Beetle', dur: 38 }, - { file: 'Sneaky Snitch', title: 'Sneaky Snitch', album: 'Mysterioso', dur: 25 }, - { file: 'Wallpaper', title: 'Wallpaper', album: 'Wallpaper', dur: 28 }, -] +import PodcastsComponent from './Podcasts.vue' export const PodcastsApp = { id: 'podcasts', name: '播客', icon: '/assets/icons/podcasts.png', storeApp: true, w: 720, h: 500, minW: 520, minH: 360, menus() { return stdMenus(this) }, render(win: any) { - const st = win.appState = { audio: new Audio(), cur: -1 } - win.body.classList.add('music-body') - const list = el('div', { class: 'music-list', style: { flex: '1' } }) - const bar = el('div', { class: 'pod-bar' }); win.body.append(list, bar) - Sys().registerMedia(st.audio) - win.onClose = () => { st.audio.pause(); st.audio.src = ''; Sys().unregisterMedia(st.audio) } - const eps = MUSIC_TRACKS.map((t: any, i: number) => ({ title: '第 ' + (i + 1) + ' 期 · ' + t.title, show: 'Web 桌面谈', dur: t.dur, file: t.file })) - eps.forEach((ep: any, i: number) => { - const btn = el('button', { class: 'btn primary', text: '▶' }) - btn.addEventListener('click', () => { if (st.cur === i && !st.audio.paused) { st.audio.pause(); btn.textContent = '▶'; return }; st.cur = i; st.audio.src = '/assets/audio/' + encodeURIComponent(ep.file) + '.mp3'; st.audio.play().then(() => btn.textContent = '⏸').catch(() => { bar.textContent = '音频加载失败' }) }) - list.append(el('div', { class: 'pod-row' }, el('span', { class: 'pod-art', text: '🎙' }), el('div', { class: 'pod-info' }, el('b', { text: ep.title }), el('small', { text: `${ep.show} · ${fmtDur(ep.dur)}` })), btn)) - }) - st.audio.addEventListener('ended', () => { st.cur = -1; $$('.pod-row .btn', list).forEach((b: any) => b.textContent = '▶') }) - } + const vnode = h(PodcastsComponent, { win }) + render(vnode, win.body) + }, } Apps.register(PodcastsApp) diff --git a/src/apps/preview/Preview.vue b/src/apps/preview/Preview.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/preview/Preview.vue @@ -0,0 +1 @@ + diff --git a/src/apps/preview/index.ts b/src/apps/preview/index.ts index 73bca77..c04fd68 100644 --- a/src/apps/preview/index.ts +++ b/src/apps/preview/index.ts @@ -1,8 +1,10 @@ +import { h, render } from 'vue' // 预览应用 — 从 js/apps2.js 手动转写 import { el, iconImg, clamp } from '../../utils' import { fs as FS } from '../../composables/useFS' import { wm as WM } from '../../composables/useWM' import { Apps, stdMenus } from '../../composables/useApps' +import PreviewComponent from './Preview.vue' import { WALLPAPERS } from '../../composables/useSettings' export const PreviewApp = { diff --git a/src/apps/quicktime/QuickTime.vue b/src/apps/quicktime/QuickTime.vue new file mode 100644 index 0000000..f5d7f08 --- /dev/null +++ b/src/apps/quicktime/QuickTime.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/apps/quicktime/index.ts b/src/apps/quicktime/index.ts index 1b7be18..99ac3b1 100644 --- a/src/apps/quicktime/index.ts +++ b/src/apps/quicktime/index.ts @@ -1,15 +1,7 @@ -// QuickTime Player 应用 — 从 js/apps2.js 手动转写 -import { el, fmtDur } from '../../utils' -import { bus as Bus } from '../../composables/useBus' -import { wm as WM } from '../../composables/useWM' +// QuickTime Player — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' -const Sys = () => (window as any).Sys - -const QT_VIDEOS = [ - { file: 'flower.mp4', title: '花朵', src: 'CC0 / MDN' }, - { file: 'friday.mp4', title: '星期五', src: 'CC0 / MDN' }, - { file: 'big-buck-bunny.mp4', title: '大雄兔', src: 'CC-BY / Blender' }, -] +import QuickTimeComponent from './QuickTime.vue' export const QuickTimeApp = { id: 'quicktime', name: 'QuickTime Player', icon: '/assets/icons/quicktime.svg', @@ -25,46 +17,8 @@ export const QuickTimeApp = { }) }, render(win: any) { - const S = Sys() - const st = win.appState = { idx: 0 } - win.body.classList.add('qt-body') - const video = el('video', { class: 'qt-video', playsinline: '' }) as HTMLVideoElement - const errBox = el('div', { class: 'qt-error hidden' }, el('div', { class: 'es-icon', text: '🎬' }), el('div', { text: '无法播放此视频' }), el('div', { class: 'qt-error-sub', text: '文件缺失或格式不受支持' })) - const stage = el('div', { class: 'qt-stage' }, video, errBox) - const playBtn = el('button', { class: 'qt-btn', html: '▶', title: '播放/暂停' }) - const prevBtn = el('button', { class: 'qt-btn', html: '⏮', title: '上一个' }) - const nextBtn = el('button', { class: 'qt-btn', html: '⏭', title: '下一个' }) - const curT = el('span', { class: 'music-time', text: '0:00' }) - const durT = el('span', { class: 'music-time', text: '--:--' }) - const seek = el('input', { type: 'range', class: 'slider qt-seek', min: '0', max: '100', value: '0' }) as HTMLInputElement - const vol = el('input', { type: 'range', class: 'slider qt-vol', min: '0', max: '100', value: String(Math.round(S.settings.volume * 100)), title: '音量' }) as HTMLInputElement - const nameEl = el('div', { class: 'qt-name' }) - const controls = el('div', { class: 'qt-controls' }, playBtn, prevBtn, nextBtn, curT, seek, durT, el('span', { class: 'qt-vol-ico', text: '🔊' }), vol) - win.body.append(stage, controls) - S.registerMedia(video) - win.onClose = () => { video.pause(); video.src = ''; S.unregisterMedia(video); (win as any)._volUnsub?.() } - - const load = () => { - const v = QT_VIDEOS[st.idx] - WM.setTitle(win, `${v.title} — QuickTime Player`) - nameEl.textContent = ''; errBox.classList.add('hidden'); video.classList.remove('hidden') - video.innerHTML = `` - video.load(); video.play().catch(() => syncPlay()) - } - const syncPlay = () => { playBtn.innerHTML = video.paused ? '▶' : '⏸' } - st.toggle = () => { video.paused ? video.play().catch(() => {}) : video.pause() } - st.nav = (d: number) => { st.idx = (st.idx + d + QT_VIDEOS.length) % QT_VIDEOS.length; load() } - video.addEventListener('play', syncPlay); video.addEventListener('pause', syncPlay) - video.addEventListener('timeupdate', () => { if (!video.duration) return; curT.textContent = fmtDur(video.currentTime); durT.textContent = fmtDur(video.duration); seek.value = String((video.currentTime / video.duration) * 100); seek.style.setProperty('--fill', seek.value + '%') }) - video.addEventListener('ended', () => st.nav(1)) - video.addEventListener('error', () => { video.classList.add('hidden'); errBox.classList.remove('hidden') }) - stage.addEventListener('click', () => st.toggle()) - seek.addEventListener('input', () => { if (video.duration) video.currentTime = (+seek.value / 100) * video.duration }) - vol.addEventListener('input', () => { S.settings.volume = +vol.value / 100; S.settings.muted = +vol.value === 0; vol.style.setProperty('--fill', vol.value + '%'); S.applyVolume(); S.save() }) - ;(win as any)._volUnsub = Bus.on('volume:changed', (gv: number) => { vol.value = String(Math.round(gv * 100)); vol.style.setProperty('--fill', (gv * 100) + '%') }) - playBtn.addEventListener('click', () => st.toggle()); prevBtn.addEventListener('click', () => st.nav(-1)); nextBtn.addEventListener('click', () => st.nav(1)) - win.el.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === ' ' && !(e.target as Element).matches('input')) { e.preventDefault(); st.toggle() } }) - load() - } + const vnode = h(QuickTimeComponent, { win }) + render(vnode, win.body) + }, } Apps.register(QuickTimeApp) diff --git a/src/apps/reminders/Reminders.vue b/src/apps/reminders/Reminders.vue new file mode 100644 index 0000000..6a11dd8 --- /dev/null +++ b/src/apps/reminders/Reminders.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/apps/safari/Safari.vue b/src/apps/safari/Safari.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/safari/Safari.vue @@ -0,0 +1 @@ + diff --git a/src/apps/safari/index.ts b/src/apps/safari/index.ts index d3ec8b5..22fb73a 100644 --- a/src/apps/safari/index.ts +++ b/src/apps/safari/index.ts @@ -1,8 +1,10 @@ +import { h, render } from 'vue' // Safari 浏览器应用 — 从 js/apps2.js 手动转写 import { el } from '../../utils' import { store as Store } from '../../composables/useStore' import { wm as WM } from '../../composables/useWM' import { Apps, stdMenus } from '../../composables/useApps' +import SafariComponent from './Safari.vue' const Sys = () => (window as any).Sys export const SafariApp = { diff --git a/src/apps/settings/Settings.vue b/src/apps/settings/Settings.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/settings/Settings.vue @@ -0,0 +1 @@ + diff --git a/src/apps/settings/index.ts b/src/apps/settings/index.ts index cef2c04..f8db5c5 100644 --- a/src/apps/settings/index.ts +++ b/src/apps/settings/index.ts @@ -1,3 +1,4 @@ +import { h, render } from 'vue' // 系统设置应用 — 从 js/settings.js 手动转写 // 注意:这是一个 444 行的大文件,包含 30+ 个设置面板 import { el, iconImg, fmtBytes, debounce } from '../../utils' @@ -6,6 +7,7 @@ import { fs as FS } from '../../composables/useFS' import { wm as WM } from '../../composables/useWM' import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' +import SettingsComponent from './Settings.vue' import { WALLPAPERS } from '../../composables/useSettings' const Sys = () => (window as any).Sys diff --git a/src/apps/stickies/Stickies.vue b/src/apps/stickies/Stickies.vue new file mode 100644 index 0000000..9196ef6 --- /dev/null +++ b/src/apps/stickies/Stickies.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/apps/stickies/index.ts b/src/apps/stickies/index.ts index b262066..aaab103 100644 --- a/src/apps/stickies/index.ts +++ b/src/apps/stickies/index.ts @@ -1,18 +1,24 @@ -// 便笺应用 -import { el, uid, debounce } from '../../utils' -import { store as Store } from '../../composables/useStore' -import { wm as WM } from '../../composables/useWM' -import { ui as UI } from '../../composables/useUI' +// 便笺应用 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' +import StickiesComponent from './Stickies.vue' + +const colors = { yellow:'#fff7ae',blue:'#cfe8ff',green:'#d9f7c4',pink:'#ffd6e8',purple:'#e8d9ff' } as Record export const StickiesApp = { id: 'stickies', name: '便笺', icon: '/assets/icons/stickies.svg', w: 260, h: 240, minW: 200, minH: 160, singleton: false, - colors: { yellow:'#fff7ae',blue:'#cfe8ff',green:'#d9f7c4',pink:'#ffd6e8',purple:'#e8d9ff' } as Record, store: { get(){return Store.get('stickies',[{id:'s1',text:'双击便笺即可编辑。\n通过菜单可以更换颜色。',color:'yellow',x:null,y:null}])}, set(v:any){Store.set('stickies',v)} }, - menus(win:any){const st=win?.appState;return stdMenus(this,{file:[{label:'新建便笺',key:'⌘N',action:()=>this.spawn()},{label:'删除此便笺',key:'⌘⌫',action:()=>st?.removeSelf()}],format:Object.entries(this.colors).map(([k])=>({label:{yellow:'黄色',blue:'蓝色',green:'绿色',pink:'粉色',purple:'紫色'}[k]!,checked:st?.note.color===k,action:()=>st?.setColor(k)}))})}, + menus(win:any){const st=win?.appState;return stdMenus(this,{file:[{label:'新建便笺',key:'⌘N',action:()=>this.spawn()},{label:'删除此便笺',key:'⌘⌫',action:()=>st?.removeSelf()}],format:Object.entries(colors).map(([k])=>({label:{yellow:'黄色',blue:'蓝色',green:'绿色',pink:'粉色',purple:'紫色'}[k]!,checked:st?.note.color===k,action:()=>st?.setColor(k)}))})}, spawn(note?:any){const data=this.store.get();if(!note){note={id:uid(),text:'',color:'yellow',x:null,y:null};data.push(note);this.store.set(data)};return Apps.open('stickies',{noteId:note.id})}, openAll(){const data=this.store.get();if(!data.length)return this.spawn();data.forEach((n:any,i:number)=>setTimeout(()=>this.spawn(n),i*120))}, - render(win:any,args:any){const data=this.store.get();const note=data.find((n:any)=>n.id===args.noteId)||data[0];const st=win.appState={note};win.el.classList.add('sticky-win');win.body.classList.add('no-chrome');win.el.style.background=this.colors[note.color]||'#fff7ae';const area=el('div',{class:'sticky-body',contenteditable:'true','aria-label':'便笺内容'});area.textContent=note.text;win.body.append(area);area.addEventListener('input',debounce(()=>{const all=this.store.get();const n=all.find((x:any)=>x.id===note.id);if(n){n.text=area.textContent;this.store.set(all)}},400));st.setColor=(k:string)=>{note.color=k;const all=this.store.get();const n=all.find((x:any)=>x.id===note.id);if(n){n.color=k;this.store.set(all)};win.el.style.background=this.colors[k]};st.removeSelf=()=>{const all=this.store.get().filter((x:any)=>x.id!==note.id);this.store.set(all);win.confirmClose=null;WM.close(win)};win.el.addEventListener('contextmenu',(e:Event)=>{e.preventDefault();UI.contextMenu([{label:'新建便笺',action:()=>this.spawn()},{label:'删除便笺',action:()=>st.removeSelf()},{sep:true},...Object.entries(this.colors).map(([k])=>({label:{yellow:'黄色',blue:'蓝色',green:'绿色',pink:'粉色',purple:'紫色'}[k]!,checked:note.color===k,action:()=>st.setColor(k)}))],e as MouseEvent)});WM.setTitle(win,'便笺');setTimeout(()=>area.focus(),60)} + render(win:any,args:any){ + const vnode = h(StickiesComponent, { win }) + render(vnode, win.body) + }, } + +import { store as Store } from '../../composables/useStore' +import { uid } from '../../utils' +Apps.register(StickiesApp) Apps.register(StickiesApp) diff --git a/src/apps/terminal/Terminal.vue b/src/apps/terminal/Terminal.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/terminal/Terminal.vue @@ -0,0 +1 @@ + diff --git a/src/apps/terminal/index.ts b/src/apps/terminal/index.ts index b7ac596..009b332 100644 --- a/src/apps/terminal/index.ts +++ b/src/apps/terminal/index.ts @@ -1,7 +1,9 @@ +import { h, render } from 'vue' // 终端应用 — 从 js/apps.js 手动转写 import { $, $$, el } from '../../utils' import { fs as FS } from '../../composables/useFS' import { Apps, stdMenus } from '../../composables/useApps' +import TerminalComponent from './Terminal.vue' export const terminalApp = { id: 'terminal', name: '终端', icon: '/assets/icons/terminal.png', diff --git a/src/apps/textedit/TextEdit.vue b/src/apps/textedit/TextEdit.vue new file mode 100644 index 0000000..0ada1d8 --- /dev/null +++ b/src/apps/textedit/TextEdit.vue @@ -0,0 +1 @@ + diff --git a/src/apps/textedit/index.ts b/src/apps/textedit/index.ts index a236652..398650b 100644 --- a/src/apps/textedit/index.ts +++ b/src/apps/textedit/index.ts @@ -1,9 +1,11 @@ +import { h, render } from 'vue' // 文本编辑应用 — 从 js/apps.js 手动转写 import { el, clamp } from '../../utils' import { fs as FS } from '../../composables/useFS' import { wm as WM } from '../../composables/useWM' import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' +import TextEditComponent from './TextEdit.vue' export const TextEditApp = { id: 'textedit', name: '文本编辑', icon: '/assets/icons/textedit.svg', diff --git a/src/apps/tv/TV.vue b/src/apps/tv/TV.vue new file mode 100644 index 0000000..e043004 --- /dev/null +++ b/src/apps/tv/TV.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/apps/tv/index.ts b/src/apps/tv/index.ts index c856787..73c93bc 100644 --- a/src/apps/tv/index.ts +++ b/src/apps/tv/index.ts @@ -1,28 +1,15 @@ -// Apple TV (Store App) -import { el } from '../../utils' +// Apple TV (Store App) — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' -const Sys = () => (window as any).Sys - -const QT_VIDEOS = [ - { file: 'flower.mp4', title: '花朵', src: 'CC0 / MDN' }, - { file: 'friday.mp4', title: '星期五', src: 'CC0 / MDN' }, - { file: 'big-buck-bunny.mp4', title: '大雄兔', src: 'CC-BY / Blender' }, -] +import TVComponent from './TV.vue' export const TVApp = { id: 'tv', name: 'Apple TV', icon: '/assets/icons/tv.png', storeApp: true, w: 800, h: 560, minW: 560, minH: 400, menus() { return stdMenus(this) }, render(win: any) { - win.body.classList.add('tv-body') - const grid = el('div', { class: 'tv-grid' }), playerBox = el('div', { class: 'tv-player hidden' }) - win.body.append(el('h2', { class: 'store-h', text: '立即观看' }), grid, playerBox) - let video: HTMLVideoElement | null = null - QT_VIDEOS.forEach((v, i) => { - const card = el('div', { class: 'tv-card' }, el('div', { class: 'tv-poster tv-p' + i }, el('span', { text: '▶' })), el('div', { class: 'tv-name', text: v.title }), el('div', { class: 'tv-sub', text: v.src })) - card.addEventListener('click', () => { playerBox.classList.remove('hidden'); if (video) { video.pause(); Sys().unregisterMedia(video) }; playerBox.innerHTML = ''; video = el('video', { class: 'tv-video', controls: '', autoplay: '' }) as HTMLVideoElement; video.innerHTML = ``; video.load(); Sys().registerMedia(video); playerBox.append(el('div', { class: 'tv-ptitle', text: v.title }), video); playerBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }); grid.append(card) - }) - win.onClose = () => { if (video) { video.pause(); video.src = ''; Sys().unregisterMedia(video) } } + const vnode = h(TVComponent, { win }) + render(vnode, win.body) } } Apps.register(TVApp) diff --git a/src/apps/typora/Typora.vue b/src/apps/typora/Typora.vue new file mode 100644 index 0000000..015b9ca --- /dev/null +++ b/src/apps/typora/Typora.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/apps/typora/index.ts b/src/apps/typora/index.ts index 2971503..5d92b66 100644 --- a/src/apps/typora/index.ts +++ b/src/apps/typora/index.ts @@ -1,31 +1,15 @@ -// Typora — 实时预览 Markdown (Store App) -import { el, esc, debounce } from '../../utils' +// Typora — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' +import TyporaComponent from './Typora.vue' export const TyporaApp = { id: 'typora', name: 'Typora', icon: '/assets/icons/typora.png', storeApp: true, w: 780, h: 540, minW: 560, minH: 360, menus() { return stdMenus(this) }, render(win: any) { - win.body.classList.add('typora-body') - const ta = el('textarea', { class: 'te-area typora-edit' }) as HTMLTextAreaElement - const pv = el('div', { class: 'typora-preview' }) - win.body.append(ta, pv) - const renderMd = (md: string) => { - pv.innerHTML = '' - md.split('\n').forEach((line: string) => { - const escd = esc(line).replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/`(.+?)`/g, '$1') - if (line.startsWith('# ')) pv.append(el('h1', { html: escd.slice(2) })) - else if (line.startsWith('## ')) pv.append(el('h2', { html: escd.slice(3) })) - else if (line.startsWith('### ')) pv.append(el('h3', { html: escd.slice(4) })) - else if (line.startsWith('- ')) pv.append(el('p', { html: '• ' + escd.slice(2), class: 'ty-li' })) - else if (line.trim() === '') pv.append(el('div', { style: { height: '10px' } })) - else pv.append(el('p', { html: escd })) - }) - } - ta.value = '# Typora\n\n**所见即所得** 的 Markdown 编辑体验。\n\n- 左侧输入\n- 右侧实时预览\n\n`console.log("hello")`\n' - ta.addEventListener('input', debounce(() => renderMd(ta.value), 150)) - renderMd(ta.value) + const vnode = h(TyporaComponent, { win }) + render(vnode, win.body) } } Apps.register(TyporaApp) diff --git a/src/apps/vscode/VSCode.vue b/src/apps/vscode/VSCode.vue new file mode 100644 index 0000000..b921b12 --- /dev/null +++ b/src/apps/vscode/VSCode.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/apps/vscode/index.ts b/src/apps/vscode/index.ts index 8440b28..966aab3 100644 --- a/src/apps/vscode/index.ts +++ b/src/apps/vscode/index.ts @@ -1,23 +1,15 @@ -// VS Code Web — 轻量编辑器 (Store App) -import { el } from '../../utils' -import { fs as FS } from '../../composables/useFS' +// VS Code Web — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' +import VSCodeComponent from './VSCode.vue' export const VSCodeApp = { id: 'vscode', name: 'VS Code Web', icon: '/assets/icons/vscode.png', storeApp: true, w: 860, h: 560, minW: 600, minH: 400, menus(win: any) { return stdMenus(this, { file: [{ label: '保存', key: '⌘S', action: () => win?.appState?.save() }] }) }, render(win: any) { - const st = win.appState = { path: null as string | null } - win.body.classList.add('vscode-body') - const tree = el('div', { class: 'vs-tree' }) - const ta = el('textarea', { class: 'vs-editor', spellcheck: 'false' }) as HTMLTextAreaElement - const status = el('div', { class: 'vs-status', text: '就绪' }) - win.body.append(tree, el('div', { class: 'vs-main' }, ta, status)) - const renderTree = () => { tree.innerHTML = ''; tree.append(el('div', { class: 'fb-side-title', text: '资源管理器' })); FS.walk(FS.HOME, (p: string, n: any) => { if (p.startsWith(FS.TRASH) || p === FS.HOME) return; const depth = p.split('/').length - 3; if (depth > 2) return; const row = el('div', { class: 'vs-row' + (st.path === p ? ' sel' : ''), style: { paddingLeft: 10 + depth * 14 + 'px' } }, el('span', { text: (n.t === 'd' ? '📁 ' : '📄 ') + FS.baseName(p) })); if (n.t === 'f') row.addEventListener('click', () => { st.path = p; ta.value = FS.read(p); status.textContent = p.replace(FS.HOME, '~'); renderTree() }); tree.append(row) }) } - st.save = () => { if (st.path) { FS.write(st.path, ta.value); status.textContent = '已保存 ' + st.path!.replace(FS.HOME, '~') } } - win.el.addEventListener('keydown', (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); st.save() } }) - renderTree() + const vnode = h(VSCodeComponent, { win }) + render(vnode, win.body) } } Apps.register(VSCodeApp) diff --git a/src/components/busComponents/BannerContainer.vue b/src/components/busComponents/BannerContainer.vue new file mode 100644 index 0000000..56e6ddf --- /dev/null +++ b/src/components/busComponents/BannerContainer.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/busComponents/BootScreen.vue b/src/components/busComponents/BootScreen.vue new file mode 100644 index 0000000..c827351 --- /dev/null +++ b/src/components/busComponents/BootScreen.vue @@ -0,0 +1,10 @@ + diff --git a/src/components/busComponents/ControlCenterArea.vue b/src/components/busComponents/ControlCenterArea.vue new file mode 100644 index 0000000..efdb04c --- /dev/null +++ b/src/components/busComponents/ControlCenterArea.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/busComponents/DesktopArea.vue b/src/components/busComponents/DesktopArea.vue new file mode 100644 index 0000000..306afa3 --- /dev/null +++ b/src/components/busComponents/DesktopArea.vue @@ -0,0 +1,5 @@ + diff --git a/src/components/busComponents/DockArea.vue b/src/components/busComponents/DockArea.vue new file mode 100644 index 0000000..62e0564 --- /dev/null +++ b/src/components/busComponents/DockArea.vue @@ -0,0 +1,4 @@ + diff --git a/src/components/busComponents/LockScreenArea.vue b/src/components/busComponents/LockScreenArea.vue new file mode 100644 index 0000000..0b6d4c6 --- /dev/null +++ b/src/components/busComponents/LockScreenArea.vue @@ -0,0 +1,18 @@ + diff --git a/src/components/busComponents/MenuBarArea.vue b/src/components/busComponents/MenuBarArea.vue new file mode 100644 index 0000000..2da49ec --- /dev/null +++ b/src/components/busComponents/MenuBarArea.vue @@ -0,0 +1,6 @@ + diff --git a/src/components/busComponents/NotificationCenterArea.vue b/src/components/busComponents/NotificationCenterArea.vue new file mode 100644 index 0000000..c8efccf --- /dev/null +++ b/src/components/busComponents/NotificationCenterArea.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/busComponents/PowerOff.vue b/src/components/busComponents/PowerOff.vue new file mode 100644 index 0000000..7219bb6 --- /dev/null +++ b/src/components/busComponents/PowerOff.vue @@ -0,0 +1,5 @@ + diff --git a/src/components/busComponents/ScreensaverArea.vue b/src/components/busComponents/ScreensaverArea.vue new file mode 100644 index 0000000..3d5ea00 --- /dev/null +++ b/src/components/busComponents/ScreensaverArea.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/busComponents/SpotlightArea.vue b/src/components/busComponents/SpotlightArea.vue new file mode 100644 index 0000000..2628cbe --- /dev/null +++ b/src/components/busComponents/SpotlightArea.vue @@ -0,0 +1,14 @@ + diff --git a/src/components/pureComponents/OverlayBrightness.vue b/src/components/pureComponents/OverlayBrightness.vue new file mode 100644 index 0000000..6b4dd1a --- /dev/null +++ b/src/components/pureComponents/OverlayBrightness.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/pureComponents/OverlayNightShift.vue b/src/components/pureComponents/OverlayNightShift.vue new file mode 100644 index 0000000..002547a --- /dev/null +++ b/src/components/pureComponents/OverlayNightShift.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/pureComponents/WindowLayer.vue b/src/components/pureComponents/WindowLayer.vue new file mode 100644 index 0000000..f10cefe --- /dev/null +++ b/src/components/pureComponents/WindowLayer.vue @@ -0,0 +1,3 @@ +