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