fix: 修复图片压缩失败并支持前后放大预览

- 修复压缩全部失败的根因:useWebWorker 时库默认从 jsdelivr CDN
  importScripts 加载核心,离线/网络受限即报错;改用 ?url 打包本地
  资源并传 libURL,Worker 初始化失败时自动降级主线程压缩
- 压缩结果列表同时展示原图与压缩图缩略图,点击可经 t-image-viewer
  放大预览并可左右切换对比;压缩中显示进度占位
- agents.md 新增该 CDN Worker 坑位说明
This commit is contained in:
夏犀麟 2026-08-21 15:53:42 +08:00
parent 6e25de5c21
commit 85db6a3373
2 changed files with 78 additions and 8 deletions

View File

@ -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. 构建、验证与提交

View File

@ -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;