diff --git a/agents.md b/agents.md index 20f0ba8..6d45b3a 100644 --- a/agents.md +++ b/agents.md @@ -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. 构建、验证与提交 diff --git a/src/views/image-compress/ImageCompressView.vue b/src/views/image-compress/ImageCompressView.vue index be791cb..dbfd84e 100644 --- a/src/views/image-compress/ImageCompressView.vue +++ b/src/views/image-compress/ImageCompressView.vue @@ -65,16 +65,31 @@

共 {{ results.length }} 张:原始 {{ formatBytes(totalSource) }} → 压缩后 {{ formatBytes(totalOut) }}, - 总节省 {{ totalSaved }}% + 总节省 {{ totalSaved }}%(点击缩略图可放大预览)

- +
+ 原图 + + 压缩图 + {{ busy ? '压缩中…' : '失败' }} +

{{ r.name }}

- {{ formatBytes(r.sourceSize) }} → {{ formatBytes(r.outSize) }} - 节省 {{ r.savedPct }}% + {{ formatBytes(r.sourceSize) }} → + + + + 节省 {{ r.savedPct }}%

{{ r.error }}

@@ -93,6 +108,15 @@
+ + + 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([]) +/** 放大预览状态:展示「原图 + 压缩图」,从点击的那张开始 */ +const viewerVisible = ref(false) +const viewerImages = ref([]) +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;