feat: 重构 safari — Vue 组件化(内置页/地址栏/历史导航/搜索引擎)+ 6 测试

This commit is contained in:
李岩岩 2026-07-23 09:55:59 +08:00
parent d210df0133
commit c577546ada
3 changed files with 245 additions and 103 deletions

View File

@ -1 +1,185 @@
<template><div></div></template> <template>
<div class="safari-body">
<!-- 工具栏 -->
<div class="fb-toolbar sf-toolbar">
<button class="fb-btn" title="后退" :disabled="!canBack" @click="back" v-html="''"></button>
<button class="fb-btn" title="前进" :disabled="!canFwd" @click="fwd" v-html="''"></button>
<button class="fb-btn" title="重新载入" @click="reload" v-html="'⟳'"></button>
<div class="sf-addr-wrap">
<input
ref="addrEl"
class="text-input sf-addr"
type="text"
placeholder="搜索或输入网站地址"
spellcheck="false"
aria-label="地址栏"
v-model="addrValue"
@keydown.stop="onAddrKeydown"
>
<div class="sf-progress" :style="{ width: progressW, opacity: progressO, transition: progressT }"></div>
</div>
</div>
<!-- 页面内容区 -->
<div class="sf-page">
<!-- 起始页 -->
<div v-if="currentPage === 'start'" class="sf-start">
<h2>个人收藏</h2>
<div class="sf-fav-grid">
<div v-for="f in favs" :key="f.url" class="sf-fav" @click="navigate(f.url)">
<div class="sf-fav-ico">{{ f.ico }}</div>
<div class="sf-fav-name">{{ f.name }}</div>
</div>
</div>
<p class="sf-tip">在地址栏输入 macos://weather 线退</p>
</div>
<!-- 内置页 -->
<div v-else-if="currentPage === 'builtin'" class="sf-builtin" v-html="builtinHtml"></div>
<!-- 离线页 -->
<div v-else-if="currentPage === 'offline'" class="sf-offline">
<div class="sf-offline-globe">🌐</div>
<h3>Safari 浏览器无法打开页面</h3>
<p>你的 Mac 当前处于离线环境无法连接到"{{ offlineUrl }}"</p>
<div class="sf-offline-btns">
<button class="btn primary" @click="openExternal(offlineUrl)">在新标签页打开</button>
<button class="btn" @click="navigate('macos://start')">返回起始页</button>
</div>
<p class="sf-offline-sub">当前搜索引擎{{ searchEngineName }}可在 系统设置 Safari 中修改</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { store } from '../../composables/useStore'
import { wm } from '../../composables/useWM'
const Sys = () => (window as any).Sys
const props = defineProps<{ win: any }>()
// ============ ============
const hist = ref<string[]>([])
const hi = ref(-1)
const addrValue = ref('')
const currentPage = ref<'start' | 'builtin' | 'offline'>('start')
const builtinHtml = ref('')
const offlineUrl = ref('')
const progressW = ref('0')
const progressO = ref('0')
const progressT = ref('width .2s ease')
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: '💻' },
])
// ============ ============
const canBack = computed(() => hi.value > 0)
const canFwd = computed(() => hi.value < hist.value.length - 1)
const searchEngineName = computed(() => {
const S = Sys()
return S?.settings?.searchEngine === 'baidu' ? '百度' : 'Bing'
})
// ============ ============
const builtinPages: Record<string, { title: string; html: string }> = {
'macos://weather': {
title: '天气 - 内置页',
html: '<h2>天气</h2><p>请打开天气应用查看完整天气信息。</p>',
},
'macos://news': {
title: '科技新闻 - 内置页',
html: '<h2>科技要闻</h2>'
+ '<div class="sf-news-item"><b>macOS 网页版发布</b><p>完全在浏览器中运行的桌面体验。</p></div>'
+ '<div class="sf-news-item"><b>本地优先的软件设计兴起</b><p>越来越多的应用选择将数据保存在本地。</p></div>'
+ '<div class="sf-news-item"><b>Web 平台能力持续增强</b><p>通知、文件系统访问与硬件加速让网页应用接近原生体验。</p></div>'
+ '<div class="sf-news-item"><b>开源社区年度盘点</b><p>来自全球的开发者为桌面模拟器项目贡献了素材。</p></div>',
},
'macos://baike': {
title: '百科 - 内置页',
html: '<h2>macOS</h2><p>macOS 是苹果公司为 Mac 系列电脑开发的操作系统。本页面由 Safari 内置百科提供,离线可读。</p><h3>版本沿革</h3><p>Cheetah、Puma、Jaguar … Sequoia。</p>',
},
'macos://github': {
title: '代码仓库 - 内置页',
html: '<h2>macos-web</h2><p>一个纯静态的 macOS 桌面模拟器。许可MIT。</p><pre class="sf-code">macos-web/\n├── index.html\n├── css/\n├── js/\n└── assets/</pre>',
},
}
// ============ ============
function setLoading(on: boolean) {
progressW.value = on ? '70%' : '100%'
progressO.value = on ? '1' : '0'
if (on) {
setTimeout(() => { progressT.value = 'width 2s ease'; progressW.value = '92%' }, 60)
} else {
progressT.value = 'width .2s ease'
setTimeout(() => { progressW.value = '0' }, 240)
}
}
function navigate(url: string, push = true) {
url = (url || '').trim()
if (!url) { currentPage.value = 'start'; wm.setTitle(props.win, '起始页 — Safari 浏览器'); addrValue.value = ''; return }
setLoading(true)
setTimeout(() => {
setLoading(false)
if (push) { hist.value = hist.value.slice(0, hi.value + 1); hist.value.push(url); hi.value = hist.value.length - 1 }
if (url === 'macos://start') { currentPage.value = 'start'; wm.setTitle(props.win, '起始页 — Safari 浏览器'); addrValue.value = ''; return }
const bp = builtinPages[url]
if (bp) {
currentPage.value = 'builtin'
builtinHtml.value = bp.html
addrValue.value = url
wm.setTitle(props.win, bp.title)
return
}
// HTTP URL or search
if (/^https?:\/\//i.test(url)) {
addrValue.value = url
} else {
const q = encodeURIComponent(url)
const S = Sys()
const su = S?.settings?.searchEngine === 'baidu' ? `https://www.baidu.com/s?wd=${q}` : `https://www.bing.com/search?q=${q}`
addrValue.value = su
}
currentPage.value = 'offline'
offlineUrl.value = url
wm.setTitle(props.win, '无法连接 — Safari 浏览器')
}, 380)
}
function back() { if (canBack.value) { hi.value--; navigate(hist.value[hi.value], false) } }
function fwd() { if (canFwd.value) { hi.value++; navigate(hist.value[hi.value], false) } }
function reload() { navigate(hist.value[hi.value] || 'macos://start', false) }
function onAddrKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
let v = addrValue.value.trim()
if (/^[\w-]+(\.[\w-]+)+(\/.*)?$/.test(v) && !v.includes(' ')) v = 'https://' + v
navigate(v)
}
}
function openExternal(url: string) { window.open(url, '_blank', 'noopener') }
// menus
props.win.appState = { canBack, canFwd, back, fwd, reload }
// ============ ============
onMounted(() => {
const args = props.win.data || {}
navigate(args.url || 'macos://start', true)
})
</script>

