fix: 修复图片压缩失败并支持前后放大预览
- 修复压缩全部失败的根因:useWebWorker 时库默认从 jsdelivr CDN importScripts 加载核心,离线/网络受限即报错;改用 ?url 打包本地 资源并传 libURL,Worker 初始化失败时自动降级主线程压缩 - 压缩结果列表同时展示原图与压缩图缩略图,点击可经 t-image-viewer 放大预览并可左右切换对比;压缩中显示进度占位 - agents.md 新增该 CDN Worker 坑位说明
This commit is contained in:
parent
6e25de5c21
commit
85db6a3373
@ -88,7 +88,7 @@ export interface ToolItem {
|
||||
|
||||
| 工具 | 目录 | 核心依赖 / 实现 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| 图片压缩 | `views/image-compress` | `browser-image-compression` + `jszip` | 支持批量(最多 20 张);「按质量 / 限制大小」双模式(大小模式传 `maxSizeMB` + 迭代);`maxWidthOrHeight` 等比缩放;输出格式可选保持原格式/JPG/PNG/WebP;转 JPG 时透明图先垫白底(canvas 重绘)避免变黑;批量下载用 jszip 打包;WebP 编码部分 Safari 不支持,已做 try/catch 逐张降级提示 |
|
||||
| 图片压缩 | `views/image-compress` | `browser-image-compression` + `jszip` | 支持批量(最多 20 张);「按质量 / 限制大小」双模式(大小模式传 `maxSizeMB` + 迭代);`maxWidthOrHeight` 等比缩放;输出格式可选保持原格式/JPG/PNG/WebP;转 JPG 时透明图先垫白底(canvas 重绘)避免变黑;批量下载用 jszip 打包;WebWorker 核心库经 `?url` 打包并传 `libURL`(默认走 CDN 会失败),失败自动降级主线程;结果列表原图/压缩图可点击放大预览(`t-image-viewer`);WebP 编码部分 Safari 不支持,已做 try/catch 逐张降级提示 |
|
||||
| 图片裁剪 | `views/image-cropper` | `cropperjs`(含 `cropperjs/dist/cropper.css`) | 实例在 `onBeforeUnmount` 必须 `destroy()`;换图时先 destroy 再重建 |
|
||||
| 二维码 | `views/qrcode` | `qrcode`(生成)+ `jsqr`(解析) | 解析需先把图片绘制到 canvas 取 `ImageData` |
|
||||
| JSON 格式化 | `views/json-format` | 原生 `JSON.parse/stringify` | 缩进支持 2/4 空格/Tab |
|
||||
@ -142,6 +142,7 @@ import TesseractCore from 'tesseract.js-core/tesseract-core.wasm.js?url'
|
||||
9. **PNG 无损**:canvas 的 PNG 编码忽略 quality 参数,`browser-image-compression` 对 PNG 只能靠迭代降质量(无效);「限制大小」模式对 PNG 基本不生效,UI 已提示改用 JPG/WebP。
|
||||
10. **透明图转 JPG 变黑**:浏览器 canvas 把透明区编码为 JPG 时填黑色;必须先画白底再压缩(`flattenToWhite` 顺序不能反,先压缩透明区已被填黑不可恢复)。
|
||||
11. **历史文本解析**:图片压缩/裁剪的历史按结构化文本保存(如 `q=0.8 | type=jpeg | max=1920`),用正则 `q=([\d.]+)` 等解析;不要用 `\| jpg` 这类含空分支的正则(`/| jpg/` 恒为 true,是历史 bug)。
|
||||
12. **browser-image-compression 的 WebWorker 依赖 CDN**:`useWebWorker: true` 时库会创建 Blob Worker 并 `importScripts(libURL)`,默认 `libURL` 指向 jsdelivr CDN——离线或网络受限时全部压缩报错(「压缩不成功」最常见原因)。必须 `import ... from 'browser-image-compression/dist/browser-image-compression.js?url'` 打包本地资源并传 `libURL`,同时 catch 后降级 `useWebWorker: false` 重试。
|
||||
|
||||
## 8. 构建、验证与提交
|
||||
|
||||
|
||||
@ -65,16 +65,31 @@
|
||||
<t-card v-if="results.length" class="tool-section" title="压缩结果">
|
||||
<p class="summary">
|
||||
共 {{ results.length }} 张:原始 {{ formatBytes(totalSource) }} → 压缩后 {{ formatBytes(totalOut) }},
|
||||
总节省 <span class="saved">{{ totalSaved }}%</span>
|
||||
总节省 <span class="saved">{{ totalSaved }}%</span>(点击缩略图可放大预览)
|
||||
</p>
|
||||
<div class="result-list">
|
||||
<div v-for="(r, i) in results" :key="`${r.name}-${i}`" class="result-item">
|
||||
<img :src="r.outUrl || r.sourceUrl" alt="" class="result-thumb" />
|
||||
<div class="thumbs">
|
||||
<img :src="r.sourceUrl" alt="原图" class="result-thumb" title="点击放大原图" @click="openPreview(r, 0)" />
|
||||
<span class="arrow">→</span>
|
||||
<img
|
||||
v-if="r.outUrl"
|
||||
:src="r.outUrl"
|
||||
alt="压缩图"
|
||||
class="result-thumb"
|
||||
title="点击放大压缩图"
|
||||
@click="openPreview(r, 1)"
|
||||
/>
|
||||
<span v-else class="thumb-failed">{{ busy ? '压缩中…' : '失败' }}</span>
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<p class="result-name" :title="r.name">{{ r.name }}</p>
|
||||
<p class="result-size">
|
||||
{{ formatBytes(r.sourceSize) }} → {{ formatBytes(r.outSize) }}
|
||||
<span v-if="!r.error" class="saved">节省 {{ r.savedPct }}%</span>
|
||||
{{ formatBytes(r.sourceSize) }} →
|
||||
<template v-if="r.outUrl">{{ formatBytes(r.outSize) }}</template>
|
||||
<template v-else-if="r.error">—</template>
|
||||
<template v-else>压缩中…</template>
|
||||
<span v-if="r.outUrl" class="saved">节省 {{ r.savedPct }}%</span>
|
||||
</p>
|
||||
<p v-if="r.error" class="result-error">{{ r.error }}</p>
|
||||
</div>
|
||||
@ -93,6 +108,15 @@
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 原图 / 压缩图放大预览(v-if 保证每次打开都从 defaultIndex 开始) -->
|
||||
<t-image-viewer
|
||||
v-if="viewerVisible"
|
||||
v-model="viewerVisible"
|
||||
:images="viewerImages"
|
||||
:default-index="viewerIndex"
|
||||
:close-on-overlay="true"
|
||||
/>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@ -105,6 +129,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
// WebWorker 模式下核心库通过 ?url 打进产物(默认 libURL 指向 jsdelivr CDN,
|
||||
// 离线或网络受限时 importScripts 会失败导致压缩全部报错,是「压缩不成功」的常见原因)
|
||||
import imageCompressionLibUrl from 'browser-image-compression/dist/browser-image-compression.js?url'
|
||||
import JSZip from 'jszip'
|
||||
import UploadDrop from '@/components/common/UploadDrop.vue'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
@ -161,6 +188,11 @@ const error = ref('')
|
||||
const doneCount = ref(0)
|
||||
const results = ref<CompressResult[]>([])
|
||||
|
||||
/** 放大预览状态:展示「原图 + 压缩图」,从点击的那张开始 */
|
||||
const viewerVisible = ref(false)
|
||||
const viewerImages = ref<string[]>([])
|
||||
const viewerIndex = ref(0)
|
||||
|
||||
/** 历史记录:保存「张数 | 压缩方式 | 格式 | 尺寸」摘要,点击可恢复压缩参数 */
|
||||
const history = useHistory('image-compress')
|
||||
|
||||
@ -248,17 +280,24 @@ async function compressOne(file: File) {
|
||||
if (whiteBg.value && mime === 'image/jpeg' && mayHaveAlpha(file)) {
|
||||
source = await flattenToWhite(file)
|
||||
}
|
||||
const out = await imageCompression(source, {
|
||||
const baseOptions = {
|
||||
// 质量模式:仅应用质量;大小模式:迭代逼近目标大小(初始质量取 0.9 留足下调空间)
|
||||
initialQuality: mode.value === 'quality' ? quality.value : 0.9,
|
||||
maxSizeMB: mode.value === 'size' ? targetKb.value / 1024 : Number.POSITIVE_INFINITY,
|
||||
maxIteration: 10,
|
||||
// 超过目标尺寸时等比缩小;不传 0,否则按 0 处理
|
||||
...(maxDim.value > 0 ? { maxWidthOrHeight: maxDim.value } : {}),
|
||||
useWebWorker: true,
|
||||
fileType: mime,
|
||||
alwaysKeepResolution: true,
|
||||
})
|
||||
}
|
||||
let out: File
|
||||
try {
|
||||
// 默认走 WebWorker 不阻塞页面,核心库指向本地打包产物(不走 CDN)
|
||||
out = await imageCompression(source, { ...baseOptions, useWebWorker: true, libURL: imageCompressionLibUrl })
|
||||
} catch {
|
||||
// Worker 初始化失败(如 importScripts 受限)时降级为主线程压缩,保证功能可用
|
||||
out = await imageCompression(source, { ...baseOptions, useWebWorker: false })
|
||||
}
|
||||
result.outFile = out
|
||||
result.outSize = out.size
|
||||
result.outUrl = URL.createObjectURL(out)
|
||||
@ -300,6 +339,13 @@ function downloadOne(r: CompressResult) {
|
||||
if (r.outFile) downloadBlob(r.outFile, r.outName)
|
||||
}
|
||||
|
||||
/** 放大预览:优先展示「原图 + 压缩图」两张并可左右切换,失败时只有原图 */
|
||||
function openPreview(r: CompressResult, index: number) {
|
||||
viewerImages.value = r.outUrl ? [r.sourceUrl, r.outUrl] : [r.sourceUrl]
|
||||
viewerIndex.value = r.outUrl ? index : 0
|
||||
viewerVisible.value = true
|
||||
}
|
||||
|
||||
/** 全部结果打包为 ZIP 下载(jszip,纯本地生成) */
|
||||
async function downloadAll() {
|
||||
const ok = results.value.filter((r) => r.outFile)
|
||||
@ -425,6 +471,16 @@ onBeforeUnmount(() => {
|
||||
border-radius: 8px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
.thumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
.result-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@ -432,6 +488,19 @@ onBeforeUnmount(() => {
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.thumb-failed {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--td-error-color);
|
||||
border: 1px dashed var(--td-border-level-2-color);
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
.result-info {
|
||||
flex: 1;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user