From c577546ada38b26f595a5e77a157d418d1984f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Thu, 23 Jul 2026 09:55:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20safari=20=E2=80=94?= =?UTF-8?q?=20Vue=20=E7=BB=84=E4=BB=B6=E5=8C=96=EF=BC=88=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5/=E5=9C=B0=E5=9D=80=E6=A0=8F/=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=AF=BC=E8=88=AA/=E6=90=9C=E7=B4=A2=E5=BC=95=E6=93=8E?= =?UTF-8?q?=EF=BC=89+=206=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apps/safari/Safari.vue | 186 +++++++++++++++++++++++++++++++++- src/apps/safari/index.ts | 108 ++------------------ tests/cases/27-safari.test.ts | 54 ++++++++++ 3 files changed, 245 insertions(+), 103 deletions(-) create mode 100644 tests/cases/27-safari.test.ts diff --git a/src/apps/safari/Safari.vue b/src/apps/safari/Safari.vue index 0ada1d8..e23edd8 100644 --- a/src/apps/safari/Safari.vue +++ b/src/apps/safari/Safari.vue @@ -1 +1,185 @@ - + + + + diff --git a/src/apps/safari/index.ts b/src/apps/safari/index.ts index 22fb73a..f2e863f 100644 --- a/src/apps/safari/index.ts +++ b/src/apps/safari/index.ts @@ -1,11 +1,7 @@ +// Safari 浏览器 — Vue 组件化 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 = { id: 'safari', name: 'Safari 浏览器', icon: '/assets/icons/safari.png', @@ -18,106 +14,14 @@ export const SafariApp = { { label: '重新载入页面', key: '⌘R', action: () => st?.reload() }, ], view: [ - { label: '后退', key: '⌘[', disabled: !st?.canBack(), action: () => st?.back() }, - { label: '前进', key: '⌘]', disabled: !st?.canFwd(), action: () => st?.fwd() }, + { label: '后退', key: '⌘[', disabled: !st?.canBack?.value, action: () => st?.back() }, + { label: '前进', key: '⌘]', disabled: !st?.canFwd?.value, action: () => st?.fwd() }, ] }) }, - builtinPages: { - 'macos://weather': { - title: '天气 - 内置页', - build(box: HTMLElement) { - box.append(el('h2', { text: '天气' })) - box.append(el('p', { text: '请打开天气应用查看完整天气信息。' })) - } - }, - 'macos://news': { - title: '科技新闻 - 内置页', - build(box: HTMLElement) { - box.append(el('h2', { text: '科技要闻' })); - [['macOS 网页版发布', '完全在浏览器中运行的桌面体验。'], ['本地优先的软件设计兴起', '越来越多的应用选择将数据保存在本地。'], ['Web 平台能力持续增强', '通知、文件系统访问与硬件加速让网页应用接近原生体验。'], ['开源社区年度盘点', '来自全球的开发者为桌面模拟器项目贡献了素材。']].forEach(([t, d]) => box.append(el('div', { class: 'sf-news-item' }, el('b', { text: t }), el('p', { text: d })))) - } - }, - 'macos://baike': { - title: '百科 - 内置页', - build(box: HTMLElement) { - box.append(el('h2', { text: 'macOS' })) - box.append(el('p', { text: 'macOS 是苹果公司为 Mac 系列电脑开发的操作系统。本页面由 Safari 内置百科提供,离线可读。' })) - box.append(el('h3', { text: '版本沿革' })) - box.append(el('p', { text: 'Cheetah、Puma、Jaguar … Sequoia。' })) - } - }, - 'macos://github': { - title: '代码仓库 - 内置页', - build(box: HTMLElement) { - box.append(el('h2', { text: 'macos-web' })) - box.append(el('p', { text: '一个纯静态的 macOS 桌面模拟器。许可:MIT。' })) - box.append(el('pre', { class: 'sf-code', text: 'macos-web/\n├── index.html\n├── css/\n├── js/\n└── assets/' })) - } - }, + render(win: any) { + const vnode = h(SafariComponent, { win }) + render(vnode, win.body) }, - render(win: any, args: any) { - const S = Sys() - const st = win.appState = { hist: [] as string[], hi: -1, loading: false } - const favs = Store.get('safari-favs', [ - { name: '天气', url: 'macos://weather', ico: '🌤' }, - { name: '科技新闻', url: 'macos://news', ico: '📰' }, - { name: '百科', url: 'macos://baike', ico: '📚' }, - { name: '代码仓库', url: 'macos://github', ico: '💻' }, - ]) - win.body.classList.add('safari-body') - const backBtn = el('button', { class: 'fb-btn', html: '‹', title: '后退' }) as HTMLButtonElement - const fwdBtn = el('button', { class: 'fb-btn', html: '›', title: '前进' }) as HTMLButtonElement - const reloadBtn = el('button', { class: 'fb-btn', html: '⟳', title: '重新载入' }) - const addr = el('input', { class: 'text-input sf-addr', type: 'text', placeholder: '搜索或输入网站地址', spellcheck: 'false', 'aria-label': '地址栏' }) as HTMLInputElement - const progress = el('div', { class: 'sf-progress' }) - const toolbar = el('div', { class: 'fb-toolbar sf-toolbar' }, backBtn, fwdBtn, reloadBtn, el('div', { class: 'sf-addr-wrap' }, addr, progress)) - const page = el('div', { class: 'sf-page' }) - win.body.append(toolbar, page) - st.canBack = () => st.hi > 0; st.canFwd = () => st.hi < st.hist.length - 1 - const syncNav = () => { backBtn.disabled = !st.canBack(); fwdBtn.disabled = !st.canFwd() } - const setLoading = (on: boolean) => { - st.loading = on; progress.style.width = on ? '70%' : '100%'; progress.style.opacity = on ? '1' : '0' - if (on) setTimeout(() => { if (st.loading) { progress.style.transition = 'width 2s ease'; progress.style.width = '92%' } }, 60) - else { progress.style.transition = 'width .2s ease'; setTimeout(() => progress.style.width = '0', 240) } - } - const showStart = () => { - page.innerHTML = ''; WM.setTitle(win, '起始页 — Safari 浏览器'); addr.value = '' - const wrap = el('div', { class: 'sf-start' }, el('h2', { text: '个人收藏' })) - const grid = el('div', { class: 'sf-fav-grid' }) - favs.forEach((f: any) => { const c = el('div', { class: 'sf-fav' }, el('div', { class: 'sf-fav-ico', text: f.ico }), el('div', { class: 'sf-fav-name', text: f.name })); c.addEventListener('click', () => navigate(f.url)); grid.append(c) }) - wrap.append(grid, el('p', { class: 'sf-tip', text: '在地址栏输入 macos://weather 等内置地址,或输入网址查看离线回退页。' })) - page.append(wrap) - } - const showOffline = (url: string) => { - page.innerHTML = ''; WM.setTitle(win, '无法连接 — Safari 浏览器') - const engine = S.settings.searchEngine === 'baidu' ? '百度' : 'Bing' - page.append(el('div', { class: 'sf-offline' }, - el('div', { class: 'sf-offline-globe', text: '🌐' }), el('h3', { text: 'Safari 浏览器无法打开页面' }), - el('p', { text: `你的 Mac 当前处于离线环境,无法连接到"${url.replace(/^https?:\/\//, '').slice(0, 60)}"。` }), - el('div', { class: 'sf-offline-btns' }, el('button', { class: 'btn primary', text: '在新标签页打开', onclick: () => window.open(url, '_blank', 'noopener') }), el('button', { class: 'btn', text: '返回起始页', onclick: () => navigate('macos://start') })), - el('p', { class: 'sf-offline-sub', text: `当前搜索引擎:${engine}(可在 系统设置 › Safari 中修改)` }))) - } - const navigate = (url: string, push = true) => { - url = (url || '').trim(); if (!url) return showStart() - setLoading(true) - setTimeout(() => { setLoading(false) - if (push) { st.hist = st.hist.slice(0, st.hi + 1); st.hist.push(url); st.hi++ } - syncNav(); if (url === 'macos://start') return showStart() - const bp = (SafariApp as any).builtinPages[url] - if (bp) { page.innerHTML = ''; addr.value = url; WM.setTitle(win, bp.title); const box = el('div', { class: 'sf-builtin' }); try { bp.build(box) } catch (e) { box.append(el('p', { text: '页面加载失败。' })) }; page.append(box); return } - if (/^https?:\/\//i.test(url)) { addr.value = url; return showOffline(url) } - const q = encodeURIComponent(url) - const su = S.settings.searchEngine === 'baidu' ? `https://www.baidu.com/s?wd=${q}` : `https://www.bing.com/search?q=${q}` - addr.value = su; showOffline(su) - }, 380) - } - st.back = () => { if (st.canBack()) { st.hi--; navigate(st.hist[st.hi], false) } } - st.fwd = () => { if (st.canFwd()) { st.hi++; navigate(st.hist[st.hi], false) } } - st.reload = () => { const u = st.hist[st.hi] || 'macos://start'; navigate(u, false) } - backBtn.addEventListener('click', () => st.back()); fwdBtn.addEventListener('click', () => st.fwd()); reloadBtn.addEventListener('click', () => st.reload()) - addr.addEventListener('keydown', (e: KeyboardEvent) => { e.stopPropagation(); if (e.key === 'Enter') { let v = addr.value.trim(); if (/^[\w-]+(\.[\w-]+)+(\/.*)?$/.test(v) && !v.includes(' ')) v = 'https://' + v; navigate(v) } }) - navigate(args.url || 'macos://start', true) - } } Apps.register(SafariApp) diff --git a/tests/cases/27-safari.test.ts b/tests/cases/27-safari.test.ts new file mode 100644 index 0000000..bcf3f77 --- /dev/null +++ b/tests/cases/27-safari.test.ts @@ -0,0 +1,54 @@ +/** + * 27-safari: Safari Vue 组件化测试 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { h, render, nextTick } from 'vue' +import SafariComponent from '../../src/apps/safari/Safari.vue' +import { setupDOM } from '../helpers' + +function makeMockWin() { + const el = document.createElement('div'); el.className = 'window' + document.getElementById('window-layer')?.appendChild(el) + const body = document.createElement('div'); body.className = 'win-body' + el.appendChild(body) + return { id: 'sf', appId: 'safari', el, body, timers: [] as any[], data: {} } +} + +describe('27-safari — Safari Vue 组件化', () => { + let win: any + beforeEach(() => { setupDOM(); win = makeMockWin() }) + afterEach(() => { render(null, win.body); win.el.remove() }) + + function mount() { const v = h(SafariComponent, { win }); render(v, win.body) } + + it('挂载后渲染起始页', async () => { + mount(); await nextTick(); await nextTick() + // setTimeout 380ms 后导航到 start + await new Promise(r => setTimeout(r, 500)) + expect(win.body.querySelector('.sf-start')).toBeTruthy() + }) + + it('地址栏存在', () => { mount(); expect(win.body.querySelector('.sf-addr')).toBeTruthy() }) + + it('有后退/前进/刷新按钮', () => { + mount() + expect(win.body.querySelector('.fb-btn[title="后退"]')).toBeTruthy() + expect(win.body.querySelector('.fb-btn[title="前进"]')).toBeTruthy() + }) + + it('导航到内置页后显示内容', async () => { + win.data = { url: 'macos://weather' } + mount() + await new Promise(r => setTimeout(r, 500)) + expect(win.body.querySelector('.sf-builtin')).toBeTruthy() + }) + + it('暴露 back/fwd/reload 给菜单', () => { + mount() + expect(typeof win.appState.back).toBe('function') + expect(typeof win.appState.fwd).toBe('function') + expect(typeof win.appState.reload).toBe('function') + }) + + it('卸载后 DOM 为空', () => { mount(); render(null, win.body); expect(win.body.children.length).toBe(0) }) +})