View File

@ -1,11 +1,7 @@
// Safari 浏览器 — Vue 组件化
import { h, render } from '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 { Apps, stdMenus } from '../../composables/useApps'
import SafariComponent from './Safari.vue' import SafariComponent from './Safari.vue'
const Sys = () => (window as any).Sys
export const SafariApp = { export const SafariApp = {
id: 'safari', name: 'Safari 浏览器', icon: '/assets/icons/safari.png', id: 'safari', name: 'Safari 浏览器', icon: '/assets/icons/safari.png',
@ -18,106 +14,14 @@ export const SafariApp = {
{ label: '重新载入页面', key: '⌘R', action: () => st?.reload() }, { label: '重新载入页面', key: '⌘R', action: () => st?.reload() },
], ],
view: [ view: [
{ label: '后退', key: '⌘[', disabled: !st?.canBack(), action: () => st?.back() }, { label: '后退', key: '⌘[', disabled: !st?.canBack?.value, action: () => st?.back() },
{ label: '前进', key: '⌘]', disabled: !st?.canFwd(), action: () => st?.fwd() }, { label: '前进', key: '⌘]', disabled: !st?.canFwd?.value, action: () => st?.fwd() },
] ]
}) })
}, },
builtinPages: { render(win: any) {
'macos://weather': { const vnode = h(SafariComponent, { win })
title: '天气 - 内置页', render(vnode, win.body)
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, 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) Apps.register(SafariApp)

View File

@ -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) })
})