All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 1m10s
主色青玉改宝石蓝,字号整体放大一档(正文 16px), 通过 --td-comp-* 与 --td-font-* 接管 TDesign 控件高度与文字, 修复 theme-mode 选择器权重不足导致深色变量被 UI 库覆盖的问题。
534 lines
18 KiB
Vue
534 lines
18 KiB
Vue
<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))
|
||
})
|
||
|
||
/** 解析单张图片的目标 MIME:keep 时沿用原类型,不可编码的类型统一转 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>
|