/* ============ 扩展应用 A:启动台 / 地图 / App Store / 活动监视器 / 天气 / 时钟 ============ */
'use strict';
/* ==================== 启动台 ==================== */
Apps.register({
id: 'launchpad', name: '启动台', icon: 'assets/icons/launchpad.png',
menus() { return stdMenus(this); },
render() { /* 不走 WM 窗口 */ },
open() { Launchpad.show(); }
});
const Launchpad = {
overlay: null,
show() {
if (this.overlay) return this.hide();
const ov = el('div', { class: 'launchpad', tabindex: '0' });
const search = el('input', { class: 'lp-search', type: 'search', placeholder: '搜索 App' });
const grid = el('div', { class: 'lp-grid' });
ov.append(el('div', { class: 'lp-search-wrap' }, search), grid);
const renderGrid = q => {
grid.innerHTML = '';
const apps = Object.values(Apps.registry)
.filter(a => a.id !== 'launchpad' && (!a.storeApp || AppStoreApp.isInstalled(a.id)))
.filter(a => !q || a.name.toLowerCase().includes(q.toLowerCase()));
if (!apps.length) grid.append(el('div', { class: 'lp-empty', text: '没有匹配的 App' }));
apps.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN')).forEach(app => {
const c = el('div', { class: 'lp-cell' }, iconImg(app.icon, '', app.name), el('div', { class: 'lp-name', text: app.name }));
c.addEventListener('click', () => { this.hide(); Apps.open(app.id); });
grid.append(c);
});
};
renderGrid('');
search.addEventListener('input', () => renderGrid(search.value.trim()));
search.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Escape') this.hide(); });
ov.addEventListener('keydown', e => { if (e.key === 'Escape') this.hide(); });
ov.addEventListener('click', e => { if (e.target === ov) this.hide(); });
document.body.append(ov);
this.overlay = ov;
setTimeout(() => { ov.classList.add('show'); search.focus(); }, 10);
},
hide() {
if (!this.overlay) return;
const ov = this.overlay;
this.overlay = null;
ov.classList.remove('show');
setTimeout(() => ov.remove(), 250);
}
};
window.Launchpad = Launchpad;
const _origAppsOpen = Apps.open.bind(Apps);
Apps.open = function (id, args) {
if (id === 'launchpad') { Launchpad.show(); return null; }
if (id === 'stickies' && !(args && args.noteId)) { Apps.get('stickies').openAll(); return null; }
const app = this.get(id);
if (app && app.storeApp && !AppStoreApp.isInstalled(id)) {
UI.alert('尚未安装', `请先在 App Store 中获取“${app.name}”。`, 'assets/icons/appstore.png');
return null;
}
return _origAppsOpen(id, args);
};
/* ==================== 地图(离线设计版) ==================== */
const MAP_POIS = [
{ name: '天安门广场', x: 0.50, y: 0.52, kind: 'landmark' },
{ name: '故宫博物院', x: 0.50, y: 0.45, kind: 'landmark' },
{ name: '王府井大街', x: 0.56, y: 0.50, kind: 'shopping' },
{ name: '北海公园', x: 0.44, y: 0.42, kind: 'park' },
{ name: '颐和园', x: 0.18, y: 0.18, kind: 'park' },
{ name: '北京站', x: 0.60, y: 0.62, kind: 'transit' },
{ name: '中关村', x: 0.30, y: 0.25, kind: 'shopping' },
{ name: '三里屯', x: 0.68, y: 0.42, kind: 'shopping' },
{ name: '奥林匹克公园', x: 0.52, y: 0.20, kind: 'park' },
{ name: '北京西站', x: 0.32, y: 0.66, kind: 'transit' },
{ name: '国贸 CBD', x: 0.66, y: 0.56, kind: 'landmark' },
{ name: '南锣鼓巷', x: 0.50, y: 0.40, kind: 'landmark' },
];
Apps.register({
id: 'maps', name: '地图', icon: 'assets/icons/maps.png',
w: 860, h: 580, minW: 520, minH: 360,
menus(win) {
const st = win?.appState;
return stdMenus(this, {
view: [
{ label: '标准', checked: st?.mode === 'std', action: () => st?.setMode('std') },
{ label: '卫星', checked: st?.mode === 'sat', action: () => st?.setMode('sat') },
{ sep: true },
{ label: '放大', key: '⌘+', action: () => st?.zoomBy(1) },
{ label: '缩小', key: '⌘−', action: () => st?.zoomBy(-1) },
]
});
},
render(win) {
const st = win.appState = { mode: 'std', zoom: 3, cx: 0.5, cy: 0.48, pin: null };
win.body.classList.add('maps-body');
const search = el('input', { class: 'text-input maps-search', type: 'search', placeholder: '搜索地点或地址' });
const results = el('div', { class: 'maps-results hidden' });
const seg = el('div', { class: 'segmented maps-seg' },
el('button', { text: '标准', class: 'on', onclick: () => { st.setMode('std'); seg.children[0].classList.add('on'); seg.children[1].classList.remove('on'); } }),
el('button', { text: '卫星', onclick: () => { st.setMode('sat'); seg.children[1].classList.add('on'); seg.children[0].classList.remove('on'); } }));
const zIn = el('button', { class: 'maps-zoom', text: '+', title: '放大' });
const zOut = el('button', { class: 'maps-zoom', text: '−', title: '缩小' });
const locate = el('button', { class: 'maps-locate', html: '◎', title: '我的位置' });
const canvas = el('canvas', { class: 'maps-canvas' });
const offlineTag = el('div', { class: 'maps-offline-tag', text: '离线地图 · 北京市(示意图)' });
win.body.append(canvas, el('div', { class: 'maps-top' }, search), results, seg,
el('div', { class: 'maps-zoom-col' }, zIn, zOut), locate, offlineTag);
const ctx = canvas.getContext('2d');
st.setMode = m => { st.mode = m; draw(); };
st.zoomBy = d => { st.zoom = clamp(st.zoom + d, 1, 6); draw(); };
zIn.addEventListener('click', () => st.zoomBy(1));
zOut.addEventListener('click', () => st.zoomBy(-1));
locate.addEventListener('click', () => { st.cx = 0.5; st.cy = 0.52; st.pin = { name: '我的位置', x: 0.5, y: 0.52 }; draw(); });
canvas.addEventListener('wheel', e => { e.preventDefault(); st.zoomBy(e.deltaY < 0 ? 1 : -1); }, { passive: false });
// 拖动平移
canvas.addEventListener('pointerdown', e => {
const sx = e.clientX, sy = e.clientY, ox = st.cx, oy = st.cy;
const scale = Math.pow(1.7, st.zoom - 1);
const move = ev => {
st.cx = clamp(ox - (ev.clientX - sx) / (canvas.width * scale / 2), 0, 1);
st.cy = clamp(oy - (ev.clientY - sy) / (canvas.height * scale / 2), 0, 1);
draw();
};
const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); };
document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
});
const toScreen = (px, py) => {
const scale = Math.pow(1.7, st.zoom - 1);
return [canvas.width / 2 + (px - st.cx) * canvas.width * scale / 2,
canvas.height / 2 + (py - st.cy) * canvas.height * scale / 2];
};
const draw = () => {
const W = canvas.width = canvas.clientWidth * devicePixelRatio;
const H = canvas.height = canvas.clientHeight * devicePixelRatio;
const sat = st.mode === 'sat';
ctx.fillStyle = sat ? '#1c2418' : '#e8e4da';
ctx.fillRect(0, 0, W, H);
const scale = Math.pow(1.7, st.zoom - 1);
// 水面(护城河/湖泊)
ctx.fillStyle = sat ? '#16202e' : '#a8c8e8';
const lake = (cx, cy, rx, ry) => { const [x, y] = toScreen(cx, cy); ctx.beginPath(); ctx.ellipse(x, y, rx * W * scale / 2, ry * W * scale / 2, 0, 0, Math.PI * 2); ctx.fill(); };
lake(0.44, 0.42, 0.05, 0.035); lake(0.17, 0.17, 0.045, 0.05); lake(0.52, 0.21, 0.035, 0.03);
// 公园绿地
ctx.fillStyle = sat ? '#22331f' : '#b8d9a8';
const park = (cx, cy, r) => { const [x, y] = toScreen(cx, cy); ctx.beginPath(); ctx.arc(x, y, r * W * scale / 2, 0, Math.PI * 2); ctx.fill(); };
park(0.44, 0.42, 0.045); park(0.52, 0.20, 0.05); park(0.18, 0.18, 0.05);
// 路网
const road = (x1, y1, x2, y2, w, color) => {
const [ax, ay] = toScreen(x1, y1), [bx, by] = toScreen(x2, y2);
ctx.strokeStyle = color; ctx.lineWidth = w * devicePixelRatio * (0.6 + st.zoom * 0.3);
ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
};
const main = sat ? '#4a4640' : '#ffffff', ring = sat ? '#57534b' : '#f8d67c';
for (let i = 1; i < 9; i++) road(i / 9, 0.05, i / 9, 0.95, 3, main);
for (let j = 1; j < 8; j++) road(0.05, j / 8, 0.95, j / 8, 3, main);
road(0.1, 0.52, 0.9, 0.52, 6, ring); // 长安街
road(0.5, 0.08, 0.5, 0.92, 5, ring); // 中轴线
// 环路
ctx.strokeStyle = ring; ctx.lineWidth = 4 * devicePixelRatio;
const [ccx, ccy] = toScreen(0.5, 0.5);
ctx.beginPath(); ctx.arc(ccx, ccy, 0.16 * W * scale / 2, 0, Math.PI * 2); ctx.stroke();
// 路名
if (st.zoom >= 3) {
ctx.fillStyle = sat ? '#b8b2a4' : '#8a8578';
ctx.font = `${11 * devicePixelRatio}px sans-serif`;
const roadName = (t, x, y) => { const [sx, sy] = toScreen(x, y); ctx.fillText(t, sx, sy); };
roadName('长安街', 0.42, 0.505); roadName('中轴路', 0.505, 0.3);
roadName('二环路', 0.63, 0.36);
}
// POI
MAP_POIS.forEach(p => {
const [x, y] = toScreen(p.x, p.y);
if (x < -60 || y < -60 || x > W + 60 || y > H + 60) return;
ctx.fillStyle = { park: '#3f9d4e', transit: '#3a7bd5', shopping: '#c86dd7', landmark: '#d5663a' }[p.kind];
ctx.beginPath(); ctx.arc(x, y, 5 * devicePixelRatio, 0, Math.PI * 2); ctx.fill();
if (st.zoom >= 2) {
ctx.fillStyle = sat ? '#e8e4da' : '#3a372f';
ctx.font = `${10.5 * devicePixelRatio}px sans-serif`;
ctx.fillText(p.name, x + 8 * devicePixelRatio, y + 4 * devicePixelRatio);
}
});
// 大头针
if (st.pin) {
const [x, y] = toScreen(st.pin.x, st.pin.y);
ctx.fillStyle = '#ff453a';
ctx.beginPath(); ctx.arc(x, y - 14 * devicePixelRatio, 8 * devicePixelRatio, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(x - 6 * devicePixelRatio, y - 9 * devicePixelRatio);
ctx.lineTo(x, y + 2 * devicePixelRatio); ctx.lineTo(x + 6 * devicePixelRatio, y - 9 * devicePixelRatio); ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = `bold ${11 * devicePixelRatio}px sans-serif`;
ctx.fillText(st.pin.name, x + 12 * devicePixelRatio, y - 10 * devicePixelRatio);
}
};
win.timers.push(setInterval(() => { if (canvas.width !== canvas.clientWidth * devicePixelRatio) draw(); }, 500));
setTimeout(draw, 30);
// 搜索
search.addEventListener('input', debounce(() => {
const q = search.value.trim();
results.innerHTML = '';
if (!q) { results.classList.add('hidden'); return; }
const hits = MAP_POIS.filter(p => p.name.includes(q));
results.classList.remove('hidden');
if (!hits.length) results.append(el('div', { class: 'maps-result', text: '本地结果中没有“' + q + '”(离线地图)' }));
hits.forEach(p => {
const r = el('div', { class: 'maps-result' }, el('b', { text: p.name }), el('small', { text: { park: '公园', transit: '车站', shopping: '商圈', landmark: '地标' }[p.kind] }));
r.addEventListener('click', () => {
st.cx = p.x; st.cy = p.y; st.zoom = Math.max(st.zoom, 4); st.pin = p;
results.classList.add('hidden'); search.value = p.name; draw();
});
results.append(r);
});
}, 200));
search.addEventListener('keydown', e => e.stopPropagation());
}
});
/* ==================== App Store ==================== */
const STORE_CATALOG = [
{ id: 'bear', name: '熊掌记', cat: '效率', desc: '优雅的 Markdown 笔记', icon: 'assets/icons/bear.png' },
{ id: 'typora', name: 'Typora', cat: '效率', desc: '所见即所得 Markdown 编辑器', icon: 'assets/icons/typora.png' },
{ id: 'vscode', name: 'VS Code Web', cat: '开发', desc: '轻量代码编辑器', icon: 'assets/icons/vscode.png' },
{ id: 'keynote', name: 'Keynote 讲演', cat: '效率', desc: '制作精美演示文稿', icon: 'assets/icons/keynote.png' },
{ id: 'podcasts', name: '播客', cat: '娱乐', desc: '收听你喜爱的节目', icon: 'assets/icons/podcasts.png' },
{ id: 'news', name: 'Apple News', cat: '阅读', desc: '精选科技资讯', icon: 'assets/icons/news.png' },
{ id: 'github', name: 'GitHub Desktop', cat: '开发', desc: '浏览与管理代码仓库', icon: 'assets/icons/github.png' },
{ id: 'tv', name: 'Apple TV', cat: '娱乐', desc: '观看影片与节目', icon: 'assets/icons/tv.png' },
];
const AppStoreApp = {
id: 'appstore', name: 'App Store', icon: 'assets/icons/appstore.png',
w: 860, h: 600, minW: 600, minH: 420,
installed() { return Store.get('appstore-installed', []); },
isInstalled(id) { return this.installed().includes(id); },
setInstalled(id) {
const l = this.installed();
if (!l.includes(id)) { l.push(id); Store.set('appstore-installed', l); FS.syncApps(); Bus.emit('appstore:changed'); }
},
menus(win) {
return stdMenus(this, {
store: [
{ label: '发现', action: () => win?.appState?.go('discover') },
{ label: '分类', action: () => win?.appState?.go('cats') },
{ label: '更新', action: () => win?.appState?.go('updates') },
]
});
},
onArgs(args, win) { if (args.tab) win.appState?.go(args.tab); },
render(win) {
const st = win.appState = { tab: 'discover', q: '' };
win.body.classList.add('store-body');
const tabs = [['discover', '发现'], ['cats', '分类'], ['arcade', '搜索'], ['updates', '更新']];
const tabbar = el('div', { class: 'store-tabs' });
const search = el('input', { class: 'text-input store-search', type: 'search', placeholder: '搜索 App' });
const content = el('div', { class: 'store-content' });
win.body.append(el('div', { class: 'store-top' }, tabbar, search), content);
const btnFor = item => {
const installed = this.isInstalled(item.id);
const b = el('button', { class: 'btn store-get' + (installed ? '' : ' primary'), text: installed ? '打开' : '获取' });
b.addEventListener('click', () => {
if (this.isInstalled(item.id)) { Apps.open(item.id); return; }
b.disabled = true; b.textContent = '安装中…'; b.classList.add('installing');
setTimeout(() => {
this.setInstalled(item.id);
b.textContent = '打开'; b.disabled = false; b.classList.remove('installing', 'primary');
Notify.send({ appId: 'appstore', title: 'App Store', body: `“${item.name}”已安装,可在启动台与应用程序文件夹中找到。` });
}, 1200 + Math.random() * 1200);
});
return b;
};
const cardFor = (item, big) => {
const c = el('div', { class: 'store-card' + (big ? ' big' : '') },
iconImg(item.icon, 'store-icon', ''),
el('div', { class: 'store-info' },
el('div', { class: 'store-name', text: item.name }),
el('div', { class: 'store-desc', text: item.desc }),
el('div', { class: 'store-cat', text: item.cat })),
btnFor(item));
return c;
};
st.go = tab => {
st.tab = tab;
$$('.store-tab', tabbar).forEach(n => n.classList.toggle('on', n.dataset.tab === tab));
content.innerHTML = '';
if (tab === 'discover') {
content.append(el('h2', { class: 'store-h', text: '编辑推荐' }));
const hero = el('div', { class: 'store-hero' },
el('div', null, el('div', { class: 'store-hero-tag', text: '今日 App' }), el('div', { class: 'store-hero-name', text: '熊掌记' }), el('div', { class: 'store-hero-desc', text: '记录灵感,优雅如斯。' }),
el('div', { class: 'store-card hero-get', style: { background: 'transparent', border: 'none', padding: '10px 0 0' } }, btnFor(STORE_CATALOG[0]))),
iconImg('assets/icons/bear.png', ''));
content.append(hero, el('h2', { class: 'store-h', text: '热门 App' }));
const grid = el('div', { class: 'store-grid' });
STORE_CATALOG.slice(1).forEach(i => grid.append(cardFor(i)));
content.append(grid);
} else if (tab === 'cats') {
const cats = [...new Set(STORE_CATALOG.map(i => i.cat))];
cats.forEach(cat => {
content.append(el('h2', { class: 'store-h', text: cat }));
const grid = el('div', { class: 'store-grid' });
STORE_CATALOG.filter(i => i.cat === cat).forEach(i => grid.append(cardFor(i)));
content.append(grid);
});
} else if (tab === 'arcade') {
content.append(el('h2', { class: 'store-h', text: '搜索 App' }));
const grid = el('div', { class: 'store-grid' });
const hits = STORE_CATALOG.filter(i => !st.q || (i.name + i.desc + i.cat).toLowerCase().includes(st.q.toLowerCase()));
if (!hits.length) content.append(el('div', { class: 'empty-state', style: { height: '120px' }, text: '没有找到相关 App' }));
hits.forEach(i => grid.append(cardFor(i)));
content.append(grid);
} else if (tab === 'updates') {
const inst = STORE_CATALOG.filter(i => this.isInstalled(i.id));
if (!inst.length) content.append(el('div', { class: 'empty-state', style: { height: '200px' } }, el('div', { class: 'es-icon', text: '✅' }), el('div', { text: '全部 App 均为最新版本' })));
else {
content.append(el('h2', { class: 'store-h', text: '已安装' }));
const grid = el('div', { class: 'store-grid' });
inst.forEach(i => grid.append(cardFor(i)));
content.append(grid, el('p', { class: 'set-note', text: '所有已安装 App 均为最新版本。' }));
}
}
};
tabs.forEach(([id, name]) => {
const b = el('button', { class: 'store-tab', text: name, dataset: { tab: id } });
b.addEventListener('click', () => st.go(id));
tabbar.append(b);
});
search.addEventListener('input', debounce(() => { st.q = search.value.trim(); st.go('arcade'); }, 200));
search.addEventListener('keydown', e => e.stopPropagation());
win._unsub = Bus.on('appstore:changed', () => { if (document.body.contains(win.el)) st.go(st.tab); });
win.onClose = () => win._unsub && win._unsub();
st.go('discover');
}
};
Apps.register(AppStoreApp);
window.AppStoreApp = AppStoreApp;
/* ==================== 活动监视器 ==================== */
Apps.register({
id: 'activitymonitor', name: '活动监视器', icon: 'assets/icons/activitymonitor.svg',
w: 720, h: 480, minW: 520, minH: 320,
menus() { return stdMenus(this); },
baseProcs: [
{ name: 'kernel_task', sys: true }, { name: 'WindowServer', sys: true }, { name: 'Dock', sys: true },
{ name: 'Finder', sys: true }, { name: 'SystemUIServer', sys: true }, { name: 'loginwindow', sys: true },
{ name: 'mds', sys: true }, { name: 'cfprefsd', sys: true },
],
render(win) {
const st = win.appState = { sort: 'cpu', desc: true, q: '', rows: [] };
win.body.classList.add('am-body');
const search = el('input', { class: 'text-input am-search', type: 'search', placeholder: '搜索进程' });
const quitBtn = el('button', { class: 'btn', text: '✕ 结束进程', disabled: true });
const table = el('div', { class: 'am-table' });
win.body.append(el('div', { class: 'fb-toolbar' }, el('span', { class: 'am-title', text: '进程' }), quitBtn, search), table);
let selName = null;
let lastKey = '';
const jitter = (v, amp, min, max) => clamp(v + (Math.random() - 0.5) * amp, min, max);
const collect = () => {
const procs = this.baseProcs.map(p => ({ name: p.name, sys: true }));
[...new Set(WM.windows.map(w => w.appId))].forEach(id => {
const app = Apps.get(id); if (app) procs.push({ name: app.name, sys: false, appId: id });
});
for (const p of procs) {
const old = st.rows.find(r => r.name === p.name);
p.cpu = old ? jitter(old.cpu, 2.2, 0.1, p.sys ? 12 : 28) : Math.random() * 5;
p.mem = old ? jitter(old.mem, 8, 18, 900) : 40 + Math.random() * 300;
p.energy = old ? jitter(old.energy, 0.8, 0.1, 20) : Math.random() * 6;
}
st.rows = procs;
};
const sortedRows = () => {
let rows = st.rows.filter(r => !st.q || r.name.toLowerCase().includes(st.q.toLowerCase()));
rows.sort((a, b) => {
const k = st.sort;
const v = k === 'name' ? a.name.localeCompare(b.name) : a[k] - b[k];
return st.desc ? -v : v;
});
return rows;
};
const rowElFor = p => {
const r = el('div', { class: 'am-row' + (selName === p.name ? ' sel' : ''), dataset: { proc: p.name } },
el('span', { class: 'am-col am-name', text: p.name }),
el('span', { class: 'am-col am-cpu', text: p.cpu.toFixed(1) }),
el('span', { class: 'am-col am-mem', text: p.mem.toFixed(0) + ' MB' }),
el('span', { class: 'am-col am-energy', text: p.energy.toFixed(1) }));
r.addEventListener('click', () => {
selName = p.name; quitBtn.disabled = false;
$$('.am-row', table).forEach(n => n.classList.toggle('sel', n.dataset.proc === selName));
});
return r;
};
const render = () => {
collect();
const rows = sortedRows();
const key = rows.map(r => r.name).join('|') + '#' + st.sort + st.desc + st.q;
if (key !== lastKey) {
lastKey = key;
table.innerHTML = '';
const head = el('div', { class: 'am-row am-head' });
[['名称', 'name'], ['CPU %', 'cpu'], ['内存', 'mem'], ['能耗', 'energy']].forEach(([label, k2]) => {
const h = el('span', { class: 'am-col', text: label + (st.sort === k2 ? (st.desc ? ' ↓' : ' ↑') : '') });
h.addEventListener('click', () => { if (st.sort === k2) st.desc = !st.desc; else { st.sort = k2; st.desc = true; } render(); });
head.append(h);
});
table.append(head);
rows.forEach(p => table.append(rowElFor(p)));
} else {
// 仅更新数值,避免 DOM 重建打断交互
for (const p of rows) {
const rEl = table.querySelector(`.am-row[data-proc="${CSS.escape(p.name)}"]`);
if (!rEl) continue;
rEl.querySelector('.am-cpu').textContent = p.cpu.toFixed(1);
rEl.querySelector('.am-mem').textContent = p.mem.toFixed(0) + ' MB';
rEl.querySelector('.am-energy').textContent = p.energy.toFixed(1);
}
}
if (selName && !st.rows.some(r => r.name === selName)) { selName = null; quitBtn.disabled = true; }
};
quitBtn.addEventListener('click', async () => {
const sel = st.rows.find(r => r.name === selName);
if (!sel) return;
if (sel.sys) { UI.alert('无法结束此进程', `“${sel.name}”是系统关键进程,结束它可能导致系统不稳定。`, this.icon); return; }
if (await UI.confirm('结束此进程?', `“${sel.name}”的所有窗口将被关闭。`, { ok: '结束', danger: true, icon: this.icon })) {
Apps.quit(sel.appId); selName = null; quitBtn.disabled = true; render();
}
});
search.addEventListener('input', debounce(() => { st.q = search.value.trim(); render(); }, 200));
search.addEventListener('keydown', e => e.stopPropagation());
win.timers.push(setInterval(() => { if (!document.body.contains(table)) return; render(); }, 2000));
render();
}
});
/* ==================== 天气 ==================== */
const WMO = {
0: ['晴', '☀️'], 1: ['大部晴朗', '🌤'], 2: ['局部多云', '⛅'], 3: ['阴', '☁️'],
45: ['雾', '🌫'], 48: ['雾凇', '🌫'], 51: ['小毛毛雨', '🌦'], 53: ['毛毛雨', '🌦'], 55: ['大毛毛雨', '🌦'],
61: ['小雨', '🌧'], 63: ['中雨', '🌧'], 65: ['大雨', '🌧'], 66: ['冻雨', '🌨'], 67: ['冻雨', '🌨'],
71: ['小雪', '🌨'], 73: ['中雪', '🌨'], 75: ['大雪', '❄️'], 77: ['雪粒', '❄️'],
80: ['小阵雨', '🌦'], 81: ['阵雨', '🌧'], 82: ['强阵雨', '🌧'], 85: ['阵雪', '🌨'], 86: ['强阵雪', '❄️'],
95: ['雷暴', '⛈'], 96: ['雷暴伴冰雹', '⛈'], 99: ['强雷暴', '⛈'],
};
const WEATHER_CITIES = {
'北京': [39.9042, 116.4074], '上海': [31.2304, 121.4737], '广州': [23.1291, 113.2644],
'深圳': [22.5431, 114.0579], '成都': [30.5728, 104.0668], '杭州': [30.2741, 120.1551],
'西安': [34.3416, 108.9398], '哈尔滨': [45.8038, 126.5349], '拉萨': [29.6520, 91.1721],
'香港': [22.3193, 114.1694], '三亚': [18.2528, 109.5119], '乌鲁木齐': [43.8256, 87.6168],
};
const WeatherApp = {
id: 'weather', name: '天气', icon: 'assets/icons/weather.svg',
w: 780, h: 560, minW: 520, minH: 400,
menus() { return stdMenus(this); },
store: {
get() { return Store.get('weather', { cities: ['北京', '上海'], cur: '北京', cache: {} }); },
set(v) { Store.set('weather', v); }
},
desc(code) { return (WMO[code] || ['多云', '⛅']); },
/* 确定性模拟数据(离线回退) */
simulate(city) {
let seed = 0; for (const c of city) seed = (seed * 31 + c.charCodeAt(0)) >>> 0;
const rnd = () => { seed = (seed * 1103515245 + 12345) >>> 0; return seed / 4294967296; };
const base = 18 + Math.round(rnd() * 14) - 4;
const codes = [0, 1, 2, 3, 61, 80, 95];
const hourly = [], daily = [];
const now = new Date();
for (let i = 0; i < 12; i++) hourly.push({ t: new Date(now.getTime() + i * 3600000).getHours(), temp: base + Math.round(Math.sin(i / 3) * 4), code: codes[Math.floor(rnd() * 3)] });
const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
for (let d = 0; d < 7; d++) daily.push({ day: d === 0 ? '今天' : wd[(now.getDay() + d) % 7], hi: base + 3 + Math.round(rnd() * 4), lo: base - 5 - Math.round(rnd() * 3), code: codes[Math.floor(rnd() * codes.length)] });
return { temp: base, code: codes[Math.floor(rnd() * 3)], hourly, daily, simulated: true, ts: Date.now() };
},
async fetchCity(city) {
const ll = WEATHER_CITIES[city];
if (!ll) return this.simulate(city);
const cache = this.store.get().cache[city];
try {
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), 5000);
const url = `https://api.open-meteo.com/v1/forecast?latitude=${ll[0]}&longitude=${ll[1]}¤t=temperature_2m,weather_code&hourly=temperature_2m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=7&timezone=auto`;
const res = await fetch(url, { signal: ctrl.signal });
clearTimeout(to);
if (!res.ok) throw new Error('http ' + res.status);
const j = await res.json();
const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const data = {
temp: Math.round(j.current.temperature_2m), code: j.current.weather_code,
hourly: j.hourly.time.slice(0, 12).map((t, i) => ({ t: new Date(t).getHours(), temp: Math.round(j.hourly.temperature_2m[i]), code: j.hourly.weather_code[i] })),
daily: j.daily.time.map((t, i) => ({ day: i === 0 ? '今天' : wd[new Date(t).getDay()], hi: Math.round(j.daily.temperature_2m_max[i]), lo: Math.round(j.daily.temperature_2m_min[i]), code: j.daily.weather_code[i] })),
ts: Date.now(),
};
const st = this.store.get(); st.cache[city] = data; this.store.set(st);
return data;
} catch (e) {
if (cache) return Object.assign({ stale: true }, cache);
return this.simulate(city);
}
},
widgetData() {
const st = this.store.get();
const d = st.cache[st.cur] || st.cache[st.cities[0]];
if (!d) return null;
const [txt] = this.desc(d.code);
return { city: st.cur, temp: d.temp, desc: txt, hi: d.daily?.[0]?.hi ?? d.temp, lo: d.daily?.[0]?.lo ?? d.temp };
},
renderInline(box) {
const st = this.store.get();
const d = st.cache[st.cur];
if (!d) { box.append(el('p', { text: '天气数据尚未加载,请先打开天气 App。' })); return; }
const [txt, ico] = this.desc(d.code);
box.append(el('h2', { text: `${st.cur} ${d.temp}° ${ico}` }),
el('p', { text: `${txt}。今日最高 ${d.daily[0].hi}°,最低 ${d.daily[0].lo}°。` }),
el('p', { text: '逐日预报:' + d.daily.map(x => `${x.day} ${x.lo}~${x.hi}°`).join(';') }));
},
render(win) {
const st = win.appState = { data: this.store.get(), loading: false };
win.body.classList.add('weather-body');
const side = el('div', { class: 'weather-side' });
const search = el('input', { class: 'text-input weather-search', type: 'search', placeholder: '搜索城市' });
const sug = el('div', { class: 'weather-sug hidden' });
const main = el('div', { class: 'weather-main' });
win.body.append(el('div', { class: 'weather-left' }, search, sug, side), main);
const save = () => this.store.set(st.data);
const renderSide = () => {
side.innerHTML = '';
st.data.cities.forEach(c => {
const d = st.data.cache[c];
const row = el('div', { class: 'weather-city' + (c === st.data.cur ? ' sel' : '') },
el('div', null, el('b', { text: c }), el('small', { text: d ? this.desc(d.code)[0] : '—' })),
el('span', { class: 'weather-city-temp', text: d ? d.temp + '°' : '…' }),
el('button', { class: 'weather-del', text: '✕', title: '删除城市', onclick: e => {
e.stopPropagation();
st.data.cities = st.data.cities.filter(x => x !== c);
if (st.data.cur === c) st.data.cur = st.data.cities[0] || '北京';
save(); renderAll();
} }));
row.addEventListener('click', () => { st.data.cur = c; save(); renderAll(); });
side.append(row);
});
};
const renderMain = async () => {
const c = st.data.cur;
main.innerHTML = '';
main.append(el('div', { class: 'empty-state', style: { height: '160px' } }, el('div', { class: 'es-icon', text: '⏳' }), el('div', { text: `正在更新${c}的天气…` })));
const d = await this.fetchCity(c);
if (st.data.cur !== c || !document.body.contains(main)) return;
st.data.cache[c] = d; save();
main.innerHTML = '';
const [txt, ico] = this.desc(d.code);
main.append(el('div', { class: 'weather-now' },
el('div', { class: 'weather-now-city', text: c + (d.simulated ? '(模拟数据)' : d.stale ? '(缓存)' : '') }),
el('div', { class: 'weather-now-temp', text: d.temp + '°' }),
el('div', { class: 'weather-now-desc', text: `${ico} ${txt}` }),
el('div', { class: 'weather-now-hl', text: `最高 ${d.daily[0].hi}° 最低 ${d.daily[0].lo}°` })));
const strip = el('div', { class: 'weather-strip' });
d.hourly.forEach((h, i) => strip.append(el('div', { class: 'weather-hour' },
el('span', { text: i === 0 ? '现在' : h.t + '时' }),
el('span', { class: 'weather-hour-ico', text: this.desc(h.code)[1] }),
el('b', { text: h.temp + '°' }))));
main.append(el('div', { class: 'weather-card' }, el('div', { class: 'cc-title', text: '逐小时预报' }), strip));
const list = el('div', { class: 'weather-days' });
d.daily.forEach(x => {
list.append(el('div', { class: 'weather-day' },
el('span', { class: 'weather-day-name', text: x.day }),
el('span', { text: this.desc(x.code)[1] }),
el('span', { class: 'weather-lo', text: x.lo + '°' }),
el('div', { class: 'weather-range' }, el('div', { class: 'weather-range-fill', style: { left: '20%', width: '60%' } })),
el('span', { class: 'weather-hi', text: x.hi + '°' })));
});
main.append(el('div', { class: 'weather-card' }, el('div', { class: 'cc-title', text: '7 日预报' }), list));
};
const renderAll = () => { renderSide(); renderMain(); };
search.addEventListener('input', debounce(() => {
const q = search.value.trim();
sug.innerHTML = '';
if (!q) { sug.classList.add('hidden'); return; }
const hits = Object.keys(WEATHER_CITIES).filter(c => c.includes(q) && !st.data.cities.includes(c));
sug.classList.remove('hidden');
if (!hits.length) sug.append(el('div', { class: 'maps-result', text: '无匹配城市' }));
hits.slice(0, 6).forEach(c => {
const r = el('div', { class: 'maps-result', text: c });
r.addEventListener('click', () => {
st.data.cities.push(c); st.data.cur = c; save();
search.value = ''; sug.classList.add('hidden'); renderAll();
});
sug.append(r);
});
}, 200));
search.addEventListener('keydown', e => e.stopPropagation());
renderAll();
}
};
Apps.register(WeatherApp);
window.WeatherApp = WeatherApp;
/* ==================== 时钟 ==================== */
const ClockApp = {
id: 'clock', name: '时钟', icon: 'assets/icons/clock.svg',
w: 680, h: 480, minW: 480, minH: 360,
menus() { return stdMenus(this); },
store: {
get() { return Store.get('clock', { cities: ['上海', '东京', '伦敦', '纽约'], alarms: [{ id: 'a1', h: 8, m: 30, label: '起床', on: false }] }); },
set(v) { Store.set('clock', v); }
},
/* 世界时钟使用 IANA 时区 + Intl.DateTimeFormat,自动正确处理夏令时 */
cityTZ: {
'北京': 'Asia/Shanghai', '上海': 'Asia/Shanghai', '东京': 'Asia/Tokyo', '伦敦': 'Europe/London',
'巴黎': 'Europe/Paris', '纽约': 'America/New_York', '洛杉矶': 'America/Los_Angeles', '悉尼': 'Australia/Sydney',
'迪拜': 'Asia/Dubai', '新加坡': 'Asia/Singapore',
},
tzOffsetLabel(tz) {
try {
const part = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
.formatToParts(new Date()).find(p => p.type === 'timeZoneName');
return part ? part.value.replace('GMT', 'UTC').replace(/^UTC$/, 'UTC+0') : '';
} catch (e) { return ''; }
},
beep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator(), g = ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.frequency.value = 880; g.gain.setValueAtTime(Sys.settings.muted ? 0 : Sys.settings.volume * 0.3, ctx.currentTime);
o.start(); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.2);
o.stop(ctx.currentTime + 1.3);
} catch (e) {}
},
tick() {
const st = this.store.get();
const now = new Date();
const hm = now.getHours() * 60 + now.getMinutes();
for (const a of st.alarms) {
if (!a.on) continue;
const key = '_fired_' + a.id + '_' + now.toDateString();
if (a.h * 60 + a.m === hm && !this[key]) {
this[key] = true;
Notify.send({ appId: 'clock', title: '闹钟', body: `${a.label || '闹钟'} ${String(a.h).padStart(2, '0')}:${String(a.m).padStart(2, '0')}` });
this.beep();
}
}
},
render(win) {
const st = win.appState = { tab: 'world', data: this.store.get(), sw: { running: false, t0: 0, acc: 0, laps: [] }, timer: { left: 0, running: false, total: 0 } };
win.body.classList.add('clock-body');
const tabbar = el('div', { class: 'store-tabs clock-tabs' });
const content = el('div', { class: 'clock-content' });
win.body.append(tabbar, content);
const save = () => this.store.set(st.data);
const tabs = [['world', '世界时钟'], ['alarm', '闹钟'], ['sw', '秒表'], ['timer', '计时器']];
tabs.forEach(([id, name]) => {
const b = el('button', { class: 'store-tab', text: name, dataset: { tab: id } });
b.addEventListener('click', () => go(id));
tabbar.append(b);
});
const go = id => {
st.tab = id;
$$('.store-tab', tabbar).forEach(n => n.classList.toggle('on', n.dataset.tab === id));
content.innerHTML = '';
this['render_' + id](content, st, save);
};
win.timers.push(setInterval(() => {
if (!document.body.contains(content)) return;
if (st.tab === 'world') this.render_world(content, st, save, true);
// 秒表/计时器的到点检测不依赖当前标签;UI 同步仅在对应标签可见时发生
if (st.sw.running && this._swTick) this._swTick();
if (st.timer.running && this._timerTick) this._timerTick();
}, 500));
/* 关闭窗口即停止计时,先明确提示(产品定义:计时随窗口生命周期结束) */
win.confirmClose = (done, cancel) => {
if (!st.sw.running && !st.timer.running) return done();
const what = st.timer.running ? '计时器正在运行,关闭窗口将停止计时。' : '秒表正在运行,关闭窗口将停止计时。';
UI.dialog({ icon: this.icon, title: '关闭时钟?', msg: what, buttons: ['取消', '关闭'] })
.then(r => { r.index === 1 ? done() : cancel(); });
};
go('world');
},
render_world(box, st, save, soft) {
const now = new Date();
if (!soft) {
box.innerHTML = '';
const sel = el('select', { class: 'text-input' }, el('option', { text: '添加城市…', value: '' }),
...Object.keys(this.cityTZ).filter(c => !st.data.cities.includes(c)).map(c => el('option', { text: c, value: c })));
sel.addEventListener('change', () => { if (sel.value) { st.data.cities.push(sel.value); save(); this.render_world(box, st, save); } });
const list = el('div', { class: 'clock-cities' });
box.append(el('div', { class: 'clock-addrow' }, sel), list);
}
const list = box.querySelector('.clock-cities');
list.innerHTML = '';
st.data.cities.forEach(c => {
const tz = this.cityTZ[c];
if (!tz) return;
const row = el('div', { class: 'clock-city' },
el('div', null, el('b', { text: c }), el('small', { text: this.tzOffsetLabel(tz) })),
el('span', { class: 'clock-city-time', text: fmtTimeHM(now, Sys.settings.h24, tz) }),
el('button', { class: 'weather-del', text: '✕', title: '删除', onclick: () => { st.data.cities = st.data.cities.filter(x => x !== c); save(); this.render_world(box, st, save); } }));
list.append(row);
});
},
render_alarm(box, st, save) {
box.innerHTML = '';
const list = el('div', { class: 'alarm-list' });
const renderList = () => {
list.innerHTML = '';
if (!st.data.alarms.length) list.append(el('div', { class: 'empty-state', style: { height: '120px' }, text: '没有闹钟' }));
st.data.alarms.forEach(a => {
const sw = el('div', { class: 'switch' + (a.on ? ' on' : ''), role: 'switch', 'aria-checked': String(!!a.on), tabindex: '0', 'aria-label': `闹钟 ${a.h}` });
const flipSw = () => { a.on = !a.on; sw.classList.toggle('on', a.on); sw.setAttribute('aria-checked', String(!!a.on)); save(); };
sw.addEventListener('click', flipSw);
sw.addEventListener('keydown', e => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); flipSw(); } });
const row = el('div', { class: 'alarm-row' },
el('div', { class: 'alarm-time', text: `${String(a.h).padStart(2, '0')}:${String(a.m).padStart(2, '0')}` }),
el('div', { class: 'alarm-label', text: a.label || '闹钟' }),
sw,
el('button', { class: 'weather-del', text: '✕', title: '删除闹钟', onclick: () => { st.data.alarms = st.data.alarms.filter(x => x !== a); save(); renderList(); } }));
list.append(row);
});
};
const hIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '23', value: '8', style: { width: '64px' } });
const mIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '59', value: '0', style: { width: '64px' } });
const lIn = el('input', { class: 'text-input', type: 'text', placeholder: '标签', value: '闹钟' });
const add = el('button', { class: 'btn primary', text: '添加闹钟' });
add.addEventListener('click', () => {
const h = clamp(+hIn.value || 0, 0, 23), m = clamp(+mIn.value || 0, 0, 59);
st.data.alarms.push({ id: uid(), h, m, label: lIn.value.trim() || '闹钟', on: true });
save(); renderList();
});
box.append(el('div', { class: 'clock-addrow' }, hIn, el('span', { text: ':' }), mIn, lIn, add), list);
renderList();
},
render_sw(box, st) {
box.innerHTML = '';
const disp = el('div', { class: 'sw-disp' });
const startBtn = el('button', { class: 'btn primary sw-btn' });
const lapBtn = el('button', { class: 'btn sw-btn', text: '计次' });
const resetBtn = el('button', { class: 'btn sw-btn', text: '复位' });
const laps = el('div', { class: 'sw-laps' });
box.append(disp, el('div', { class: 'sw-btns' }, lapBtn, startBtn, resetBtn), laps);
const fmtT = ms => {
const t = Math.floor(ms / 100);
return `${String(Math.floor(t / 600)).padStart(2, '0')}:${String(Math.floor(t / 10) % 60).padStart(2, '0')}.${t % 10}`;
};
const cur = () => st.sw.acc + (st.sw.running ? Date.now() - st.sw.t0 : 0);
const renderLaps = () => {
laps.innerHTML = '';
st.sw.laps.forEach((lp, i) => laps.prepend(el('div', { class: 'sw-lap' }, el('span', { text: `计次 ${i + 1}` }), el('span', { text: fmtT(lp) }))));
};
const syncUI = () => {
disp.textContent = fmtT(cur());
startBtn.textContent = st.sw.running ? '暂停' : (st.sw.acc > 0 ? '继续' : '开始');
lapBtn.disabled = !st.sw.running;
resetBtn.disabled = st.sw.running || st.sw.acc === 0;
};
this._swTick = () => { if (disp.isConnected) disp.textContent = fmtT(cur()); };
startBtn.addEventListener('click', () => {
st.sw.running = !st.sw.running;
if (st.sw.running) st.sw.t0 = Date.now();
else st.sw.acc += Date.now() - st.sw.t0;
syncUI();
});
lapBtn.addEventListener('click', () => { st.sw.laps.push(cur()); renderLaps(); });
resetBtn.addEventListener('click', () => { st.sw = { running: false, t0: 0, acc: 0, laps: [] }; renderLaps(); syncUI(); });
renderLaps(); syncUI(); // 跨标签切换后恢复:当前值、按钮文字、计次列表
},
render_timer(box, st) {
box.innerHTML = '';
const mIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '180', value: '5', style: { width: '72px' } });
const sIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '59', value: '0', style: { width: '72px' } });
const disp = el('div', { class: 'sw-disp', text: '05:00' });
const startBtn = el('button', { class: 'btn primary sw-btn' });
const resetBtn = el('button', { class: 'btn sw-btn', text: '复位' });
const ring = el('div', { class: 'timer-ring' }, disp);
box.append(el('div', { class: 'clock-addrow' }, mIn, el('span', { text: '分' }), sIn, el('span', { text: '秒' })),
ring, el('div', { class: 'sw-btns' }, resetBtn, startBtn));
const fmtT = ms => { const t = Math.max(0, Math.ceil(ms / 1000)); return `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`; };
/* endAt 是唯一时间基准;left 仅是暂停时的快照 */
const left = () => st.timer.running ? Math.max(0, st.timer.endAt - Date.now()) : st.timer.left;
const sync = () => {
const l = left();
disp.textContent = fmtT(l);
ring.style.setProperty('--p', st.timer.total ? String(1 - l / st.timer.total) : '0');
startBtn.textContent = st.timer.running ? '暂停' : (st.timer.left > 0 ? '继续' : '开始');
mIn.disabled = sIn.disabled = st.timer.running;
};
this._timerTick = () => {
if (!st.timer.running) return;
if (st.timer.endAt - Date.now() <= 0) {
st.timer.running = false; st.timer.left = 0;
if (!st.timer.notified) { // 一次计时结束只通知一次
st.timer.notified = true;
Notify.send({ appId: 'clock', title: '计时器', body: '时间到!', breakthrough: true });
this.beep();
}
} else st.timer.left = st.timer.endAt - Date.now();
if (disp.isConnected) sync();
};
startBtn.addEventListener('click', () => {
if (st.timer.running) { st.timer.left = Math.max(0, st.timer.endAt - Date.now()); st.timer.running = false; sync(); return; }
if (st.timer.left <= 0) {
st.timer.total = ((+mIn.value || 0) * 60 + (+sIn.value || 0)) * 1000;
if (st.timer.total <= 0) return;
st.timer.left = st.timer.total;
}
st.timer.endAt = Date.now() + st.timer.left;
st.timer.notified = false;
st.timer.running = true;
sync();
});
resetBtn.addEventListener('click', () => {
st.timer = { left: 0, running: false, total: 0, notified: false };
sync();
disp.textContent = fmtT(((+mIn.value || 0) * 60 + (+sIn.value || 0)) * 1000);
});
mIn.addEventListener('input', () => { if (!st.timer.running && st.timer.left <= 0) disp.textContent = fmtT(((+mIn.value || 0) * 60 + (+sIn.value || 0)) * 1000); });
sIn.addEventListener('input', () => mIn.dispatchEvent(new Event('input')));
sync(); // 跨标签切换后恢复:剩余时间、按钮文字、进度环
}
};
Apps.register(ClockApp);
window.ClockApp = ClockApp;
/* ============ 扩展应用 B:提醒事项 / 通讯录 / 便笺 / 邮件 / 信息 / FaceTime / 商店应用 ============ */
/* ==================== 提醒事项 ==================== */
Apps.register({
id: 'reminders', name: '提醒事项', icon: 'assets/icons/reminders.png',
w: 720, h: 500, minW: 520, minH: 340,
menus(win) {
const st = win?.appState;
return stdMenus(this, { file: [{ label: '新建提醒事项', key: '⌘N', action: () => st?.focusNew() }] });
},
colors: ['#0a84ff', '#ff9f0a', '#ff453a', '#32d74b', '#bf5af2'],
store: {
get() {
return 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: localDateKey(new Date(Date.now() + 86400000 * 2)), done: false },
{ id: uid(), list: 'l3', title: '买牛奶和鸡蛋', due: '', done: true },
]
});
},
set(v) { Store.set('reminders', v); }
},
render(win) {
const st = win.appState = { data: this.store.get(), cur: 'l1' };
win.body.classList.add('rem-body');
const side = el('div', { class: 'rem-side' });
const listEl = el('div', { class: 'rem-items' });
const newIn = el('input', { class: 'text-input rem-new', type: 'text', placeholder: '添加提醒事项,回车确认' });
const main = el('div', { class: 'rem-main' }, listEl, el('div', { class: 'rem-new-row' }, newIn));
win.body.append(side, main);
const save = () => this.store.set(st.data);
st.focusNew = () => newIn.focus();
const renderSide = () => {
side.innerHTML = '';
side.append(el('div', { class: 'fb-side-title', text: '我的列表' }));
st.data.lists.forEach(l => {
const row = el('div', { class: 'fb-side-item' + (st.cur === l.id ? ' sel' : '') },
el('span', { class: 'rem-color', style: { background: l.color } }),
el('span', { class: 'rem-lname', text: l.name }),
el('span', { class: 'rem-count', text: String(st.data.items.filter(i => i.list === l.id && !i.done).length) }));
row.addEventListener('click', () => { st.cur = l.id; renderAll(); });
side.append(row);
});
const addList = el('button', { class: 'btn rem-addlist', text: '+ 添加列表' });
addList.addEventListener('click', async () => {
const name = await UI.prompt('新建列表', '列表名称:', '新列表');
if (!name) return;
const color = this.colors[st.data.lists.length % this.colors.length];
st.data.lists.push({ id: uid(), name, color }); save(); renderSide();
});
side.append(addList);
};
const renderItems = () => {
listEl.innerHTML = '';
const items = st.data.items.filter(i => i.list === st.cur);
const open = items.filter(i => !i.done), done = items.filter(i => i.done);
const mkRow = it => {
const cb = el('button', { class: 'rem-check' + (it.done ? ' done' : ''), 'aria-label': it.done ? '标记为未完成' : '标记为完成' });
cb.addEventListener('click', () => { it.done = !it.done; save(); renderAll(); });
const titleIn = el('input', { class: 'rem-title' + (it.done ? ' done' : ''), value: it.title });
titleIn.addEventListener('change', () => { it.title = titleIn.value.trim() || it.title; save(); renderAll(); });
const dueIn = el('input', { class: 'rem-due', type: 'date', value: it.due || '' });
dueIn.addEventListener('change', () => { it.due = dueIn.value; save(); });
const del = el('button', { class: 'weather-del', text: '✕', title: '删除', onclick: () => { st.data.items = st.data.items.filter(x => x !== it); save(); renderAll(); } });
return el('div', { class: 'rem-row' }, cb, titleIn, dueIn, del);
};
open.forEach(it => listEl.append(mkRow(it)));
if (done.length) {
listEl.append(el('div', { class: 'fb-side-title', text: `已完成(${done.length})` }));
done.forEach(it => listEl.append(mkRow(it)));
}
if (!items.length) listEl.append(el('div', { class: 'empty-state', style: { height: '140px' }, text: '此列表为空' }));
};
newIn.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter' && newIn.value.trim()) {
st.data.items.push({ id: uid(), list: st.cur, title: newIn.value.trim(), due: '', done: false });
newIn.value = ''; save(); renderAll();
}
});
const renderAll = () => { renderSide(); renderItems(); };
renderAll();
}
});
/* ==================== 通讯录 ==================== */
const ContactsApp = {
id: 'contacts', name: '通讯录', icon: 'assets/icons/contacts.png',
w: 760, h: 520, minW: 560, minH: 360,
menus(win) {
const st = win?.appState;
return stdMenus(this, { file: [{ label: '新建联系人', key: '⌘N', action: () => st?.edit(null) }] });
},
store: {
get() {
return Store.get('contacts', [
{ id: uid(), name: '王小明', phone: '138 0000 1234', email: 'xiaoming@example.com', note: '大学同学' },
{ id: uid(), name: '李思颖', phone: '139 1111 5678', email: 'siying.li@example.com', note: '产品经理' },
{ id: uid(), name: '张伟', phone: '137 2222 9012', email: 'zhangwei@example.com', note: '' },
{ id: uid(), name: '陈静', phone: '136 3333 3456', email: 'chenjing@example.com', note: '设计团队' },
{ id: uid(), name: '刘洋', phone: '135 4444 7890', email: 'liuyang@example.com', note: '周末球友' },
]);
},
set(v) { Store.set('contacts', v); }
},
render(win) {
const st = win.appState = { data: this.store.get(), cur: null, q: '' };
win.body.classList.add('contacts-body');
const search = el('input', { class: 'text-input notes-search', type: 'search', placeholder: '搜索' });
const addBtn = el('button', { class: 'fb-btn', html: '+', title: '新建联系人' });
const listEl = el('div', { class: 'notes-list' });
const detail = el('div', { class: 'contact-detail' });
win.body.append(el('div', { class: 'notes-side' }, el('div', { class: 'notes-side-bar' }, search, addBtn), listEl), detail);
const save = () => this.store.set(st.data);
const initials = n => n.slice(0, 1);
const renderList = () => {
listEl.innerHTML = '';
const items = st.data.filter(c => !st.q || (c.name + c.phone + c.email).toLowerCase().includes(st.q.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
if (!items.length) listEl.append(el('div', { class: 'empty-state', style: { height: '120px' }, text: '没有联系人' }));
items.forEach(c => {
const row = el('div', { class: 'note-row contact-row' + (c === st.cur ? ' sel' : '') },
el('span', { class: 'contact-avatar', text: initials(c.name) }),
el('div', null, el('div', { class: 'note-title', text: c.name }), el('div', { class: 'note-sub', text: c.phone })));
row.addEventListener('click', () => { st.cur = c; renderDetail(); });
listEl.append(row);
});
};
const renderDetail = () => {
renderList();
detail.innerHTML = '';
if (!st.cur) { detail.append(el('div', { class: 'empty-state' }, el('div', { class: 'es-icon', text: '👤' }), el('div', { text: '选择联系人' }))); return; }
const c = st.cur;
const mail = el('button', { class: 'btn', text: '✉️ 发邮件' });
mail.addEventListener('click', () => Apps.open('mail', { compose: { to: c.email, name: c.name } }));
const ft = el('button', { class: 'btn', text: '📹 FaceTime' });
ft.addEventListener('click', () => Apps.open('facetime', { call: c }));
const edit = el('button', { class: 'btn', text: '编辑' });
edit.addEventListener('click', () => st.edit(c));
const del = el('button', { class: 'btn danger', text: '删除' });
del.addEventListener('click', async () => {
if (!await UI.confirm('删除联系人?', `将删除“${c.name}”。`, { ok: '删除', danger: true })) return;
st.data = st.data.filter(x => x !== c); st.cur = null; save(); renderDetail();
});
detail.append(
el('div', { class: 'contact-card' },
el('div', { class: 'contact-big-avatar', text: initials(c.name) }),
el('div', { class: 'contact-name', text: c.name }),
c.note ? el('div', { class: 'contact-note', text: c.note }) : null,
el('div', { class: 'contact-fields' },
el('div', { class: 'contact-field' }, el('span', { class: 'cf-label', text: '电话' }), el('span', { text: c.phone })),
el('div', { class: 'contact-field' }, el('span', { class: 'cf-label', text: '邮箱' }), el('span', { text: c.email }))),
el('div', { class: 'contact-actions' }, mail, ft, edit, del)));
};
st.edit = c => {
const isNew = !c;
const data = c ? { ...c } : { id: uid(), name: '', phone: '', email: '', note: '' };
const mask = el('div', { class: 'modal-mask' });
const nameIn = el('input', { class: 'text-input dlg-input', placeholder: '姓名', value: data.name });
const phoneIn = el('input', { class: 'text-input dlg-input', placeholder: '电话', value: data.phone });
const emailIn = el('input', { class: 'text-input dlg-input', placeholder: '邮箱', value: data.email });
const noteIn = el('input', { class: 'text-input dlg-input', placeholder: '备注', value: data.note });
const dlg = el('div', { class: 'dialog', style: { width: '300px' } },
el('div', { class: 'dlg-title', text: isNew ? '新建联系人' : '编辑联系人' }),
nameIn, phoneIn, emailIn, noteIn,
el('div', { class: 'dlg-btns row' },
el('button', { class: 'btn', text: '取消', onclick: () => mask.remove() }),
el('button', {
class: 'btn primary', text: '存储', onclick: () => {
if (!nameIn.value.trim()) { nameIn.focus(); return; }
Object.assign(data, { name: nameIn.value.trim(), phone: phoneIn.value.trim(), email: emailIn.value.trim(), note: noteIn.value.trim() });
if (isNew) st.data.push(data);
else Object.assign(c, data);
st.cur = data; save(); mask.remove(); renderDetail();
}
})));
mask.append(dlg); document.body.append(mask);
nameIn.focus();
};
search.addEventListener('input', debounce(() => { st.q = search.value.trim(); renderList(); }, 200));
addBtn.addEventListener('click', () => st.edit(null));
renderDetail();
}
};
Apps.register(ContactsApp);
window.ContactsApp = ContactsApp;
/* ==================== 便笺 ==================== */
Apps.register({
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' },
store: {
get() { return Store.get('stickies', [{ id: 's1', text: '双击便笺即可编辑。\n通过菜单可以更换颜色。', color: 'yellow', x: null, y: null }]); },
set(v) { Store.set('stickies', v); }
},
menus(win) {
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, v]) => ({
label: { yellow: '黄色', blue: '蓝色', green: '绿色', pink: '粉色', purple: '紫色' }[k],
checked: st?.note.color === k,
action: () => st?.setColor(k),
}))
});
},
spawn(note) {
const data = this.store.get();
if (!note) { note = { id: uid(), text: '', color: 'yellow', x: null, y: null }; data.push(note); this.store.set(data); }
const win = Apps.open('stickies', { noteId: note.id });
return win;
},
openAll() {
const data = this.store.get();
if (!data.length) return this.spawn();
data.forEach((n, i) => setTimeout(() => this.spawn(n), i * 120));
},
render(win, args) {
const data = this.store.get();
const note = data.find(n => 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 => x.id === note.id);
if (n) { n.text = area.textContent; this.store.set(all); }
}, 400));
st.setColor = k => {
note.color = k;
const all = this.store.get();
const n = all.find(x => 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 => x.id !== note.id);
this.store.set(all);
win.confirmClose = null;
WM.close(win);
};
win.el.addEventListener('contextmenu', e => {
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);
});
WM.setTitle(win, '便笺');
setTimeout(() => area.focus(), 60);
}
});
/* ==================== 邮件 ==================== */
Apps.register({
id: 'mail', name: '邮件', icon: 'assets/icons/mail.png',
w: 900, h: 580, minW: 640, minH: 420,
menus(win) {
const st = win?.appState;
return stdMenus(this, {
file: [{ label: '新邮件', key: '⌘N', action: () => st?.compose({}) }],
});
},
store: {
get() {
return Store.get('mail', {
inbox: [
{ id: uid(), from: 'Apple', addr: 'no-reply@apple.com', subject: '欢迎使用 macOS 网页版', body: '谢谢你选择 macOS 网页版!\n\n这是一封预置邮件,你可以将它标为已读、加星标或删除。\n\n—— Apple 团队', ts: Date.now() - 3600000 * 5, read: false, star: true },
{ id: uid(), from: '王小明', addr: 'xiaoming@example.com', subject: '周末聚餐', body: '周六晚上老地方,七点不见不散!\n记得带上 Switch。', ts: Date.now() - 3600000 * 26, read: false, star: false },
{ id: uid(), from: 'GitHub', addr: 'notifications@github.com', subject: '[macos-web] 新的 Star', body: '你的仓库 macos-web 获得了新的 Star!\n\n当前 Star 数:1024', ts: Date.now() - 86400000 * 2, read: true, star: false },
{ id: uid(), from: '李思颖', addr: 'siying.li@example.com', subject: '设计评审纪要', body: '今天评审的结论:\n1. Dock 放大效果再调一版\n2. 深色模式对比度提升\n3. 下周二前出高保真\n\n辛苦大家!', ts: Date.now() - 86400000 * 3, read: true, star: false },
],
sent: [], trash: []
});
},
set(v) { Store.set('mail', v); }
},
onArgs(args, win) { if (args.compose) win.appState?.compose(args.compose); },
render(win) {
const st = win.appState = { data: this.store.get(), box: 'inbox', cur: null, q: '' };
win.body.classList.add('mail-body');
const boxes = [['inbox', '收件箱', '📥'], ['sent', '已发送', '📤'], ['trash', '废纸篓', '🗑']];
const sideEl = el('div', { class: 'mail-side' });
const listEl = el('div', { class: 'mail-list' });
const detailEl = el('div', { class: 'mail-detail' });
const newBtn = el('button', { class: 'fb-btn', html: '✏️', title: '新邮件' });
const search = el('input', { class: 'text-input notes-search', type: 'search', placeholder: '搜索邮件' });
win.body.append(
el('div', { class: 'notes-side mail-col1' }, el('div', { class: 'notes-side-bar' }, search, newBtn), sideEl),
listEl, detailEl);
const save = () => this.store.set(st.data);
const renderSide = () => {
sideEl.innerHTML = '';
boxes.forEach(([id, name, ico]) => {
const unread = st.data[id].filter(m => !m.read).length;
const row = el('div', { class: 'fb-side-item' + (st.box === id ? ' sel' : '') },
el('span', { class: 'fb-side-ico', text: ico }), el('span', { class: 'rem-lname', text: name }),
unread ? el('span', { class: 'badge', text: String(unread) }) : null);
row.addEventListener('click', () => { st.box = id; st.cur = null; renderAll(); });
sideEl.append(row);
});
};
const renderList = () => {
listEl.innerHTML = '';
const items = st.data[st.box]
.filter(m => !st.q || (m.subject + m.from + m.body).toLowerCase().includes(st.q.toLowerCase()))
.sort((a, b) => b.ts - a.ts);
if (!items.length) listEl.append(el('div', { class: 'empty-state', style: { height: '160px' }, text: '没有邮件' }));
items.forEach(m => {
const row = el('div', { class: 'mail-row' + (m === st.cur ? ' sel' : '') + (m.read ? '' : ' unread') },
el('div', { class: 'mail-row-top' }, el('b', { text: m.from }), el('span', { class: 'mail-time', text: relTime(m.ts) })),
el('div', { class: 'mail-subject' }, m.star ? el('span', { class: 'mail-star', text: '★' }) : null, el('span', { text: m.subject })),
el('div', { class: 'mail-preview', text: m.body.slice(0, 48) }));
row.addEventListener('click', () => {
st.cur = m;
if (!m.read) { m.read = true; save(); }
renderAll();
});
listEl.append(row);
});
};
const renderDetail = () => {
detailEl.innerHTML = '';
const m = st.cur;
if (!m) { detailEl.append(el('div', { class: 'empty-state' }, el('div', { class: 'es-icon', text: '✉️' }), el('div', { text: '选择一封邮件' }))); return; }
const star = el('button', { class: 'fb-btn', text: m.star ? '★' : '☆', title: '星标' });
star.addEventListener('click', () => { m.star = !m.star; save(); renderAll(); });
const del = el('button', { class: 'fb-btn', html: '🗑', title: '删除' });
del.addEventListener('click', () => {
st.data[st.box] = st.data[st.box].filter(x => x !== m);
if (st.box !== 'trash') st.data.trash.push(m);
st.cur = null; save(); renderAll();
});
const reply = el('button', { class: 'btn', text: '回复' });
reply.addEventListener('click', () => st.compose({ to: m.addr, name: m.from, subject: '回复:' + m.subject, quote: m.body }));
detailEl.append(
el('div', { class: 'mail-detail-head' },
el('div', { class: 'mail-detail-subject', text: m.subject }),
el('div', { class: 'mail-detail-meta' }, el('span', { text: `${m.from} <${m.addr}>` }), el('span', { text: new Date(m.ts).toLocaleString('zh-CN') })),
el('div', { class: 'mail-detail-actions' }, star, del, reply)),
el('div', { class: 'mail-detail-body', text: m.body }));
};
st.compose = ({ to = '', name = '', subject = '', quote = '' } = {}) => {
const mask = el('div', { class: 'modal-mask' });
const toIn = el('input', { class: 'text-input dlg-input', placeholder: '收件人', value: to });
const subIn = el('input', { class: 'text-input dlg-input', placeholder: '主题', value: subject });
const bodyIn = el('textarea', { class: 'text-input mail-compose-body', placeholder: '正文…' });
bodyIn.value = quote ? `\n\n—— 原始邮件 ——\n${quote}` : '';
const send = () => {
if (!toIn.value.trim()) { toIn.focus(); return; }
st.data.sent.push({ id: uid(), from: '我', addr: toIn.value.trim(), subject: subIn.value.trim() || '(无主题)', body: bodyIn.value, ts: Date.now(), read: true, star: false });
save(); mask.remove();
Notify.send({ appId: 'mail', title: '邮件已发送', body: `发送至 ${toIn.value.trim()}`, silent: true });
// 模拟回信
setTimeout(() => {
st.data.inbox.push({ id: uid(), from: name || toIn.value.trim().split('@')[0], addr: toIn.value.trim(), subject: '回复:' + (subIn.value.trim() || '(无主题)'), body: '收到你的邮件了,谢谢!\n\n(这是一条自动回复,用于演示离线邮件流程。)', ts: Date.now(), read: false, star: false });
save();
Notify.send({ appId: 'mail', title: name || '新邮件', body: '回复:' + (subIn.value.trim() || '(无主题)') });
if (document.body.contains(win.el)) renderAll();
}, 4000 + Math.random() * 3000);
};
const dlg = el('div', { class: 'dialog mail-compose' },
el('div', { class: 'dlg-title', text: '新邮件' }), toIn, subIn, bodyIn,
el('div', { class: 'dlg-btns row' },
el('button', { class: 'btn', text: '取消', onclick: () => mask.remove() }),
el('button', { class: 'btn primary', text: '发送', onclick: send })));
mask.append(dlg); document.body.append(mask);
toIn.focus();
};
newBtn.addEventListener('click', () => st.compose({}));
search.addEventListener('input', debounce(() => { st.q = search.value.trim(); renderList(); }, 200));
const renderAll = () => { renderSide(); renderList(); renderDetail(); };
renderAll();
}
});
/* ==================== 信息 ==================== */
Apps.register({
id: 'messages', name: '信息', icon: 'assets/icons/messages.png',
w: 760, h: 540, minW: 560, minH: 380,
menus(win) {
const st = win?.appState;
return stdMenus(this, { file: [{ label: '新信息', key: '⌘N', action: () => st?.newChat() }] });
},
store: {
get() {
return Store.get('messages', {
convs: [
{ id: 'c1', name: '王小明', msgs: [
{ from: 'them', text: '在吗?周末的球局你还来吗', ts: Date.now() - 7200000 },
{ from: 'me', text: '来!老时间老地方?', ts: Date.now() - 7000000 },
{ from: 'them', text: '对,下午三点,别迟到', ts: Date.now() - 6900000 },
], unread: 0 },
{ id: 'c2', name: '李思颖', msgs: [
{ from: 'them', text: '设计稿我看完了,整体很棒', ts: Date.now() - 4000000 },
{ from: 'them', text: 'Dock 的放大曲线再顺滑一点就更好了', ts: Date.now() - 3900000 },
], unread: 2 },
{ id: 'c3', name: '妈妈', msgs: [
{ from: 'them', text: '吃饭了吗?最近降温,多穿点', ts: Date.now() - 90000000 },
], unread: 1 },
]
});
},
set(v) { Store.set('messages', v); }
},
replies: ['哈哈好的', '收到!', '稍等,我马上看', '真的吗?太好了', '没问题 👌', '那就这么定了', '哈哈哈哈', '嗯嗯,知道了'],
render(win) {
const st = win.appState = { data: this.store.get(), cur: null };
win.body.classList.add('msg-body');
const listEl = el('div', { class: 'notes-list' });
const chatEl = el('div', { class: 'msg-chat' });
win.body.append(el('div', { class: 'notes-side msg-side' }, el('div', { class: 'notes-side-bar' }, el('span', { class: 'fb-side-title', text: '对话' })), listEl), chatEl);
const save = () => this.store.set(st.data);
st.newChat = async () => {
const name = await UI.prompt('新对话', '输入联系人姓名:', '');
if (!name) return;
const conv = { id: uid(), name, msgs: [], unread: 0 };
st.data.convs.push(conv); st.cur = conv; save(); renderAll();
};
const renderList = () => {
listEl.innerHTML = '';
st.data.convs.sort((a, b) => (b.msgs[b.msgs.length - 1]?.ts || 0) - (a.msgs[a.msgs.length - 1]?.ts || 0)).forEach(c => {
const last = c.msgs[c.msgs.length - 1];
const row = el('div', { class: 'note-row contact-row' + (c === st.cur ? ' sel' : '') },
el('span', { class: 'contact-avatar', text: c.name.slice(0, 1) }),
el('div', { style: { flex: '1', minWidth: '0' } },
el('div', { class: 'note-title', text: c.name }),
el('div', { class: 'note-sub', text: last ? last.text : '开始聊天吧' })),
c.unread ? el('span', { class: 'badge', text: String(c.unread) }) : null);
row.addEventListener('click', () => { st.cur = c; c.unread = 0; save(); renderAll(); });
listEl.append(row);
});
};
const renderChat = () => {
chatEl.innerHTML = '';
const c = st.cur;
if (!c) { chatEl.append(el('div', { class: 'empty-state' }, el('div', { class: 'es-icon', text: '💬' }), el('div', { text: '选择对话' }))); return; }
const scroll = el('div', { class: 'msg-scroll' });
let lastTs = 0;
c.msgs.forEach(m => {
if (m.ts - lastTs > 300000) scroll.append(el('div', { class: 'msg-time', text: relTime(m.ts) }));
lastTs = m.ts;
scroll.append(el('div', { class: 'msg-bubble-row ' + m.from }, el('div', { class: 'msg-bubble', text: m.text })));
});
const input = el('input', { class: 'text-input msg-input', type: 'text', placeholder: 'iMessage 信息' });
const send = () => {
const v = input.value.trim(); if (!v) return;
c.msgs.push({ from: 'me', text: v, ts: Date.now() });
input.value = ''; save(); renderChat(); renderList();
setTimeout(() => {
if (!st.data.convs.includes(c)) return;
c.msgs.push({ from: 'them', text: this.replies[Math.floor(Math.random() * this.replies.length)], ts: Date.now() });
save();
if (st.cur === c && document.body.contains(chatEl)) renderChat();
else { c.unread++; Notify.send({ appId: 'messages', title: c.name, body: c.msgs[c.msgs.length - 1].text }); }
renderList();
}, 1200 + Math.random() * 2200);
};
input.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') send(); });
chatEl.append(el('div', { class: 'msg-header' }, el('span', { class: 'contact-avatar', text: c.name.slice(0, 1) }), el('b', { text: c.name })), scroll, el('div', { class: 'msg-input-row' }, input));
setTimeout(() => { scroll.scrollTop = scroll.scrollHeight; }, 30);
};
const renderAll = () => { renderList(); renderChat(); };
st.cur = st.data.convs[0] || null;
renderAll();
}
});
/* ==================== FaceTime ==================== */
Apps.register({
id: 'facetime', name: 'FaceTime 通话', icon: 'assets/icons/facetime.png',
w: 720, h: 520, minW: 480, minH: 360,
menus() { return stdMenus(this); },
onArgs(args, win) { if (args.call) win.appState?.startCall(args.call); },
render(win, args) {
const st = win.appState = { phase: 'idle', contact: null };
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 => {
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 => {
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) }));
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 secs = 0, timer = 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);
}
});
/* ==================== App Store 目录应用(安装后可用) ==================== */
/* 熊掌记 — Markdown 笔记 */
Apps.register({
id: 'bear', name: '熊掌记', icon: 'assets/icons/bear.png', storeApp: true,
w: 720, h: 500, minW: 480, minH: 320,
menus(win) { return stdMenus(this, { file: [{ label: '保存', key: '⌘S', action: () => win?.appState?.save() }] }); },
render(win) {
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 记录想法…' });
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 => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); st.save(); } });
}
});
/* Typora — 实时预览 Markdown */
Apps.register({
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) {
win.body.classList.add('typora-body');
const ta = el('textarea', { class: 'te-area typora-edit' });
const pv = el('div', { class: 'typora-preview' });
win.body.append(ta, pv);
const renderMd = md => {
pv.innerHTML = '';
md.split('\n').forEach(line => {
const escd = esc(line).replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/`(.+?)`/g, '$1');
if (line.startsWith('# ')) pv.append(el('h1', { html: escd.slice(2) }));
else if (line.startsWith('## ')) pv.append(el('h2', { html: escd.slice(3) }));
else if (line.startsWith('### ')) pv.append(el('h3', { html: escd.slice(4) }));
else if (line.startsWith('- ')) pv.append(el('p', { html: '• ' + escd.slice(2), class: 'ty-li' }));
else if (line.trim() === '') pv.append(el('div', { style: { height: '10px' } }));
else pv.append(el('p', { html: escd }));
});
};
ta.value = '# Typora\n\n**所见即所得** 的 Markdown 编辑体验。\n\n- 左侧输入\n- 右侧实时预览\n\n`console.log("hello")`\n';
ta.addEventListener('input', debounce(() => renderMd(ta.value), 150));
renderMd(ta.value);
}
});
/* VS Code Web — 轻量编辑器 */
Apps.register({
id: 'vscode', name: 'VS Code Web', icon: 'assets/icons/vscode.png', storeApp: true,
w: 860, h: 560, minW: 600, minH: 400,
menus(win) { return stdMenus(this, { file: [{ label: '保存', key: '⌘S', action: () => win?.appState?.save() }] }); },
render(win) {
const st = win.appState = { path: null };
win.body.classList.add('vscode-body');
const tree = el('div', { class: 'vs-tree' });
const ta = el('textarea', { class: 'vs-editor', spellcheck: 'false' });
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, n) => {
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 => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); st.save(); } });
renderTree();
}
});
/* Keynote — 简易幻灯片 */
Apps.register({
id: 'keynote', name: 'Keynote 讲演', icon: 'assets/icons/keynote.png', storeApp: true,
w: 800, h: 560, minW: 560, minH: 400,
menus(win) { 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) {
const st = win.appState = { idx: 0 };
win.body.classList.add('keynote-body');
const side = el('div', { class: 'kn-side' });
const 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 => { 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 => {
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();
}
});
/* 播客 */
Apps.register({
id: 'podcasts', name: '播客', icon: 'assets/icons/podcasts.png', storeApp: true,
w: 720, h: 500, minW: 520, minH: 360,
menus() { return stdMenus(this); },
render(win) {
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, i) => ({ title: '第 ' + (i + 1) + ' 期 · ' + t.title, show: 'Web 桌面谈', dur: t.dur, file: t.file }));
eps.forEach((ep, i) => {
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 => b.textContent = '▶'); });
}
});
/* Apple News */
Apps.register({
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) {
win.body.classList.add('news-body');
const arts = [
['macOS 网页版 1.0 正式发布', '科技', '完全在浏览器中运行的桌面体验:窗口管理、虚拟文件系统、20+ 款应用,全部离线可用。'],
['本地优先软件为何卷土重来', '观点', '当云服务成为默认,选择把数据留在本地反而成了一种新的自由。'],
['浏览器平台能力年度盘点', '技术', '从通知到文件系统访问,Web 与原生应用的边界正在消融。'],
['开源素材生态探访', '社区', 'CC-BY 音乐、CC0 视频与社区图标包,让个人项目也能拥有完整质感。'],
];
arts.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 })));
});
}
});
/* GitHub Desktop */
Apps.register({
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) {
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: '· 完整窗口管理器(拖动 / 8 向缩放 / 全屏)\n· localStorage 虚拟文件系统\n· 20+ 内置应用与完整系统设置\n· 离线优先,优雅回退' })));
}
});
/* Apple TV */
Apps.register({
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) {
const st = win.appState = {};
win.body.classList.add('tv-body');
const grid = el('div', { class: 'tv-grid' });
const playerBox = el('div', { class: 'tv-player hidden' });
win.body.append(el('h2', { class: 'store-h', text: '立即观看' }), grid, playerBox);
let video = 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: '' });
video.innerHTML = ``;
video.load();
Sys.registerMedia(video);
playerBox.append(el('div', { class: 'tv-ptitle', text: v.title }), video);
playerBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
grid.append(card);
});
win.onClose = () => { if (video) { video.pause(); video.src = ''; Sys.unregisterMedia(video); } };
}
});