feat: 重构 preview — Vue 组件化(画廊/图片/PDF/文本/缩放/旋转)+ 4 测试
This commit is contained in:
parent
f71a5ba4b5
commit
d210df0133
@ -1 +1,73 @@
|
|||||||
<template><div></div></template>
|
<template>
|
||||||
|
<div class="preview-body">
|
||||||
|
<div class="fb-toolbar preview-toolbar">
|
||||||
|
<button class="fb-btn" title="缩小" @click="zoom(-0.2)">−</button>
|
||||||
|
<button class="fb-btn" title="放大" @click="zoom(0.2)">+</button>
|
||||||
|
<button class="fb-btn" title="旋转" @click="rot = (rot + 90) % 360" v-html="'⟳'"></button>
|
||||||
|
<span class="preview-info">缩放 {{ Math.round(scale * 100) }}%{{ isPdf ? ' · 第 1 页,共 1 页' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-stage">
|
||||||
|
<!-- 画廊 -->
|
||||||
|
<div v-if="!filePath" class="preview-gallery">
|
||||||
|
<div v-for="w in wallpapers" :key="w.src" class="preview-cell" @click="openAsset(w)">
|
||||||
|
<img :src="w.src" :alt="w.name"><div class="photo-name">{{ w.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 图片 -->
|
||||||
|
<div v-else-if="isImage" class="preview-canvas">
|
||||||
|
<img v-if="!imgErr" class="preview-img" :src="filePath" :alt="fileName" :style="imgStyle" @error="imgErr = true">
|
||||||
|
<div v-else class="empty-state"><div class="es-icon">🚫</div><div>无法打开此文件</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- PDF -->
|
||||||
|
<div v-else-if="isPdf" class="preview-canvas">
|
||||||
|
<div class="preview-pdf" :style="imgStyle">
|
||||||
|
<div class="preview-pdf-badge">PDF</div>
|
||||||
|
<div class="preview-pdf-name">{{ fileName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 文本 -->
|
||||||
|
<div v-else class="preview-canvas">
|
||||||
|
<pre class="preview-text" :style="imgStyle">{{ textContent }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { clamp } from '../../utils'
|
||||||
|
import { fs } from '../../composables/useFS'
|
||||||
|
import { wm } from '../../composables/useWM'
|
||||||
|
import { Apps } from '../../composables/useApps'
|
||||||
|
import { WALLPAPERS } from '../../composables/useSettings'
|
||||||
|
|
||||||
|
const props = defineProps<{ win: any }>()
|
||||||
|
const scale = ref(1)
|
||||||
|
const rot = ref(0)
|
||||||
|
const filePath = ref<string | null>(null)
|
||||||
|
const fileName = ref('')
|
||||||
|
const imgErr = ref(false)
|
||||||
|
const textContent = ref('')
|
||||||
|
const wallpapers = WALLPAPERS.map(w => ({ src: w.src, name: w.name }))
|
||||||
|
|
||||||
|
const isImage = computed(() => filePath.value ? /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(filePath.value) : false)
|
||||||
|
const isPdf = computed(() => filePath.value?.toLowerCase().endsWith('.pdf'))
|
||||||
|
const imgStyle = computed(() => ({ transform: `scale(${scale.value}) rotate(${rot.value}deg)` }))
|
||||||
|
|
||||||
|
function zoom(d: number) { scale.value = clamp(scale.value + d, 0.2, 4) }
|
||||||
|
function openAsset(w: { src: string; name: string }) { Apps.open('preview', { asset: w.src, name: w.name }) }
|
||||||
|
|
||||||
|
props.win.appState = { zoom, zoomReset: () => { scale.value = 1; rot.value = 0 } }
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const args = props.win.data || {}
|
||||||
|
const path = args.asset || args.path || null
|
||||||
|
if (!path) { wm.setTitle(props.win, '图片集 — 预览'); return }
|
||||||
|
filePath.value = path
|
||||||
|
fileName.value = args.name || fs.baseName(path)
|
||||||
|
wm.setTitle(props.win, fileName.value + ' — 预览')
|
||||||
|
if (args.asset || isImage.value || isPdf.value) return
|
||||||
|
try { textContent.value = fs.read(path) } catch { textContent.value = '无法读取' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
|
// 预览应用 — Vue 组件化
|
||||||
import { h, render } from 'vue'
|
import { h, render } from 'vue'
|
||||||
// 预览应用 — 从 js/apps2.js 手动转写
|
|
||||||
import { el, iconImg, clamp } from '../../utils'
|
|
||||||
import { fs as FS } from '../../composables/useFS'
|
|
||||||
import { wm as WM } from '../../composables/useWM'
|
|
||||||
import { Apps, stdMenus } from '../../composables/useApps'
|
import { Apps, stdMenus } from '../../composables/useApps'
|
||||||
import PreviewComponent from './Preview.vue'
|
import PreviewComponent from './Preview.vue'
|
||||||
import { WALLPAPERS } from '../../composables/useSettings'
|
|
||||||
|
|
||||||
export const PreviewApp = {
|
export const PreviewApp = {
|
||||||
id: 'preview', name: '预览', icon: '/assets/icons/preview.svg',
|
id: 'preview', name: '预览', icon: '/assets/icons/preview.svg',
|
||||||
@ -20,50 +16,9 @@ export const PreviewApp = {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
render(win: any, args: any) {
|
render(win: any) {
|
||||||
const st = win.appState = { scale: 1, rot: 0, path: args.path || null, isPdf: false }
|
const vnode = h(PreviewComponent, { win })
|
||||||
win.body.classList.add('preview-body')
|
render(vnode, win.body)
|
||||||
const zOut = el('button', { class: 'fb-btn', text: '−', title: '缩小' })
|
},
|
||||||
const zIn = el('button', { class: 'fb-btn', text: '+', title: '放大' })
|
|
||||||
const rotBtn = el('button', { class: 'fb-btn', html: '⟳', title: '旋转' })
|
|
||||||
const infoEl = el('span', { class: 'preview-info' })
|
|
||||||
const toolbar = el('div', { class: 'fb-toolbar preview-toolbar' }, zOut, zIn, rotBtn, infoEl)
|
|
||||||
const stage = el('div', { class: 'preview-stage' })
|
|
||||||
win.body.append(toolbar, stage)
|
|
||||||
st.zoom = (d: number) => { st.scale = clamp(st.scale + d, 0.2, 4); apply() }
|
|
||||||
st.zoomReset = () => { st.scale = 1; st.rot = 0; apply() }
|
|
||||||
zIn.addEventListener('click', () => st.zoom(0.2))
|
|
||||||
zOut.addEventListener('click', () => st.zoom(-0.2))
|
|
||||||
rotBtn.addEventListener('click', () => { st.rot = (st.rot + 90) % 360; apply() })
|
|
||||||
let contentEl: HTMLElement | null = null
|
|
||||||
const apply = () => { if (contentEl) contentEl.style.transform = `scale(${st.scale}) rotate(${st.rot}deg)`; infoEl.textContent = `缩放 ${Math.round(st.scale * 100)}%` + (st.isPdf ? ' · 第 1 页,共 1 页' : '') }
|
|
||||||
const showError = (msg: string) => { stage.innerHTML = ''; stage.append(el('div', { class: 'empty-state' }, el('div', { class: 'es-icon', text: '🚫' }), el('div', { text: '无法打开此文件' }), el('div', { class: 'preview-err-sub', text: msg }))); infoEl.textContent = '' }
|
|
||||||
if (args.asset) st.path = args.asset
|
|
||||||
if (!st.path) {
|
|
||||||
const gallery = el('div', { class: 'preview-gallery' })
|
|
||||||
WALLPAPERS.forEach((w: any) => {
|
|
||||||
const im = iconImg(w.src, '', w.name)
|
|
||||||
const cell = el('div', { class: 'preview-cell' }, im, el('div', { class: 'photo-name', text: w.name }))
|
|
||||||
cell.addEventListener('click', () => { Apps.open('preview', { asset: w.src, name: w.name }) })
|
|
||||||
gallery.append(cell)
|
|
||||||
})
|
|
||||||
stage.append(gallery); infoEl.textContent = '点击查看大图'; WM.setTitle(win, '图片集 — 预览'); return
|
|
||||||
}
|
|
||||||
const asset = args.asset || null
|
|
||||||
const ext = (st.path.split('.').pop() || '').toLowerCase()
|
|
||||||
if (asset || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
|
|
||||||
const src = asset || st.path
|
|
||||||
contentEl = el('img', { class: 'preview-img', alt: args.name || FS.baseName(src) }) as HTMLElement
|
|
||||||
;(contentEl as any).addEventListener('error', () => showError('图片数据不可用(虚拟文件或资源缺失)。'))
|
|
||||||
;(contentEl as HTMLImageElement).src = src
|
|
||||||
stage.append(el('div', { class: 'preview-canvas' }, contentEl))
|
|
||||||
WM.setTitle(win, (args.name || FS.baseName(src)) + ' — 预览'); apply()
|
|
||||||
} else if (ext === 'pdf') {
|
|
||||||
contentEl = el('div', { class: 'preview-pdf' }, el('div', { class: 'preview-pdf-badge', text: 'PDF' }), el('div', { class: 'preview-pdf-name', text: FS.baseName(st.path) }), el('div', { class: 'preview-pdf-sub', text: '虚拟 PDF 文档 · 第 1 页,共 1 页' }))
|
|
||||||
st.isPdf = true; stage.append(el('div', { class: 'preview-canvas' }, contentEl)); apply()
|
|
||||||
} else {
|
|
||||||
try { const txt = FS.read(st.path); contentEl = el('pre', { class: 'preview-text', text: txt }); stage.append(el('div', { class: 'preview-canvas' }, contentEl)); apply() } catch (e: any) { showError(e.message) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Apps.register(PreviewApp)
|
Apps.register(PreviewApp)
|
||||||
|
|||||||
36
tests/cases/26-preview.test.ts
Normal file
36
tests/cases/26-preview.test.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 26-preview: Preview Vue 组件化测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { h, render, nextTick } from 'vue'
|
||||||
|
import PreviewComponent from '../../src/apps/preview/Preview.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: 'pv', appId: 'preview', el, body, timers: [] as any[], data: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('26-preview — Preview Vue 组件化', () => {
|
||||||
|
let win: any
|
||||||
|
beforeEach(() => { setupDOM(); win = makeMockWin() })
|
||||||
|
afterEach(() => { render(null, win.body); win.el.remove() })
|
||||||
|
|
||||||
|
function mount() { const v = h(PreviewComponent, { win }); render(v, win.body) }
|
||||||
|
|
||||||
|
it('挂载后渲染画廊', () => { mount(); expect(win.body.querySelector('.preview-gallery')).toBeTruthy() })
|
||||||
|
it('工具栏有缩放/旋转按钮', () => {
|
||||||
|
mount()
|
||||||
|
expect(win.body.querySelector('.fb-btn[title="放大"]')).toBeTruthy()
|
||||||
|
expect(win.body.querySelector('.fb-btn[title="缩小"]')).toBeTruthy()
|
||||||
|
})
|
||||||
|
it('暴露 zoom/zoomReset 给菜单', () => {
|
||||||
|
mount()
|
||||||
|
expect(typeof win.appState.zoom).toBe('function')
|
||||||
|
expect(typeof win.appState.zoomReset).toBe('function')
|
||||||
|
})
|
||||||
|
it('卸载后 DOM 为空', () => { mount(); render(null, win.body); expect(win.body.children.length).toBe(0) })
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user