tool.xpcool.com/src/views/image-compress/ImageCompressView.vue
夏犀麟 dd38aeaf38
All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 1m10s
feat(theme): 改版为清晰蓝卡片工作台并接管 UI 库尺度
主色青玉改宝石蓝,字号整体放大一档(正文 16px),
通过 --td-comp-* 与 --td-font-* 接管 TDesign 控件高度与文字,
修复 theme-mode 选择器权重不足导致深色变量被 UI 库覆盖的问题。
2026-09-14 23:54:53 +08:00

534 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-wrap">
<h2 class="page-title">图片压缩</h2>
<p class="page-desc">批量上传图片按质量或目标大小压缩全部在浏览器本地完成图片不会上传服务器</p>
<t-card class="tool-section" title="上传图片">
<UploadDrop v-model:files="files" accept="image/*" :multiple="true" :max="20" tip="支持 JPG / PNG / WebP 等格式,最多 20 张" />
</t-card>
<t-card v-if="files.length" class="tool-section" title="压缩设置">
<div class="form-row">
<span class="form-label">压缩方式</span>
<t-radio-group v-model="mode" variant="default-filled">
<t-radio-button value="quality">按质量</t-radio-button>
<t-radio-button value="size">限制大小</t-radio-button>
</t-radio-group>
</div>
<div v-if="mode === 'quality'" class="form-row">
<span class="form-label">压缩质量</span>
<t-slider v-model="quality" class="form-slider" :min="0.1" :max="1" :step="0.05" />
<span class="form-value">{{ Math.round(quality * 100) }}%</span>
</div>
<div v-else class="form-row">
<span class="form-label">目标大小</span>
<t-input-number v-model="targetKb" :min="1" :max="20480" :step="50" theme="normal" />
<span class="form-suffix">KB / 张(尽力逼近,未必精确)</span>
</div>
<div class="form-row">
<span class="form-label">缩放尺寸</span>
<t-select v-model="maxDim" :options="dimOptions" class="form-select" />
<span class="form-suffix">仅当图片超过该尺寸时等比缩小</span>
</div>
<div class="form-row">
<span class="form-label">输出格式</span>
<t-select v-model="outputType" :options="typeOptions" class="form-select" />
</div>
<div v-if="outputType === 'image/jpeg'" class="form-row">
<span class="form-label">透明背景</span>
<t-switch v-model="whiteBg" />
<span class="form-suffix">PNG/WebP 透明区域填白色,避免转 JPG 后变黑</span>
</div>
<t-alert v-if="outputType === 'image/png'" theme="warning" class="form-tip">
PNG 为无损格式,「压缩质量」不生效,也无法精确控制大小;想显著减小体积请选择 JPG 或 WebP。
</t-alert>
<t-alert v-else-if="outputType === 'image/webp'" theme="info" class="form-tip">
部分旧版 Safari 不支持 WebP 编码,压缩失败时可换回 JPG / PNG。
</t-alert>
<div class="form-actions">
<t-button theme="primary" :loading="busy" @click="compressAll">
{{ busy ? progressText : '开始压缩' }}
</t-button>
<t-button variant="outline" @click="clearAll">清空</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="results.length" class="tool-section" title="压缩结果">
<p class="summary">
共 {{ results.length }} 张:原始 {{ formatBytes(totalSource) }} → 压缩后 {{ formatBytes(totalOut) }}
总节省 <span class="saved">{{ totalSaved }}%</span>(点击缩略图可放大预览)
</p>
<div class="result-list">
<div v-for="(r, i) in results" :key="`${r.name}-${i}`" class="result-item">
<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) }} →
<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>
<div class="result-actions">
<t-button v-if="r.outFile" size="small" theme="primary" variant="outline" @click="downloadOne(r)">
下载
</t-button>
</div>
</div>
</div>
<div class="form-actions">
<t-button theme="primary" variant="outline" :loading="zipping" :disabled="!okCount" @click="downloadAll">
全部下载ZIP
</t-button>
<t-button variant="outline" @click="clearResults">清空结果</t-button>
</div>
</t-card>
<!-- 原图 / 压缩图放大预览(统一使用共享 AppImageViewer -->
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<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'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import AppImageViewer from '@/components/common/AppImageViewer.vue'
import { useHistory } from '@/composables/useHistory'
import { useImageViewer } from '@/composables/useImageViewer'
import { downloadBlob } from '@/composables/useDownload'
import { formatBytes, genFilename } from '@/utils'
/** 压缩方式quality=按质量size=限制目标大小KB */
type Mode = 'quality' | 'size'
/** 单张图片的压缩结果 */
interface CompressResult {
name: string
sourceSize: number
sourceUrl: string
outUrl: string
outSize: number
savedPct: number
outFile: File | null
outName: string
error: string
}
/** 缩放尺寸选项0 表示保持原始分辨率 */
const dimOptions = [
{ label: '原始尺寸不缩放', value: 0 },
{ label: '最大 4096px', value: 4096 },
{ label: '最大 2560px', value: 2560 },
{ label: '最大 1920px', value: 1920 },
{ label: '最大 1280px', value: 1280 },
{ label: '最大 800px', value: 800 },
]
/** 输出格式选项keep 保持原格式GIF/BMP 等无法编码时自动转 PNG */
const typeOptions = [
{ label: '保持原格式', value: 'keep' },
{ label: 'JPG', value: 'image/jpeg' },
{ label: 'PNG', value: 'image/png' },
{ label: 'WebP', value: 'image/webp' },
]
const files = ref<File[]>([])
const mode = ref<Mode>('quality')
const quality = ref(0.8)
const targetKb = ref(200)
const maxDim = ref(0)
const outputType = ref('keep')
const whiteBg = ref(true)
const busy = ref(false)
const zipping = ref(false)
const error = ref('')
const doneCount = ref(0)
const results = ref<CompressResult[]>([])
/** 放大预览:统一走共享 useImageViewer展示「原图 + 压缩图」,从点击的那张开始 */
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
/** 历史记录:保存「张数 | 压缩方式 | 格式 | 尺寸」摘要,点击可恢复压缩参数 */
const history = useHistory('image-compress')
const progressText = computed(() => `压缩中(${doneCount.value}/${files.value.length}`)
const okCount = computed(() => results.value.filter((r) => r.outFile).length)
const totalSource = computed(() => results.value.reduce((s, r) => s + r.sourceSize, 0))
const totalOut = computed(() => results.value.reduce((s, r) => s + r.outSize, 0))
const totalSaved = computed(() => {
if (!totalSource.value) return 0
return Math.max(0, Math.round((1 - totalOut.value / totalSource.value) * 100))
})
/** 解析单张图片的目标 MIMEkeep 时沿用原类型,不可编码的类型统一转 PNG */
function resolveMime(file: File): string {
if (outputType.value !== 'keep') return outputType.value
const t = file.type.toLowerCase()
if (['image/jpeg', 'image/png', 'image/webp'].includes(t)) return t
return 'image/png'
}
function extOf(mime: string): string {
return mime === 'image/jpeg' ? 'jpg' : mime.split('/')[1]
}
/** 判断源图是否可能带透明通道(只有 PNG/WebP 会) */
function mayHaveAlpha(file: File): boolean {
return ['image/png', 'image/webp'].includes(file.type.toLowerCase())
}
/**
* 把透明图片铺到白色画布上重新导出为 PNG。
* 浏览器把透明 PNG 编码为 JPG 时会填黑底,先垫白底可避免「透明变黑」。
*/
async function flattenToWhite(file: File): Promise<File> {
const img = await loadImage(file)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('当前浏览器不支持 Canvas')
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('PNG 编码失败')
return new File([blob], file.name.replace(/\.[^.]+$/, '') + '_bg.png', { type: 'image/png' })
}
/** 通过 Image + 对象 URL 加载图片(兼容性好于 createImageBitmap */
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(url)
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('图片加载失败'))
}
img.src = url
})
}
/** 压缩单张图片并写入结果列表 */
async function compressOne(file: File) {
const mime = resolveMime(file)
const result: CompressResult = {
name: file.name,
sourceSize: file.size,
sourceUrl: URL.createObjectURL(file),
outUrl: '',
outSize: 0,
savedPct: 0,
outFile: null,
outName: '',
error: '',
}
results.value.push(result)
try {
// 转 JPG 且源图可能透明时,先垫白底再压缩(顺序不能反:先压缩透明区已被填黑)
let source: File = file
if (whiteBg.value && mime === 'image/jpeg' && mayHaveAlpha(file)) {
source = await flattenToWhite(file)
}
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 } : {}),
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)
result.outName = `${file.name.replace(/\.[^.]+$/, '')}_compressed.${extOf(mime)}`
result.savedPct = Math.max(0, Math.round((1 - out.size / file.size) * 100))
} catch {
// 常见原因:浏览器不支持输出格式编码(如 Safari 的 WebP、图片损坏
result.error = '压缩失败:图片可能损坏,或浏览器不支持该格式编码(如 Safari 的 WebP'
}
}
/** 批量压缩:每 4 张并行,避免同时开太多 WebWorker 卡死页面 */
async function compressAll() {
if (!files.value.length) {
error.value = '请先上传图片'
return
}
busy.value = true
error.value = ''
doneCount.value = 0
clearResults()
try {
for (let i = 0; i < files.value.length; i += 4) {
const chunk = files.value.slice(i, i + 4)
await Promise.all(chunk.map((f) => compressOne(f)))
doneCount.value = Math.min(i + 4, files.value.length)
}
// 写入本地历史(结构化文本,供点击历史时正则解析恢复参数)
const typeTxt = outputType.value === 'keep' ? 'keep' : outputType.value.split('/')[1]
let text = `${files.value.length} 张图片 | ${mode.value === 'quality' ? `q=${quality.value}` : `size=${targetKb.value}KB`} | type=${typeTxt} | max=${maxDim.value || '原始'}`
if (whiteBg.value && outputType.value === 'image/jpeg') text += ' | white'
history.add(text)
} finally {
busy.value = false
}
}
function downloadOne(r: CompressResult) {
if (r.outFile) downloadBlob(r.outFile, r.outName)
}
/** 放大预览:优先展示「原图 + 压缩图」两张并可左右切换,失败时只有原图 */
function openPreview(r: CompressResult, index: number) {
openViewer(r.outUrl ? [r.sourceUrl, r.outUrl] : [r.sourceUrl], r.outUrl ? index : 0)
}
/** 全部结果打包为 ZIP 下载jszip纯本地生成 */
async function downloadAll() {
const ok = results.value.filter((r) => r.outFile)
if (!ok.length) return
zipping.value = true
try {
const zip = new JSZip()
const used = new Set<string>()
for (const r of ok) {
let name = r.outName
let n = 1
while (used.has(name)) {
name = r.outName.replace(/(\.[^.]+)$/, `_${n}$1`)
n++
}
used.add(name)
zip.file(name, r.outFile as File)
}
const blob = await zip.generateAsync({ type: 'blob' })
downloadBlob(blob, genFilename('compressed', 'zip'))
} catch {
error.value = '打包失败,请重试或逐张下载'
} finally {
zipping.value = false
}
}
/** 点击历史:按正则解析结构化文本,恢复压缩参数 */
function onHistorySelect(text: string) {
const qMatch = /q=([\d.]+)/.exec(text)
if (qMatch) {
mode.value = 'quality'
quality.value = Number(qMatch[1])
}
const sMatch = /size=(\d+)/.exec(text)
if (sMatch) {
mode.value = 'size'
targetKb.value = Number(sMatch[1])
}
const tMatch = /type=(\w+)/.exec(text)
if (tMatch) outputType.value = tMatch[1] === 'keep' ? 'keep' : `image/${tMatch[1]}`
const mMatch = /max=(\d+|原始)/.exec(text)
if (mMatch) maxDim.value = mMatch[1] === '原始' ? 0 : Number(mMatch[1])
whiteBg.value = /white/.test(text)
}
function revokeResultUrls() {
results.value.forEach((r) => {
r.sourceUrl && URL.revokeObjectURL(r.sourceUrl)
r.outUrl && URL.revokeObjectURL(r.outUrl)
})
}
function clearResults() {
revokeResultUrls()
results.value = []
}
function clearAll() {
clearResults()
files.value = []
error.value = ''
}
onBeforeUnmount(() => {
revokeResultUrls()
})
</script>
<style scoped>
.form-label {
width: 72px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.form-slider {
flex: 1;
max-width: 300px;
}
.form-value {
width: 48px;
font-size: 13px;
color: var(--td-brand-color);
font-weight: 600;
}
.form-select {
width: 200px;
}
.form-suffix {
font-size: var(--tk-text-xs);
color: var(--td-text-color-placeholder);
}
.form-tip {
margin-top: 12px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.summary {
margin: 0 0 12px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.saved {
color: var(--td-success-color);
font-weight: 600;
}
.result-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--td-border-level-1-color);
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;
border-radius: 6px;
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: var(--tk-text-xs);
color: var(--td-error-color);
border: 1px dashed var(--td-border-level-2-color);
background: var(--td-bg-color-container);
}
.result-info {
flex: 1;
min-width: 0;
}
.result-name {
margin: 0 0 4px;
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-size {
margin: 0;
font-size: var(--tk-text-xs);
color: var(--td-text-color-secondary);
}
.result-error {
margin: 4px 0 0;
font-size: var(--tk-text-xs);
color: var(--td-error-color);
}
.result-actions {
flex-shrink: 0;
}
@media (max-width: 767px) {
.result-item {
flex-wrap: wrap;
}
.result-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
}
</style>