rebuild: 第三版重构
This commit is contained in:
parent
c134a55227
commit
57f052b6cb
112
src/App.vue
112
src/App.vue
@ -1,90 +1,42 @@
|
||||
<template>
|
||||
<!-- 全局遮罩层:亮度 / 夜览 -->
|
||||
<div id="overlay-brightness" aria-hidden="true"></div>
|
||||
<div id="overlay-nightshift" aria-hidden="true"></div>
|
||||
|
||||
<!-- 桌面层 -->
|
||||
<div id="desktop" class="hidden" aria-label="桌面">
|
||||
<div id="desktop-icons"></div>
|
||||
</div>
|
||||
|
||||
<!-- 窗口层 -->
|
||||
<div id="window-layer"></div>
|
||||
|
||||
<!-- 菜单栏 -->
|
||||
<header id="menubar" class="hidden">
|
||||
<div id="menubar-left"></div>
|
||||
<div id="menubar-right"></div>
|
||||
</header>
|
||||
|
||||
<!-- Dock -->
|
||||
<div id="dock-hotzone" aria-hidden="true"></div>
|
||||
<nav id="dock" class="hidden" aria-label="程序坞"></nav>
|
||||
|
||||
<!-- 控制中心 -->
|
||||
<div id="control-center" class="popover hidden" role="dialog" aria-label="控制中心"></div>
|
||||
|
||||
<!-- 通知中心 -->
|
||||
<aside id="notification-center" class="hidden" aria-label="通知中心"></aside>
|
||||
|
||||
<!-- Spotlight -->
|
||||
<div id="spotlight" class="hidden" role="dialog" aria-label="聚焦搜索">
|
||||
<div id="spotlight-box">
|
||||
<div id="spotlight-input-row">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" class="sp-icon">
|
||||
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2.4"/>
|
||||
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input id="spotlight-input" type="text" placeholder="聚焦搜索" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div id="spotlight-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知横幅容器 -->
|
||||
<div id="banner-container"></div>
|
||||
|
||||
<!-- 锁屏 -->
|
||||
<div id="lockscreen" class="hidden">
|
||||
<div id="lockscreen-bg"></div>
|
||||
<div id="lockscreen-main">
|
||||
<div id="lock-date"></div>
|
||||
<div id="lock-time"></div>
|
||||
</div>
|
||||
<div id="lockscreen-user">
|
||||
<img id="lock-avatar" src="/assets/icons/avatar.svg" alt="用户头像">
|
||||
<div id="lock-username">客人用户</div>
|
||||
<div id="lock-password-row" class="hidden">
|
||||
<input id="lock-password" type="password" placeholder="输入密码" aria-label="密码">
|
||||
</div>
|
||||
<div id="lock-hint">点按或按 Enter 键解锁</div>
|
||||
<div id="lock-error" class="hidden">密码不正确,请重试</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 屏保 -->
|
||||
<div id="screensaver" class="hidden"></div>
|
||||
|
||||
<!-- 开机画面 -->
|
||||
<div id="bootscreen">
|
||||
<div id="boot-apple">
|
||||
<svg viewBox="0 0 384 512" width="72" height="96" aria-hidden="true">
|
||||
<path fill="#fff" d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="boot-progress"><div id="boot-progress-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- 关机画面 -->
|
||||
<div id="poweroff" class="hidden">
|
||||
<div id="poweroff-text">已关机<br><span>点按屏幕以开机</span></div>
|
||||
</div>
|
||||
<OverlayBrightness />
|
||||
<OverlayNightShift />
|
||||
<DesktopArea />
|
||||
<WindowLayer />
|
||||
<MenuBarArea />
|
||||
<DockArea />
|
||||
<ControlCenterArea />
|
||||
<NotificationCenterArea />
|
||||
<SpotlightArea />
|
||||
<BannerContainer />
|
||||
<LockScreenArea />
|
||||
<ScreensaverArea />
|
||||
<BootScreen />
|
||||
<PowerOff />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { initSystem } from './system'
|
||||
|
||||
// Pure components
|
||||
import OverlayBrightness from './components/pureComponents/OverlayBrightness.vue'
|
||||
import OverlayNightShift from './components/pureComponents/OverlayNightShift.vue'
|
||||
import WindowLayer from './components/pureComponents/WindowLayer.vue'
|
||||
|
||||
// Business components
|
||||
import DesktopArea from './components/busComponents/DesktopArea.vue'
|
||||
import MenuBarArea from './components/busComponents/MenuBarArea.vue'
|
||||
import DockArea from './components/busComponents/DockArea.vue'
|
||||
import ControlCenterArea from './components/busComponents/ControlCenterArea.vue'
|
||||
import NotificationCenterArea from './components/busComponents/NotificationCenterArea.vue'
|
||||
import SpotlightArea from './components/busComponents/SpotlightArea.vue'
|
||||
import BannerContainer from './components/busComponents/BannerContainer.vue'
|
||||
import LockScreenArea from './components/busComponents/LockScreenArea.vue'
|
||||
import ScreensaverArea from './components/busComponents/ScreensaverArea.vue'
|
||||
import BootScreen from './components/busComponents/BootScreen.vue'
|
||||
import PowerOff from './components/busComponents/PowerOff.vue'
|
||||
|
||||
onMounted(() => {
|
||||
initSystem()
|
||||
})
|
||||
|
||||
1
src/apps/appstore/AppStore.vue
Normal file
1
src/apps/appstore/AppStore.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -1,9 +1,11 @@
|
||||
import { h, render } from 'vue'
|
||||
// App Store 应用 — 从 js/apps3.js 手动转写
|
||||
import { $, $$, el, iconImg, debounce } from '../../utils'
|
||||
import { bus as Bus } from '../../composables/useBus'
|
||||
import { store as Store } from '../../composables/useStore'
|
||||
import { fs as FS, setAppStoreRefForFS } from '../../composables/useFS'
|
||||
import { Apps, stdMenus } from '../../composables/useApps'
|
||||
import AppStoreComponent from './AppStore.vue'
|
||||
import { Notify } from '../../composables/useNotify'
|
||||
import { setAppStoreForLaunchpad } from '../launchpad/index'
|
||||
|
||||
|
||||
38
src/apps/bear/Bear.vue
Normal file
38
src/apps/bear/Bear.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="te-body">
|
||||
<div class="bear-tagbar">
|
||||
<span class="bear-tag">#灵感</span>
|
||||
<span class="bear-tag">#工作</span>
|
||||
</div>
|
||||
<textarea ref="taEl" class="te-area" :placeholder="'# 标题\n\n用 Markdown 记录想法…'" @input="onInput"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fs } from '../../composables/useFS'
|
||||
import { wm } from '../../composables/useWM'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
const taEl = ref<HTMLTextAreaElement>()
|
||||
const st = { path: fs.HOME + '/Documents/熊掌记.md' }
|
||||
|
||||
function save() {
|
||||
if (taEl.value) { fs.write(st.path, taEl.value.value, { mime: 'text/markdown' }); wm.setTitle(props.win, '熊掌记') }
|
||||
}
|
||||
|
||||
function onInput() { wm.setTitle(props.win, '熊掌记', true) }
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); save() }
|
||||
}
|
||||
|
||||
props.win.appState = { save }
|
||||
|
||||
onMounted(() => {
|
||||
if (taEl.value) {
|
||||
try { taEl.value.value = fs.exists(st.path) ? fs.read(st.path) : '# 欢迎使用熊掌记\n\n这是一篇 **Markdown** 笔记。\n' } catch (e) {}
|
||||
}
|
||||
props.win.el?.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
103
src/apps/calculator/Calc.vue
Normal file
103
src/apps/calculator/Calc.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="calc-body">
|
||||
<div class="calc-disp" role="status" :style="{ fontSize: st.disp.length > 9 ? '30px' : st.disp.length > 6 ? '42px' : '54px' }">
|
||||
{{ st.disp }}
|
||||
</div>
|
||||
<div class="calc-grid">
|
||||
<button
|
||||
v-for="(key, i) in keys"
|
||||
:key="i"
|
||||
:class="'calc-key ' + key[1]"
|
||||
@click="handleKey(key[2])"
|
||||
>{{ key[0] }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive } from 'vue'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const st = reactive({
|
||||
disp: '0',
|
||||
acc: null as number | null,
|
||||
op: null as string | null,
|
||||
fresh: true,
|
||||
err: false,
|
||||
})
|
||||
|
||||
// Expose appState to win for menus
|
||||
props.win.appState = st
|
||||
|
||||
const show = (v: string) => { st.disp = v }
|
||||
|
||||
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],
|
||||
]
|
||||
|
||||
const handleKey = (fn: () => void) => { fn() }
|
||||
|
||||
const onKeyDown = (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()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
props.win.el?.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
props.win.el?.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
1
src/apps/calendar/Calendar.vue
Normal file
1
src/apps/calendar/Calendar.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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 = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
|
||||
1
src/apps/contacts/Contacts.vue
Normal file
1
src/apps/contacts/Contacts.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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',
|
||||
|
||||
75
src/apps/facetime/FaceTime.vue
Normal file
75
src/apps/facetime/FaceTime.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="ft-body">
|
||||
<div ref="stageEl" class="ft-stage"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { el } from '../../utils'
|
||||
import { ContactsApp } from '../contacts/index'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
const stageEl = ref<HTMLElement>()
|
||||
const st = reactive({ phase: 'idle' as string, contact: null as any, _t: null as any })
|
||||
|
||||
function renderIdle() {
|
||||
if (!stageEl.value) return
|
||||
stageEl.value.innerHTML = ''
|
||||
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: () => startCall(c) }))
|
||||
grid.append(cell)
|
||||
})
|
||||
stageEl.value.append(el('div', { class: 'ft-title', text: '选择联系人开始视频通话' }), grid)
|
||||
}
|
||||
|
||||
function startCall(c: any) {
|
||||
st.contact = c; st.phase = 'calling'; renderCall()
|
||||
st._t = setTimeout(() => { if (st.phase === 'calling') { st.phase = 'connected'; renderCall() } }, 2500)
|
||||
}
|
||||
|
||||
function renderCall() {
|
||||
if (!stageEl.value) return
|
||||
const c = st.contact
|
||||
stageEl.value.innerHTML = ''
|
||||
let secs = 0
|
||||
const statusEl = el('div', { class: 'ft-status' })
|
||||
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) }))
|
||||
if (st.phase === 'calling') statusEl.textContent = `正在呼叫 ${c.name}…`
|
||||
else if (st.phase === 'connected') statusEl.textContent = `${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++
|
||||
if (statusEl.isConnected) statusEl.textContent = `${c.name} · ${String(Math.floor(secs / 60)).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`
|
||||
}, 1000)
|
||||
props.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'; statusEl.textContent = '通话已结束'; video.classList.remove('live'); setTimeout(renderIdle, 1200) })
|
||||
stageEl.value.append(video, statusEl, st.phase !== 'ended' ? selfView : null, el('div', { class: 'ft-controls' }, muteBtn, camBtn, endBtn))
|
||||
}
|
||||
|
||||
// Expose for onArgs
|
||||
props.win.appState = { startCall }
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(renderIdle)
|
||||
const args = props.win.data
|
||||
if (args?.call) startCall(args.call)
|
||||
})
|
||||
|
||||
onUnmounted(() => { clearTimeout(st._t) })
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
1
src/apps/finder/Finder.vue
Normal file
1
src/apps/finder/Finder.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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
|
||||
|
||||
20
src/apps/github/GitHub.vue
Normal file
20
src/apps/github/GitHub.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="gh-body">
|
||||
<div class="gh-head">
|
||||
<div class="gh-repo">moonshot / macos-web</div>
|
||||
<div class="gh-badges">
|
||||
<span class="gh-badge">★ 1024</span>
|
||||
<span class="gh-badge">⑂ 128</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-readme">
|
||||
<h2>macos-web</h2>
|
||||
<p>纯静态 macOS 桌面模拟器:原生 HTML/CSS/JavaScript,无构建链。</p>
|
||||
<pre class="sf-code">git clone https://example.com/macos-web
|
||||
cd macos-web
|
||||
# 双击 index.html 即可运行</pre>
|
||||
<h3>特性</h3>
|
||||
<p>· 完整窗口管理器<br>· localStorage 虚拟文件系统<br>· 20+ 内置应用<br>· 离线优先,优雅回退</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -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)
|
||||
|
||||
71
src/apps/keynote/Keynote.vue
Normal file
71
src/apps/keynote/Keynote.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="keynote-body">
|
||||
<div class="kn-side">
|
||||
<div v-for="(s, i) in slides" :key="i"
|
||||
:class="'kn-thumb' + (st.idx === i ? ' sel' : '')"
|
||||
:style="{ background: `linear-gradient(135deg,${s.bg[0]},${s.bg[1]})` }"
|
||||
@click="st.idx = i"
|
||||
>
|
||||
<span>{{ s.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kn-stage">
|
||||
<div class="kn-slide" :style="{ background: `linear-gradient(135deg,${currentSlide.bg[0]},${currentSlide.bg[1]})` }">
|
||||
<div class="kn-title">{{ currentSlide.title }}</div>
|
||||
<div class="kn-sub">{{ currentSlide.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="playing" class="kn-play" tabindex="0"
|
||||
:style="{ background: `linear-gradient(135deg,${slides[playIdx].bg[0]},${slides[playIdx].bg[1]})` }"
|
||||
@click="nextPlay" @keydown="onPlayKeydown"
|
||||
>
|
||||
<div class="kn-title big">{{ slides[playIdx].title }}</div>
|
||||
<div class="kn-sub big">{{ slides[playIdx].sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const slides = [
|
||||
{ title: 'macOS 网页版', sub: '在浏览器中运行的桌面体验', bg: ['#4a6fa5', '#c86b85'] },
|
||||
{ title: '虚拟文件系统', sub: '访达 · 终端 · 文本编辑 共享同一套文件', bg: ['#0a84ff', '#64d2ff'] },
|
||||
{ title: '窗口管理器', sub: '拖动 · 缩放 · 全屏 · 最小化', bg: ['#bf5af2', '#ff9f0a'] },
|
||||
{ title: '谢谢观看', sub: 'Made with ❤️', bg: ['#32d74b', '#0a84ff'] },
|
||||
]
|
||||
|
||||
const st = reactive({ idx: 0 })
|
||||
const playing = ref(false)
|
||||
const playIdx = ref(0)
|
||||
|
||||
const currentSlide = computed(() => slides[st.idx])
|
||||
|
||||
function nextPlay() {
|
||||
playIdx.value++
|
||||
if (playIdx.value >= slides.length) playing.value = false
|
||||
}
|
||||
|
||||
function onPlayKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') playing.value = false
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') { nextPlay() }
|
||||
}
|
||||
|
||||
function startPlay() {
|
||||
playIdx.value = 0; playing.value = true
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (playing.value) return
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') st.idx = Math.min(slides.length - 1, st.idx + 1)
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') st.idx = Math.max(0, st.idx - 1)
|
||||
}
|
||||
|
||||
// Expose for menus
|
||||
props.win.appState = { play: startPlay }
|
||||
|
||||
onMounted(() => { props.win.el?.addEventListener('keydown', onKeyDown) })
|
||||
onUnmounted(() => { props.win.el?.removeEventListener('keydown', onKeyDown) })
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
1
src/apps/launchpad/Launchpad.vue
Normal file
1
src/apps/launchpad/Launchpad.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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
|
||||
|
||||
1
src/apps/mail/Mail.vue
Normal file
1
src/apps/mail/Mail.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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 = {
|
||||
|
||||
1
src/apps/maps/Maps.vue
Normal file
1
src/apps/maps/Maps.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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' },
|
||||
|
||||
1
src/apps/messages/Messages.vue
Normal file
1
src/apps/messages/Messages.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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 = {
|
||||
|
||||
83
src/apps/music/Music.vue
Normal file
83
src/apps/music/Music.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="music-body">
|
||||
<div v-if="!albumView" class="music-list" style="flex:1">
|
||||
<div v-for="(t, i) in tracks" :key="i" :class="'music-row' + (st.cur === i ? ' sel' : '')" @click="play(i)">
|
||||
<span class="music-num">{{ st.cur === i && playing ? '▶' : i + 1 }}</span>
|
||||
<div class="music-info">
|
||||
<b>{{ t.title }}</b>
|
||||
<small>{{ t.album }}</small>
|
||||
</div>
|
||||
<span class="music-dur">{{ fmtDur(t.dur) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="music-album">
|
||||
<div class="music-album-art">🎵</div>
|
||||
<b>{{ currentTrack?.title || '' }}</b>
|
||||
<small>{{ currentTrack?.album || '' }}</small>
|
||||
</div>
|
||||
<div class="music-ctl">
|
||||
<button class="music-ctl-btn" @click="prev">⏮</button>
|
||||
<button class="music-ctl-btn music-play" @click="togglePlay">{{ playing ? '⏸' : '▶' }}</button>
|
||||
<button class="music-ctl-btn" @click="next">⏭</button>
|
||||
<button class="music-ctl-btn" style="margin-left:16px" @click="albumView = !albumView">{{ albumView ? '📋' : '🖼' }}</button>
|
||||
</div>
|
||||
<audio ref="audioEl" @ended="next" @error="next"></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onUnmounted } from 'vue'
|
||||
|
||||
const Sys = () => (window as any).Sys
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const 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 },
|
||||
]
|
||||
|
||||
const st = reactive({ cur: -1 })
|
||||
const playing = ref(false)
|
||||
const albumView = ref(false)
|
||||
const audioEl = ref<HTMLAudioElement>()
|
||||
|
||||
const currentTrack = computed(() => st.cur >= 0 ? tracks[st.cur] : null)
|
||||
|
||||
function fmtDur(s: number) { return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` }
|
||||
|
||||
function play(i: number) {
|
||||
if (st.cur === i && audioEl.value) {
|
||||
playing.value = !playing.value
|
||||
playing.value ? audioEl.value.play() : audioEl.value.pause()
|
||||
return
|
||||
}
|
||||
st.cur = i
|
||||
setTimeout(() => {
|
||||
if (audioEl.value) {
|
||||
audioEl.value.src = `/assets/audio/${tracks[i].file}.mp3`
|
||||
audioEl.value.play().then(() => { playing.value = true })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (st.cur < 0) { play(0); return }
|
||||
play(st.cur)
|
||||
}
|
||||
|
||||
function prev() { if (st.cur > 0) play(st.cur - 1) }
|
||||
function next() { if (st.cur < tracks.length - 1) play(st.cur + 1); else { playing.value = false; st.cur = -1 } }
|
||||
|
||||
onUnmounted(() => {
|
||||
if (audioEl.value) {
|
||||
audioEl.value.pause()
|
||||
audioEl.value.src = ''
|
||||
Sys().unregisterMedia(audioEl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
18
src/apps/news/News.vue
Normal file
18
src/apps/news/News.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="news-body">
|
||||
<div v-for="(item, i) in articles" :key="i" class="news-card">
|
||||
<div class="news-cat">{{ item.cat }}</div>
|
||||
<div class="news-title">{{ item.title }}</div>
|
||||
<div class="news-desc">{{ item.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const articles = [
|
||||
['macOS 网页版 1.0 正式发布', '科技', '完全在浏览器中运行的桌面体验:窗口管理、虚拟文件系统、20+ 款应用,全部离线可用。'],
|
||||
['本地优先软件为何卷土重来', '观点', '当云服务成为默认,选择把数据留在本地反而成了一种新的自由。'],
|
||||
['浏览器平台能力年度盘点', '技术', '从通知到文件系统访问,Web 与原生应用的边界正在消融。'],
|
||||
['开源素材生态探访', '社区', 'CC-BY 音乐、CC0 视频与社区图标包,让个人项目也能拥有完整质感。'],
|
||||
].map(([title, cat, desc]) => ({ title, cat, desc }))
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
1
src/apps/notes/Notes.vue
Normal file
1
src/apps/notes/Notes.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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',
|
||||
|
||||
66
src/apps/photos/Photos.vue
Normal file
66
src/apps/photos/Photos.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="photos-body">
|
||||
<div class="photos-side">
|
||||
<div class="fb-side-title">图库</div>
|
||||
<div class="fb-side-item sel"><span class="fb-side-ico">🖼</span><span>所有照片</span></div>
|
||||
<div class="fb-side-item"><span class="fb-side-ico">⭐</span><span>个人收藏</span></div>
|
||||
<div class="fb-side-item"><span class="fb-side-ico">🕘</span><span>最近项目</span></div>
|
||||
</div>
|
||||
<div class="photos-grid">
|
||||
<div
|
||||
v-for="(p, i) in photos"
|
||||
:key="i"
|
||||
class="photo-cell"
|
||||
tabindex="0"
|
||||
@click="openLightbox(i)"
|
||||
>
|
||||
<img :src="p.src" :alt="p.name" @error="onImgError($event)">
|
||||
<div class="photo-name">{{ p.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="lbOpen" class="lightbox" tabindex="0" @click.self="closeLightbox" @keydown="onLbKeydown">
|
||||
<button class="lb-btn lb-close" @click="closeLightbox">✕</button>
|
||||
<button class="lb-btn lb-prev" @click.stop="nav(-1)">‹</button>
|
||||
<img v-if="!lbFailed" class="lb-img" :src="photos[lbIdx].src" :alt="photos[lbIdx].name" @error="lbFailed = true">
|
||||
<div v-if="lbFailed" class="lb-fallback">图片不可用</div>
|
||||
<div class="lb-counter">{{ photos[lbIdx].name }} — {{ lbIdx + 1 }} / {{ photos.length }}</div>
|
||||
<button class="lb-btn lb-next" @click.stop="nav(1)">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { WALLPAPERS } from '../../composables/useSettings'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const photos = WALLPAPERS.map((w: any) => ({ src: w.src, name: w.name, date: '2026年7月' }))
|
||||
const lbOpen = ref(false)
|
||||
const lbIdx = ref(0)
|
||||
const lbFailed = ref(false)
|
||||
|
||||
function onImgError(e: Event) {
|
||||
const cell = (e.target as HTMLElement).parentElement
|
||||
if (cell) { cell.classList.add('photo-missing'); cell.innerHTML = '<div class="photo-fallback">🌄</div>' }
|
||||
}
|
||||
|
||||
function openLightbox(i: number) {
|
||||
lbIdx.value = i; lbFailed.value = false; lbOpen.value = true
|
||||
}
|
||||
|
||||
function closeLightbox() { lbOpen.value = false }
|
||||
|
||||
function nav(d: number) {
|
||||
lbIdx.value = (lbIdx.value + d + photos.length) % photos.length
|
||||
lbFailed.value = false
|
||||
}
|
||||
|
||||
function onLbKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeLightbox()
|
||||
if (e.key === 'ArrowLeft') nav(-1)
|
||||
if (e.key === 'ArrowRight') nav(1)
|
||||
}
|
||||
|
||||
props.win.appState = { idx: lbIdx }
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
72
src/apps/podcasts/Podcasts.vue
Normal file
72
src/apps/podcasts/Podcasts.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="music-body">
|
||||
<div class="music-list" style="flex:1">
|
||||
<div v-for="(ep, i) in episodes" :key="i"
|
||||
:class="'music-row' + (st.cur === i ? ' sel' : '')"
|
||||
@click="play(i)"
|
||||
>
|
||||
<span class="music-num">{{ st.cur === i && playing ? '▶' : i + 1 }}</span>
|
||||
<div class="music-info">
|
||||
<b>{{ ep.title }}</b>
|
||||
<small>{{ ep.show }}</small>
|
||||
</div>
|
||||
<span class="music-dur">{{ fmtDur(ep.dur) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pod-bar">
|
||||
<span v-if="currentEp">{{ currentEp.title }}</span>
|
||||
<span v-else>选择一集播客</span>
|
||||
</div>
|
||||
<audio ref="audioEl" @ended="next" @error="next"></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onUnmounted } from 'vue'
|
||||
|
||||
const Sys = () => (window as any).Sys
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const 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 },
|
||||
]
|
||||
|
||||
const episodes = tracks.map((t, i) => ({ title: '第 ' + (i + 1) + ' 期 · ' + t.title, show: 'Web 桌面谈', dur: t.dur, file: t.file }))
|
||||
const st = reactive({ cur: -1 })
|
||||
const playing = ref(false)
|
||||
const audioEl = ref<HTMLAudioElement>()
|
||||
|
||||
const currentEp = computed(() => st.cur >= 0 ? episodes[st.cur] : null)
|
||||
|
||||
function fmtDur(s: number) { return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` }
|
||||
|
||||
function play(i: number) {
|
||||
if (st.cur === i && audioEl.value) {
|
||||
playing.value = !playing.value
|
||||
playing.value ? audioEl.value.play() : audioEl.value.pause()
|
||||
return
|
||||
}
|
||||
st.cur = i
|
||||
setTimeout(() => {
|
||||
if (audioEl.value) {
|
||||
audioEl.value.src = `/assets/audio/${episodes[i].file}.mp3`
|
||||
audioEl.value.play().then(() => { playing.value = true })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function next() { if (st.cur < episodes.length - 1) play(st.cur + 1); else { playing.value = false; st.cur = -1 } }
|
||||
|
||||
onUnmounted(() => {
|
||||
if (audioEl.value) {
|
||||
audioEl.value.pause()
|
||||
audioEl.value.src = ''
|
||||
Sys().unregisterMedia(audioEl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
1
src/apps/preview/Preview.vue
Normal file
1
src/apps/preview/Preview.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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 = {
|
||||
|
||||
42
src/apps/quicktime/QuickTime.vue
Normal file
42
src/apps/quicktime/QuickTime.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="qt-body" style="background:#000;display:flex;align-items:center;justify-content:center;flex-direction:column">
|
||||
<video
|
||||
v-if="showPlayer"
|
||||
ref="videoEl"
|
||||
:src="src"
|
||||
class="qt-video"
|
||||
controls
|
||||
autoplay
|
||||
style="max-width:100%;max-height:100%"
|
||||
@loadedmetadata="$event => ($event.target as HTMLVideoElement).play()"
|
||||
@error="showPlayer = false"
|
||||
></video>
|
||||
<div v-if="!showPlayer" style="color:#aaa;text-align:center">
|
||||
<div style="font-size:48px;margin-bottom:12px">🎬</div>
|
||||
<div>拖放视频文件到窗口以播放</div>
|
||||
<div style="font-size:12px;margin-top:8px">或从访达中打开视频文件</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const Sys = () => (window as any).Sys
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
const showPlayer = ref(false)
|
||||
const src = ref('')
|
||||
const videoEl = ref<HTMLVideoElement>()
|
||||
|
||||
onMounted(() => {
|
||||
if (props.win.data?.path) {
|
||||
const ext = (props.win.data.path as string).split('.').pop()?.toLowerCase()
|
||||
if (['mp4', 'webm', 'mov', 'm4v'].includes(ext || '')) {
|
||||
src.value = '/assets/video/' + props.win.data.path.split('/').pop()
|
||||
showPlayer.value = true
|
||||
setTimeout(() => { if (videoEl.value) Sys().registerMedia(videoEl.value) }, 200)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -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 = `<source src="/assets/video/${v.file}" type="video/mp4"><source src="/assets/video/${v.file.replace('.mp4', '.webm')}" type="video/webm">`
|
||||
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)
|
||||
|
||||
69
src/apps/reminders/Reminders.vue
Normal file
69
src/apps/reminders/Reminders.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="rem-body">
|
||||
<div class="rem-side">
|
||||
<div class="fb-side-title">我的列表</div>
|
||||
<div v-for="l in data.lists" :key="l.id"
|
||||
:class="'fb-side-item' + (cur === l.id ? ' sel' : '')"
|
||||
@click="cur = l.id"
|
||||
>
|
||||
<span class="rem-color" :style="{ background: l.color }"></span>
|
||||
<span class="rem-lname">{{ l.name }}</span>
|
||||
<span class="rem-count">{{ data.items.filter((i: any) => i.list === l.id && !i.done).length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rem-main">
|
||||
<div class="rem-items">
|
||||
<div v-for="it in openItems" :key="it.id" class="rem-row">
|
||||
<button :class="'rem-check'" aria-label="标记为完成" @click="toggleItem(it)"></button>
|
||||
<input class="rem-title" :value="it.title" @change="onTitleChange(it, $event)">
|
||||
<input class="rem-due" type="date" :value="it.due || ''" @change="it.due = ($event.target as HTMLInputElement).value; save()">
|
||||
</div>
|
||||
<div v-if="doneItems.length" class="fb-side-title">已完成({{ doneItems.length }})</div>
|
||||
<div v-for="it in doneItems" :key="it.id" class="rem-row">
|
||||
<button class="rem-check done" aria-label="标记为未完成" @click="toggleItem(it)"></button>
|
||||
<input class="rem-title done" :value="it.title" @change="onTitleChange(it, $event)">
|
||||
<input class="rem-due" type="date" :value="it.due || ''" @change="it.due = ($event.target as HTMLInputElement).value; save()">
|
||||
</div>
|
||||
<div v-if="!items.length" class="empty-state" style="height:140px">此列表为空</div>
|
||||
</div>
|
||||
<div class="rem-new-row">
|
||||
<input class="text-input rem-new" type="text" placeholder="添加提醒事项,回车确认" @keydown.stop="onNewKeydown">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { uid } from '../../utils'
|
||||
import { store } from '../../composables/useStore'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const data = reactive(store.get('reminders', {
|
||||
lists: [{ id: 'l1', name: '提醒', color: '#0a84ff' }, { id: 'l2', name: '工作', color: '#ff9f0a' }, { id: 'l3', name: '家庭', color: '#32d74b' }],
|
||||
items: [{ id: uid(), list: 'l1', title: '体验 macOS 网页版', due: '', done: false }, { id: uid(), list: 'l2', title: '周五前提交周报', due: '', done: false }, { id: uid(), list: 'l3', title: '买牛奶和鸡蛋', due: '', done: true }],
|
||||
}))
|
||||
|
||||
const cur = ref('l1')
|
||||
|
||||
function save() { store.set('reminders', { ...data }) }
|
||||
|
||||
const items = computed(() => data.items.filter((i: any) => i.list === cur.value))
|
||||
const openItems = computed(() => items.value.filter((i: any) => !i.done))
|
||||
const doneItems = computed(() => items.value.filter((i: any) => i.done))
|
||||
|
||||
function toggleItem(it: any) { it.done = !it.done; save() }
|
||||
function onTitleChange(it: any, e: Event) { it.title = (e.target as HTMLInputElement).value.trim() || it.title; save() }
|
||||
|
||||
function onNewKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && (e.target as HTMLInputElement).value.trim()) {
|
||||
data.items.push({ id: uid(), list: cur.value, title: (e.target as HTMLInputElement).value.trim(), due: '', done: false })
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for menus
|
||||
props.win.appState = { focusNew: () => { /* handled by autofocus */ }, data, cur }
|
||||
</script>
|
||||
1
src/apps/safari/Safari.vue
Normal file
1
src/apps/safari/Safari.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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 = {
|
||||
|
||||
1
src/apps/settings/Settings.vue
Normal file
1
src/apps/settings/Settings.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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
|
||||
|
||||
|
||||
66
src/apps/stickies/Stickies.vue
Normal file
66
src/apps/stickies/Stickies.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="sticky-body no-chrome" contenteditable="true" aria-label="便笺内容" @input="onInput" v-text="note.text"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { store } from '../../composables/useStore'
|
||||
import { wm } from '../../composables/useWM'
|
||||
import { ui } from '../../composables/useUI'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
const colors: Record<string, string> = { yellow: '#fff7ae', blue: '#cfe8ff', green: '#d9f7c4', pink: '#ffd6e8', purple: '#e8d9ff' }
|
||||
|
||||
const data = store.get('stickies', [{ id: 's1', text: '双击便笺即可编辑。\n通过菜单可以更换颜色。', color: 'yellow', x: null, y: null }])
|
||||
const note = reactive(data.find((n: any) => n.id === props.win.data?.noteId) || data[0])
|
||||
|
||||
props.win.el.style.background = colors[note.color] || '#fff7ae'
|
||||
wm.setTitle(props.win, '便笺')
|
||||
|
||||
let timer: any = null
|
||||
function onInput(e: Event) {
|
||||
note.text = (e.target as HTMLElement).textContent || ''
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
const all = store.get('stickies', [])
|
||||
const n = all.find((x: any) => x.id === note.id)
|
||||
if (n) { n.text = note.text; store.set('stickies', all) }
|
||||
}, 400)
|
||||
}
|
||||
|
||||
function setColor(k: string) {
|
||||
note.color = k
|
||||
const all = store.get('stickies', [])
|
||||
const n = all.find((x: any) => x.id === note.id)
|
||||
if (n) { n.color = k; store.set('stickies', all) }
|
||||
props.win.el.style.background = colors[k]
|
||||
}
|
||||
|
||||
function removeSelf() {
|
||||
const all = store.get('stickies', []).filter((x: any) => x.id !== note.id)
|
||||
store.set('stickies', all)
|
||||
;(props.win as any).confirmClose = null
|
||||
wm.close(props.win)
|
||||
}
|
||||
|
||||
props.win.appState = { note, setColor, removeSelf }
|
||||
|
||||
onMounted(() => {
|
||||
props.win.el.addEventListener('contextmenu', (e: Event) => {
|
||||
e.preventDefault()
|
||||
ui.contextMenu([
|
||||
{ label: '新建便笺', action: () => Apps.open('stickies') },
|
||||
{ label: '删除便笺', action: () => removeSelf() },
|
||||
{ sep: true },
|
||||
...Object.entries(colors).map(([k]) => ({
|
||||
label: ({ yellow: '黄色', blue: '蓝色', green: '绿色', pink: '粉色', purple: '紫色' } as any)[k],
|
||||
checked: note.color === k,
|
||||
action: () => setColor(k),
|
||||
})),
|
||||
], e as MouseEvent)
|
||||
})
|
||||
})
|
||||
|
||||
// Dynamic import for Apps
|
||||
import { Apps } from '../../composables/useApps'
|
||||
</script>
|
||||
@ -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<string,string>
|
||||
|
||||
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<string,string>,
|
||||
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)
|
||||
|
||||
1
src/apps/terminal/Terminal.vue
Normal file
1
src/apps/terminal/Terminal.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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',
|
||||
|
||||
1
src/apps/textedit/TextEdit.vue
Normal file
1
src/apps/textedit/TextEdit.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div></div></template>
|
||||
@ -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',
|
||||
|
||||
59
src/apps/tv/TV.vue
Normal file
59
src/apps/tv/TV.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="tv-body">
|
||||
<h2 class="store-h">立即观看</h2>
|
||||
<div class="tv-grid">
|
||||
<div v-for="(v, i) in videos" :key="i" class="tv-card" @click="playVideo(v, i)">
|
||||
<div :class="'tv-poster tv-p' + i"><span>▶</span></div>
|
||||
<div class="tv-name">{{ v.title }}</div>
|
||||
<div class="tv-sub">{{ v.src }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="playerBox" class="tv-player hidden">
|
||||
<div class="tv-ptitle">{{ currentTitle }}</div>
|
||||
<video ref="videoEl" class="tv-video" controls autoplay @loadedmetadata="$event => ($event.target as HTMLVideoElement).play()">
|
||||
<source v-if="currentFile" :src="'/assets/video/' + currentFile" type="video/mp4">
|
||||
<source v-if="currentFile" :src="'/assets/video/' + currentFile.replace('.mp4', '.webm')" type="video/webm">
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const Sys = () => (window as any).Sys
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const 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' },
|
||||
]
|
||||
|
||||
const playerBox = ref<HTMLElement>()
|
||||
const videoEl = ref<HTMLVideoElement>()
|
||||
const currentFile = ref('')
|
||||
const currentTitle = ref('')
|
||||
|
||||
function playVideo(v: { file: string; title: string }, idx: number) {
|
||||
currentTitle.value = v.title
|
||||
currentFile.value = v.file
|
||||
playerBox.value?.classList.remove('hidden')
|
||||
setTimeout(() => {
|
||||
if (videoEl.value) {
|
||||
videoEl.value.load()
|
||||
Sys().registerMedia(videoEl.value)
|
||||
}
|
||||
playerBox.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, 50)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (videoEl.value) {
|
||||
videoEl.value.pause()
|
||||
videoEl.value.src = ''
|
||||
Sys().unregisterMedia(videoEl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -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 = `<source src="/assets/video/${v.file}" type="video/mp4"><source src="/assets/video/${v.file.replace('.mp4', '.webm')}" type="video/webm">`; 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)
|
||||
|
||||
47
src/apps/typora/Typora.vue
Normal file
47
src/apps/typora/Typora.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="typora-body">
|
||||
<textarea class="te-area typora-edit" :value="src" @input="onInput" placeholder="输入 Markdown…"></textarea>
|
||||
<div class="typora-preview" v-html="rendered"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { esc } from '../../utils'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const src = ref(`# Typora
|
||||
|
||||
**所见即所得** 的 Markdown 编辑体验。
|
||||
|
||||
- 左侧输入
|
||||
- 右侧实时预览
|
||||
|
||||
\`console.log("hello")\`
|
||||
`)
|
||||
const rendered = ref('')
|
||||
|
||||
function renderMd(md: string) {
|
||||
const lines = md.split('\n')
|
||||
const html = lines.map(line => {
|
||||
const escd = esc(line).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/\*(.+?)\*/g, '<i>$1</i>').replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
if (line.startsWith('# ')) return `<h1>${escd.slice(2)}</h1>`
|
||||
if (line.startsWith('## ')) return `<h2>${escd.slice(3)}</h2>`
|
||||
if (line.startsWith('### ')) return `<h3>${escd.slice(4)}</h3>`
|
||||
if (line.startsWith('- ')) return `<p class="ty-li">• ${escd.slice(2)}</p>`
|
||||
if (line.trim() === '') return '<div style="height:10px"></div>'
|
||||
return `<p>${escd}</p>`
|
||||
}).join('')
|
||||
rendered.value = html
|
||||
}
|
||||
|
||||
let timer: any = null
|
||||
function onInput(e: Event) {
|
||||
src.value = (e.target as HTMLTextAreaElement).value
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => renderMd(src.value), 150)
|
||||
}
|
||||
|
||||
renderMd(src.value)
|
||||
</script>
|
||||
@ -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, '<b>$1</b>').replace(/\*(.+?)\*/g, '<i>$1</i>').replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
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)
|
||||
|
||||
65
src/apps/vscode/VSCode.vue
Normal file
65
src/apps/vscode/VSCode.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="vscode-body">
|
||||
<div ref="treeEl" class="vs-tree">
|
||||
<div class="fb-side-title">资源管理器</div>
|
||||
<div
|
||||
v-for="(node, i) in fileTree"
|
||||
:key="i"
|
||||
:class="'vs-row' + (st.path === node.path ? ' sel' : '')"
|
||||
:style="{ paddingLeft: 10 + node.depth * 14 + 'px' }"
|
||||
@click="node.type === 'f' && openFile(node.path)"
|
||||
>
|
||||
<span>{{ node.type === 'd' ? '📁 ' : '📄 ' }}{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vs-main">
|
||||
<textarea ref="taEl" class="vs-editor" spellcheck="false" @keydown="onKeyDown"></textarea>
|
||||
<div class="vs-status">{{ statusText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { fs } from '../../composables/useFS'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
const st = reactive({ path: null as string | null })
|
||||
const treeEl = ref<HTMLElement>()
|
||||
const taEl = ref<HTMLTextAreaElement>()
|
||||
const statusText = ref('就绪')
|
||||
const fileTree = ref<{ name: string; path: string; type: string; depth: number }[]>([])
|
||||
|
||||
function buildTree() {
|
||||
const nodes: { name: string; path: string; type: string; depth: number }[] = []
|
||||
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
|
||||
nodes.push({ name: fs.baseName(p), path: p, type: n.t, depth })
|
||||
})
|
||||
fileTree.value = nodes
|
||||
}
|
||||
|
||||
function openFile(p: string) {
|
||||
st.path = p
|
||||
if (taEl.value) taEl.value.value = fs.read(p)
|
||||
statusText.value = p.replace(fs.HOME, '~')
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (st.path && taEl.value) {
|
||||
fs.write(st.path, taEl.value.value)
|
||||
statusText.value = '已保存 ' + st.path.replace(fs.HOME, '~')
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); save() }
|
||||
}
|
||||
|
||||
// Expose for menus
|
||||
props.win.appState = Object.assign(st, { save, taEl })
|
||||
|
||||
onMounted(buildTree)
|
||||
</script>
|
||||
@ -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)
|
||||
|
||||
3
src/components/busComponents/BannerContainer.vue
Normal file
3
src/components/busComponents/BannerContainer.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div id="banner-container"></div>
|
||||
</template>
|
||||
10
src/components/busComponents/BootScreen.vue
Normal file
10
src/components/busComponents/BootScreen.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div id="bootscreen">
|
||||
<div id="boot-apple">
|
||||
<svg viewBox="0 0 384 512" width="72" height="96" aria-hidden="true">
|
||||
<path fill="#fff" d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="boot-progress"><div id="boot-progress-fill"></div></div>
|
||||
</div>
|
||||
</template>
|
||||
3
src/components/busComponents/ControlCenterArea.vue
Normal file
3
src/components/busComponents/ControlCenterArea.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div id="control-center" class="popover hidden" role="dialog" aria-label="控制中心"></div>
|
||||
</template>
|
||||
5
src/components/busComponents/DesktopArea.vue
Normal file
5
src/components/busComponents/DesktopArea.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div id="desktop" class="hidden" aria-label="桌面">
|
||||
<div id="desktop-icons"></div>
|
||||
</div>
|
||||
</template>
|
||||
4
src/components/busComponents/DockArea.vue
Normal file
4
src/components/busComponents/DockArea.vue
Normal file
@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<div id="dock-hotzone" aria-hidden="true"></div>
|
||||
<nav id="dock" class="hidden" aria-label="程序坞"></nav>
|
||||
</template>
|
||||
18
src/components/busComponents/LockScreenArea.vue
Normal file
18
src/components/busComponents/LockScreenArea.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div id="lockscreen" class="hidden">
|
||||
<div id="lockscreen-bg"></div>
|
||||
<div id="lockscreen-main">
|
||||
<div id="lock-date"></div>
|
||||
<div id="lock-time"></div>
|
||||
</div>
|
||||
<div id="lockscreen-user">
|
||||
<img id="lock-avatar" src="/assets/icons/avatar.svg" alt="用户头像">
|
||||
<div id="lock-username">客人用户</div>
|
||||
<div id="lock-password-row" class="hidden">
|
||||
<input id="lock-password" type="password" placeholder="输入密码" aria-label="密码">
|
||||
</div>
|
||||
<div id="lock-hint">点按或按 Enter 键解锁</div>
|
||||
<div id="lock-error" class="hidden">密码不正确,请重试</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
6
src/components/busComponents/MenuBarArea.vue
Normal file
6
src/components/busComponents/MenuBarArea.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<header id="menubar" class="hidden">
|
||||
<div id="menubar-left"></div>
|
||||
<div id="menubar-right"></div>
|
||||
</header>
|
||||
</template>
|
||||
3
src/components/busComponents/NotificationCenterArea.vue
Normal file
3
src/components/busComponents/NotificationCenterArea.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<aside id="notification-center" class="hidden" aria-label="通知中心"></aside>
|
||||
</template>
|
||||
5
src/components/busComponents/PowerOff.vue
Normal file
5
src/components/busComponents/PowerOff.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div id="poweroff" class="hidden">
|
||||
<div id="poweroff-text">已关机<br><span>点按屏幕以开机</span></div>
|
||||
</div>
|
||||
</template>
|
||||
3
src/components/busComponents/ScreensaverArea.vue
Normal file
3
src/components/busComponents/ScreensaverArea.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div id="screensaver" class="hidden"></div>
|
||||
</template>
|
||||
14
src/components/busComponents/SpotlightArea.vue
Normal file
14
src/components/busComponents/SpotlightArea.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div id="spotlight" class="hidden" role="dialog" aria-label="聚焦搜索">
|
||||
<div id="spotlight-box">
|
||||
<div id="spotlight-input-row">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" class="sp-icon">
|
||||
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2.4"/>
|
||||
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input id="spotlight-input" type="text" placeholder="聚焦搜索" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div id="spotlight-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
3
src/components/pureComponents/OverlayBrightness.vue
Normal file
3
src/components/pureComponents/OverlayBrightness.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div id="overlay-brightness" aria-hidden="true"></div>
|
||||
</template>
|
||||
3
src/components/pureComponents/OverlayNightShift.vue
Normal file
3
src/components/pureComponents/OverlayNightShift.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div id="overlay-nightshift" aria-hidden="true"></div>
|
||||
</template>
|
||||
3
src/components/pureComponents/WindowLayer.vue
Normal file
3
src/components/pureComponents/WindowLayer.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div id="window-layer"></div>
|
||||
</template>
|
||||
Loading…
x
Reference in New Issue
Block a user