feat: 重构 launchpad — Vue overlay + 14 测试

This commit is contained in:
李岩岩 2026-07-23 10:23:23 +08:00
parent ee75d0d784
commit 0a2ca982bd
3 changed files with 231 additions and 36 deletions

View File

@ -1 +1,74 @@
<template><div></div></template> <template>
<div class="launchpad" tabindex="0" @keydown.escape="hide" @click.self="hide">
<div class="lp-search-wrap">
<input
ref="searchEl"
class="lp-search"
type="search"
placeholder="搜索 App"
v-model="query"
@input="onSearch"
@keydown.stop.escape="hide"
>
</div>
<div class="lp-grid">
<div v-if="!filteredApps.length" class="lp-empty">没有匹配的 App</div>
<div
v-for="app in filteredApps" :key="app.id"
class="lp-cell"
@click="openApp(app.id)"
>
<img :src="app.icon" alt="" @error="onIconError($event)">
<div class="lp-name">{{ app.name }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { Apps } from '../../composables/useApps'
const emit = defineEmits<{ hide: [] }>()
const query = ref('')
const searchEl = ref<HTMLInputElement>()
//
const allApps = computed(() => {
const appStoreApp = (window as any).__launchpadAppStore
return Object.values(Apps.registry)
.filter((a: any) => a.id !== 'launchpad')
.filter((a: any) => !a.storeApp || (appStoreApp && appStoreApp.isInstalled(a.id)))
.sort((a: any, b: any) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
})
const filteredApps = computed(() => {
if (!query.value) return allApps.value
const q = query.value.toLowerCase()
return allApps.value.filter(a => a.name.toLowerCase().includes(q))
})
function onSearch() { /* query 已绑定 */ }
function openApp(id: string) {
hide()
Apps.open(id)
}
function hide() { emit('hide') }
function onIconError(e: Event) {
(e.target as HTMLImageElement).src = '/assets/icons/appstore.png'
}
// Launchpad
defineExpose({ query, searchEl })
onMounted(() => {
nextTick(() => {
searchEl.value?.focus()
})
})
</script>

View File

@ -1,56 +1,43 @@
// 启动台 — Vue 组件化 overlay
import { h, render } from 'vue' import { h, render } from 'vue'
// 启动台应用 — 从 js/apps3.js 手动转写
import { el, iconImg } from '../../utils'
import { Apps, stdMenus } from '../../composables/useApps' import { Apps, stdMenus } from '../../composables/useApps'
import LaunchpadComponent from './Launchpad.vue' import LaunchpadComponent from './Launchpad.vue'
import { ui as UI } from '../../composables/useUI' import { ui } from '../../composables/useUI'
let _AppStoreApp: any = null let _AppStoreApp: any = null
export function setAppStoreForLaunchpad(a: any) { _AppStoreApp = a } export function setAppStoreForLaunchpad(a: any) { _AppStoreApp = a }
export const Launchpad = { export const Launchpad = {
overlay: null as HTMLElement | null, overlay: null as HTMLElement | null,
vnode: null as any,
show() { show() {
if (this.overlay) return this.hide() if (this.overlay) return this.hide()
const ov = el('div', { class: 'launchpad', tabindex: '0' }) const container = document.createElement('div')
const search = el('input', { class: 'lp-search', type: 'search', placeholder: '搜索 App' }) as HTMLInputElement this.vnode = h(LaunchpadComponent, {
const grid = el('div', { class: 'lp-grid' }) onHide: () => this.hide(),
ov.append(el('div', { class: 'lp-search-wrap' }, search), grid) })
const renderGrid = (q: string) => { render(this.vnode, container)
grid.innerHTML = '' document.body.append(container)
const apps = Object.values(Apps.registry) this.overlay = container
.filter((a: any) => a.id !== 'launchpad' && (!a.storeApp || (_AppStoreApp && _AppStoreApp.isInstalled(a.id)))) setTimeout(() => { container.firstElementChild?.classList.add('show') }, 10)
.filter((a: any) => !q || a.name.toLowerCase().includes(q.toLowerCase()))
if (!apps.length) grid.append(el('div', { class: 'lp-empty', text: '没有匹配的 App' }))
;(apps as any[]).sort((a: any, b: any) => a.name.localeCompare(b.name, 'zh-Hans-CN')).forEach((app: any) => {
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: KeyboardEvent) => { e.stopPropagation(); if (e.key === 'Escape') this.hide() })
ov.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') this.hide() })
ov.addEventListener('click', (e: Event) => { if (e.target === ov) this.hide() })
document.body.append(ov)
this.overlay = ov
setTimeout(() => { ov.classList.add('show'); search.focus() }, 10)
}, },
hide() { hide() {
if (!this.overlay) return if (!this.overlay) return
const ov = this.overlay; this.overlay = null const ov = this.overlay; this.overlay = null
ov.classList.remove('show'); setTimeout(() => ov.remove(), 250) ov.firstElementChild?.classList.remove('show')
} setTimeout(() => { render(null, ov); ov.remove() }, 250)
}; },
(window as any).Launchpad = Launchpad }
;(window as any).Launchpad = Launchpad
// 注册启动台应用 // 注册启动台
Apps.register({ Apps.register({
id: 'launchpad', name: '启动台', icon: '/assets/icons/launchpad.png', id: 'launchpad', name: '启动台', icon: '/assets/icons/launchpad.png',
menus() { return stdMenus(this) }, menus() { return stdMenus(this) },
render() { /* 不走 WM 窗口 */ }, render() {},
open() { Launchpad.show() } open() { Launchpad.show() },
}) })
// 扩展 Apps.open拦截 launchpad/stickies/storeApp // 扩展 Apps.open拦截 launchpad/stickies/storeApp
@ -60,7 +47,7 @@ Apps.open = function (id: string, args?: any) {
if (id === 'stickies' && !(args && args.noteId)) { (Apps.get('stickies') as any)?.openAll(); return null } if (id === 'stickies' && !(args && args.noteId)) { (Apps.get('stickies') as any)?.openAll(); return null }
const app = this.get(id) const app = this.get(id)
if (app && app.storeApp && !(_AppStoreApp && _AppStoreApp.isInstalled(id))) { if (app && app.storeApp && !(_AppStoreApp && _AppStoreApp.isInstalled(id))) {
UI.alert('尚未安装', `请先在 App Store 中获取"${app.name}"。`, '/assets/icons/appstore.png') ui.alert('尚未安装', `请先在 App Store 中获取"${app.name}"。`, '/assets/icons/appstore.png')
return null return null
} }
return _origOpen(id, args) return _origOpen(id, args)

View File

@ -0,0 +1,135 @@
/**
* 28-launchpad: Launchpad Vue
* 覆盖: show/hideEscape/storeApp
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { setupDOM, bootSystem } from '../helpers'
import { Launchpad } from '../../src/apps/launchpad/index'
import { Apps } from '../../src/composables/useApps'
describe('28-launchpad — Vue 组件化', () => {
beforeEach(async () => {
localStorage.clear()
await bootSystem()
})
afterEach(() => { Launchpad.hide() })
// ==================== show/hide ====================
describe('show/hide', () => {
it('show() 创建 overlay', () => {
Launchpad.show()
expect(document.querySelector('.launchpad')).toBeTruthy()
})
it('第二次 show() 先 hide 再 show不重复', () => {
Launchpad.show()
Launchpad.show()
expect(document.querySelectorAll('.launchpad').length).toBe(1)
})
it('hide() 移除 overlay250ms 后)', async () => {
Launchpad.show()
Launchpad.hide()
await new Promise(r => setTimeout(r, 300))
expect(document.querySelector('.launchpad')).toBeNull()
})
})
// ==================== 结构 ====================
describe('结构', () => {
it('有搜索框', () => {
Launchpad.show()
expect(document.querySelector('.lp-search')).toBeTruthy()
})
it('有应用网格', () => {
Launchpad.show()
expect(document.querySelector('.lp-grid')).toBeTruthy()
expect(document.querySelectorAll('.lp-cell').length).toBeGreaterThan(0)
})
it('应用按名称排序', () => {
Launchpad.show()
const names = [...document.querySelectorAll('.lp-name')].map(e => e.textContent || '')
const sorted = [...names].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
expect(names).toEqual(sorted)
})
it('不包含自身launchpad', () => {
Launchpad.show()
const names = [...document.querySelectorAll('.lp-name')].map(e => e.textContent)
expect(names).not.toContain('启动台')
})
})
// ==================== 搜索 ====================
describe('搜索', () => {
it('输入关键字过滤应用', async () => {
Launchpad.show()
const input = document.querySelector('.lp-search') as HTMLInputElement
input.value = '终端'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const names = [...document.querySelectorAll('.lp-name')].map(e => e.textContent)
expect(names.length).toBeGreaterThanOrEqual(1)
expect(names.every(n => n!.includes('终端'))).toBe(true)
})
it('无匹配显示"没有匹配的 App"', async () => {
Launchpad.show()
const input = document.querySelector('.lp-search') as HTMLInputElement
input.value = 'zzzzz_no_match'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(document.querySelector('.lp-empty')?.textContent).toContain('没有匹配')
})
it('清除搜索恢复全量', async () => {
Launchpad.show()
const input = document.querySelector('.lp-search') as HTMLInputElement
input.value = '终端'; input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const filtered = document.querySelectorAll('.lp-cell').length
input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(document.querySelectorAll('.lp-cell').length).toBeGreaterThan(filtered)
})
})
// ==================== 关闭 ====================
describe('关闭', () => {
it('Escape 键关闭', async () => {
Launchpad.show()
const lp = document.querySelector('.launchpad') as HTMLElement
lp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await new Promise(r => setTimeout(r, 300))
expect(document.querySelector('.launchpad')).toBeNull()
})
it('点击背景关闭', async () => {
Launchpad.show()
const lp = document.querySelector('.launchpad') as HTMLElement
lp.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await new Promise(r => setTimeout(r, 300))
expect(document.querySelector('.launchpad')).toBeNull()
})
it('点击应用单元格关闭并打开应用', async () => {
Launchpad.show()
const cell = document.querySelector('.lp-cell') as HTMLElement
cell.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await new Promise(r => setTimeout(r, 300))
expect(document.querySelector('.launchpad')).toBeNull()
})
})
// ==================== Apps.open 拦截 ====================
describe('Apps.open 拦截', () => {
it('open("launchpad") 调用 show', () => {
const result = Apps.open('launchpad')
expect(document.querySelector('.launchpad')).toBeTruthy()
expect(result).toBeNull()
})
})
})