feat: 重构 launchpad — Vue overlay + 14 测试
This commit is contained in:
parent
ee75d0d784
commit
0a2ca982bd
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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) => {
|
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
}
|
render(this.vnode, container)
|
||||||
renderGrid('')
|
document.body.append(container)
|
||||||
search.addEventListener('input', () => renderGrid(search.value.trim()))
|
this.overlay = container
|
||||||
search.addEventListener('keydown', (e: KeyboardEvent) => { e.stopPropagation(); if (e.key === 'Escape') this.hide() })
|
setTimeout(() => { container.firstElementChild?.classList.add('show') }, 10)
|
||||||
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)
|
||||||
|
|||||||
135
tests/cases/28-launchpad.test.ts
Normal file
135
tests/cases/28-launchpad.test.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user