diff --git a/src/apps/launchpad/Launchpad.vue b/src/apps/launchpad/Launchpad.vue
index 0ada1d8..eb07b6e 100644
--- a/src/apps/launchpad/Launchpad.vue
+++ b/src/apps/launchpad/Launchpad.vue
@@ -1 +1,74 @@
-
+
+
+
+
+
+
+
没有匹配的 App
+
+
![]()
+
{{ app.name }}
+
+
+
+
+
+
+
diff --git a/src/apps/launchpad/index.ts b/src/apps/launchpad/index.ts
index 7dd303d..eb10c79 100644
--- a/src/apps/launchpad/index.ts
+++ b/src/apps/launchpad/index.ts
@@ -1,56 +1,43 @@
+// 启动台 — Vue 组件化 overlay
import { h, render } from 'vue'
-// 启动台应用 — 从 js/apps3.js 手动转写
-import { el, iconImg } from '../../utils'
import { Apps, stdMenus } from '../../composables/useApps'
import LaunchpadComponent from './Launchpad.vue'
-import { ui as UI } from '../../composables/useUI'
+import { ui } from '../../composables/useUI'
let _AppStoreApp: any = null
export function setAppStoreForLaunchpad(a: any) { _AppStoreApp = a }
export const Launchpad = {
overlay: null as HTMLElement | null,
+ vnode: null as any,
+
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' }) as HTMLInputElement
- const grid = el('div', { class: 'lp-grid' })
- ov.append(el('div', { class: 'lp-search-wrap' }, search), grid)
- const renderGrid = (q: string) => {
- grid.innerHTML = ''
- const apps = Object.values(Apps.registry)
- .filter((a: any) => a.id !== 'launchpad' && (!a.storeApp || (_AppStoreApp && _AppStoreApp.isInstalled(a.id))))
- .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)
+ const container = document.createElement('div')
+ this.vnode = h(LaunchpadComponent, {
+ onHide: () => this.hide(),
+ })
+ render(this.vnode, container)
+ document.body.append(container)
+ this.overlay = container
+ setTimeout(() => { container.firstElementChild?.classList.add('show') }, 10)
},
+
hide() {
if (!this.overlay) return
const ov = this.overlay; this.overlay = null
- ov.classList.remove('show'); setTimeout(() => ov.remove(), 250)
- }
-};
-(window as any).Launchpad = Launchpad
+ ov.firstElementChild?.classList.remove('show')
+ setTimeout(() => { render(null, ov); ov.remove() }, 250)
+ },
+}
+;(window as any).Launchpad = Launchpad
-// 注册启动台应用
+// 注册启动台
Apps.register({
id: 'launchpad', name: '启动台', icon: '/assets/icons/launchpad.png',
menus() { return stdMenus(this) },
- render() { /* 不走 WM 窗口 */ },
- open() { Launchpad.show() }
+ render() {},
+ open() { Launchpad.show() },
})
// 扩展 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 }
const app = this.get(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 _origOpen(id, args)
diff --git a/tests/cases/28-launchpad.test.ts b/tests/cases/28-launchpad.test.ts
new file mode 100644
index 0000000..b68f1f4
--- /dev/null
+++ b/tests/cases/28-launchpad.test.ts
@@ -0,0 +1,135 @@
+/**
+ * 28-launchpad: Launchpad Vue 组件化测试
+ * 覆盖: show/hide、搜索过滤、网格排序、Escape/背景关闭、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() 移除 overlay(250ms 后)', 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()
+ })
+ })
+})