hyw
我们学校神秘信息课的神秘AI图片压缩器,但是我手改了几个点然后这玩意可以最多支持压缩到0.7KB的魔丸画质
神秘科技
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>图片批量压缩转换器 - 职称评审专用</title>
<!-- 引入 JSZip 用于打包下载 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js">
</script>
<style>
/* ===== 全局重置 & 基础 ===== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', 'PingFang SC', Roboto, 'Helvetica Neue', sans-serif;
background: #f0f4f8;
color: #1a202c;
padding: 24px;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
}
.container {
max-width: 1100px;
width: 100%;
background: #ffffff;
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.08);
padding: 32px 36px 40px;
transition: all 0.2s;
}
/* ===== 头部 ===== */
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 28px;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
font-size: 34px;
}
.header h1 {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.3px;
background: linear-gradient(135deg, #2b6cb0, #2c5282);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-badge {
background: #ebf4ff;
color: #2b6cb0;
padding: 4px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
border: 1px solid #bee3f8;
white-space: nowrap;
}
.header-desc {
font-size: 14px;
color: #4a5568;
background: #f7fafc;
padding: 6px 18px;
border-radius: 30px;
border: 1px solid #e2e8f0;
}
/* ===== 上传区域 ===== */
.upload-area {
border: 2.5px dashed #cbd5e0;
border-radius: 18px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.25s ease;
background: #fafcff;
position: relative;
margin-bottom: 24px;
}
.upload-area:hover,
.upload-area.dragover {
border-color: #4299e1;
background: #ebf8ff;
transform: scale(1.005);
}
.upload-area .upload-icon {
font-size: 52px;
display: block;
margin-bottom: 10px;
}
.upload-area .upload-text {
font-size: 18px;
font-weight: 600;
color: #2d3748;
}
.upload-area .upload-sub {
font-size: 14px;
color: #718096;
margin-top: 6px;
}
.upload-area input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
/* ===== 统计栏 ===== */
.stats-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #f7fafc;
border-radius: 14px;
padding: 12px 20px;
margin-bottom: 22px;
border: 1px solid #e2e8f0;
}
.stats-bar .stat-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #4a5568;
}
.stats-bar .stat-item strong {
color: #2d3748;
font-weight: 700;
}
.stats-bar .stat-item .num {
background: #edf2f7;
padding: 0 10px;
border-radius: 12px;
font-weight: 700;
color: #2b6cb0;
}
/* ===== 图片列表 ===== */
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 28px;
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.image-grid::-webkit-scrollbar {
width: 5px;
}
.image-grid::-webkit-scrollbar-track {
background: #edf2f7;
border-radius: 10px;
}
.image-grid::-webkit-scrollbar-thumb {
background: #a0aec0;
border-radius: 10px;
}
.image-card {
background: #ffffff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
transition: all 0.2s;
display: flex;
flex-direction: column;
position: relative;
}
.image-card:hover {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
border-color: #bee3f8;
}
.image-card .thumb-wrap {
position: relative;
padding-top: 75%;
background: #f7fafc;
overflow: hidden;
}
.image-card .thumb-wrap img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.image-card .card-info {
padding: 10px 12px 12px;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.image-card .card-info .file-name {
font-size: 13px;
font-weight: 600;
color: #2d3748;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.image-card .card-info .file-size {
font-size: 12px;
color: #718096;
}
.image-card .card-info .file-status {
font-size: 12px;
font-weight: 600;
margin-top: 2px;
padding: 2px 10px;
border-radius: 20px;
display: inline-block;
align-self: flex-start;
background: #edf2f7;
color: #4a5568;
}
.image-card .card-info .file-status.done {
background: #c6f6d5;
color: #276749;
}
.image-card .card-info .file-status.error {
background: #fed7d7;
color: #9b2c2c;
}
.image-card .card-info .file-status.processing {
background: #fefcbf;
color: #975a16;
animation: pulse 1.2s ease-in-out infinite;
}
.image-card .card-remove {
position: absolute;
top: 6px;
right: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: 0.2s;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.image-card:hover .card-remove {
opacity: 1;
}
.image-card .card-remove:hover {
background: #e53e3e;
transform: scale(1.1);
}
.image-card .result-size {
font-size: 12px;
color: #2b6cb0;
font-weight: 600;
margin-top: 2px;
}
/* ===== 设置区域 ===== */
.settings-area {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 20px 30px;
background: #f7fafc;
border-radius: 16px;
padding: 20px 24px;
border: 1px solid #e2e8f0;
margin-bottom: 24px;
}
.settings-group {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1 1 160px;
}
.settings-group label {
font-size: 13px;
font-weight: 600;
color: #4a5568;
letter-spacing: 0.3px;
}
.settings-group select,
.settings-group input {
padding: 8px 14px;
border-radius: 10px;
border: 1.5px solid #e2e8f0;
background: #ffffff;
font-size: 15px;
font-weight: 500;
color: #2d3748;
transition: 0.2s;
outline: none;
width: 100%;
}
.settings-group select:focus,
.settings-group input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.15);
}
.settings-group .hint {
font-size: 12px;
color: #a0aec0;
margin-top: 2px;
}
/* ===== 操作按钮 ===== */
.actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin-bottom: 16px;
}
.btn {
padding: 10px 28px;
border-radius: 12px;
border: none;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 8px;
background: #edf2f7;
color: #2d3748;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: #2b6cb0;
color: #fff;
}
.btn-primary:hover {
background: #2c5282;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-success {
background: #38a169;
color: #fff;
}
.btn-success:hover {
background: #2f855a;
}
.btn-success:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-outline {
background: transparent;
border: 1.5px solid #e2e8f0;
}
.btn-outline:hover {
background: #f7fafc;
border-color: #a0aec0;
}
.btn-danger-outline {
background: transparent;
border: 1.5px solid #feb2b2;
color: #c53030;
}
.btn-danger-outline:hover {
background: #fff5f5;
border-color: #fc8181;
}
.btn .badge {
background: rgba(255, 255, 255, 0.2);
padding: 0 10px;
border-radius: 30px;
font-size: 13px;
}
.btn-primary .badge {
background: rgba(255, 255, 255, 0.2);
}
/* ===== 进度条 ===== */
.progress-wrap {
margin-top: 12px;
margin-bottom: 16px;
display: none;
}
.progress-wrap.active {
display: block;
}
.progress-bar-bg {
background: #edf2f7;
border-radius: 30px;
height: 10px;
overflow: hidden;
position: relative;
}
.progress-bar-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4299e1, #2b6cb0);
border-radius: 30px;
transition: width 0.3s ease;
}
.progress-text {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #4a5568;
margin-top: 6px;
}
/* ===== 空状态 ===== */
.empty-state {
text-align: center;
padding: 30px 10px;
color: #a0aec0;
}
.empty-state .empty-icon {
font-size: 44px;
display: block;
margin-bottom: 8px;
}
/* ===== 响应式 ===== */
@media (max-width: 700px) {
.container {
padding: 20px 16px;
}
.header {
flex-direction: column;
align-items: flex-start;
}
.header h1 {
font-size: 22px;
}
.settings-area {
flex-direction: column;
align-items: stretch;
}
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
}
.actions {
flex-direction: column;
align-items: stretch;
}
.btn {
justify-content: center;
}
.stats-bar {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
}
@media (max-width: 440px) {
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
}
.upload-area {
padding: 24px 12px;
}
.upload-area .upload-icon {
font-size: 38px;
}
.header h1 {
font-size: 19px;
}
}
/* ===== 工具类 ===== */
.mt-8 {
margin-top: 8px;
}
.flex-center {
display: flex;
align-items: center;
gap: 6px;
}
.gap-4 {
gap: 4px;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* 单个下载按钮 */
.btn-download-single {
background: none;
border: none;
color: #2b6cb0;
font-weight: 600;
font-size: 12px;
cursor: pointer;
padding: 2px 0;
transition: 0.2s;
align-self: flex-start;
}
.btn-download-single:hover {
color: #2c5282;
text-decoration: underline;
}
.btn-download-single:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Toast 提示 */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #1a202c;
color: #fff;
padding: 12px 28px;
border-radius: 16px;
font-size: 15px;
font-weight: 500;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
z-index: 999;
opacity: 0;
transition: opacity 0.35s ease, transform 0.35s ease;
pointer-events: none;
white-space: nowrap;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast.success {
background: #276749;
}
.toast.error {
background: #9b2c2c;
}
</style>
</head>
<body>
<div class="container" id="app">
<!-- 头部 -->
<header class="header">
<div class="header-left">
<span class="header-icon">🖼️</span>
<h1>图片批量压缩转换器</h1>
<span class="header-badge">职称评审专用</span>
</div>
<span class="header-desc">⚡ 纯浏览器 · 免费 · 隐私安全</span>
</header>
<!-- 上传区域 -->
<div class="upload-area" id="dropZone">
<span class="upload-icon">📤</span>
<div class="upload-text">拖拽图片到此处,或点击选择文件</div>
<div class="upload-sub">支持 JPG / PNG / WebP / BMP / GIF 等主流格式</div>
<input type="file" id="fileInput" multiple accept="image/*" />
</div>
<!-- 统计栏 -->
<div class="stats-bar" id="statsBar">
<div class="stat-item">
📎 已上传 <strong class="num" id="countTotal">0</strong> 张
</div>
<div class="stat-item">
✅ 已处理 <strong class="num" id="countDone">0</strong> 张
</div>
<div class="stat-item">
📦 总原始大小 <strong id="totalOriginSize">0 KB</strong>
</div>
<div class="stat-item" id="totalCompressedWrap" style="display:none;">
🗜️ 压缩后 <strong id="totalCompressedSize">0 KB</strong>
<span style="color:#38a169;font-weight:700;" id="totalRatio"></span>
</div>
</div>
<!-- 图片列表 -->
<div class="image-grid" id="imageGrid">
<!-- 空状态 -->
<div class="empty-state" id="emptyState" style="grid-column:1/-1;">
<span class="empty-icon">📷</span>
<div style="font-size:15px;color:#718096;">暂无图片,请上传</div>
<div style="font-size:13px;color:#a0aec0;margin-top:4px;">支持批量拖拽或点击选择</div>
</div>
</div>
<!-- 设置区域 -->
<div class="settings-area">
<div class="settings-group">
<label for="formatSelect">📄 输出格式</label>
<select id="formatSelect">
<option value="jpg">JPG (JPEG)</option>
<option value="png">PNG</option>
<option value="webp">WebP</option>
</select>
<span class="hint">JPG 文件小,适合照片;PNG 支持透明</span>
</div>
<div class="settings-group">
<label for="targetSize">🎯 目标大小 (KB)</label>
<input type="number" id="targetSize" value="200" min="0" max="10000" step="0.001" />
<span class="hint">压缩后不超过此大小</span>
</div>
<div class="settings-group" style="flex:0 0 auto;justify-content:flex-end;">
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="btn btn-primary" id="btnProcess" disabled>
🚀 开始处理
</button>
<button class="btn btn-success" id="btnDownloadAll" disabled>
📦 全部下载
</button>
<button class="btn btn-danger-outline" id="btnClearAll">
🗑️ 清空
</button>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressFill" style="width:0%;"></div>
</div>
<div class="progress-text">
<span id="progressLabel">准备中...</span>
<span id="progressPercent">0%</span>
</div>
</div>
<!-- 底部信息 -->
<div style="font-size:12px;color:#a0aec0;text-align:center;margin-top:20px;border-top:1px solid #edf2f7;padding-top:16px;">
🔒 所有处理均在本地浏览器完成,图片不会上传至任何服务器 · 支持 Chrome / Edge / Firefox
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
// ============================================================
// 主逻辑
// ============================================================
(function() {
'use strict';
// ----- DOM 引用 -----
const fileInput = document.getElementById('fileInput');
const dropZone = document.getElementById('dropZone');
const imageGrid = document.getElementById('imageGrid');
const emptyState = document.getElementById('emptyState');
const countTotal = document.getElementById('countTotal');
const countDone = document.getElementById('countDone');
const totalOriginSize = document.getElementById('totalOriginSize');
const totalCompressedWrap = document.getElementById('totalCompressedWrap');
const totalCompressedSize = document.getElementById('totalCompressedSize');
const totalRatio = document.getElementById('totalRatio');
const formatSelect = document.getElementById('formatSelect');
const targetSizeInput = document.getElementById('targetSize');
const btnProcess = document.getElementById('btnProcess');
const btnDownloadAll = document.getElementById('btnDownloadAll');
const btnClearAll = document.getElementById('btnClearAll');
const progressWrap = document.getElementById('progressWrap');
const progressFill = document.getElementById('progressFill');
const progressLabel = document.getElementById('progressLabel');
const progressPercent = document.getElementById('progressPercent');
const toast = document.getElementById('toast');
// ----- 状态 -----
let imageItems = []; // { id, file, name, size, url, blob, status, compressedSize, compressedBlob, error }
let isProcessing = false;
let idCounter = 0;
// ----- 工具函数 -----
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(2) + ' MB';
}
function showToast(msg, type) {
toast.textContent = msg;
toast.className = 'toast show ' + (type || '');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.classList.remove('show'); }, 2800);
}
function getFileExtension(filename) {
const i = filename.lastIndexOf('.');
return i > 0 ? filename.slice(i + 1).toLowerCase() : '';
}
function getBaseName(filename) {
const i = filename.lastIndexOf('.');
return i > 0 ? filename.slice(0, i) : filename;
}
// 将 Blob 转为数据 URL(用于预览)
function blobToURL(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}
// ----- 更新统计 & UI -----
function updateStats() {
const total = imageItems.length;
const done = imageItems.filter(item => item.status === 'done').length;
countTotal.textContent = total;
countDone.textContent = done;
let totalOrig = 0;
let totalComp = 0;
let hasComp = false;
imageItems.forEach(item => {
totalOrig += item.size || 0;
if (item.compressedSize) {
totalComp += item.compressedSize;
hasComp = true;
}
});
totalOriginSize.textContent = formatSize(totalOrig);
if (hasComp && total > 0) {
totalCompressedWrap.style.display = 'flex';
totalCompressedSize.textContent = formatSize(totalComp);
const ratio = ((1 - totalComp / totalOrig) * 100);
const sign = ratio > 0 ? '↓' : '↑';
totalRatio.textContent = `${sign} ${Math.abs(ratio).toFixed(1)}%`;
totalRatio.style.color = ratio > 0 ? '#38a169' : '#e53e3e';
} else {
totalCompressedWrap.style.display = 'none';
}
// 按钮状态
btnProcess.disabled = isProcessing || total === 0;
btnDownloadAll.disabled = isProcessing || done === 0;
}
// 渲染图片列表
function renderGrid() {
// 清除所有卡片(保留空状态占位)
const cards = imageGrid.querySelectorAll('.image-card');
cards.forEach(el => el.remove());
if (imageItems.length === 0) {
emptyState.style.display = 'block';
} else {
emptyState.style.display = 'none';
}
imageItems.forEach(item => {
const card = document.createElement('div');
card.className = 'image-card';
card.dataset.id = item.id;
const thumbWrap = document.createElement('div');
thumbWrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.src = item.url;
img.alt = item.name;
img.loading = 'lazy';
thumbWrap.appendChild(img);
card.appendChild(thumbWrap);
// 删除按钮
const delBtn = document.createElement('button');
delBtn.className = 'card-remove';
delBtn.innerHTML = '✕';
delBtn.title = '移除';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeItem(item.id);
});
card.appendChild(delBtn);
const info = document.createElement('div');
info.className = 'card-info';
const nameEl = document.createElement('div');
nameEl.className = 'file-name';
nameEl.textContent = item.name;
info.appendChild(nameEl);
const sizeEl = document.createElement('div');
sizeEl.className = 'file-size';
sizeEl.textContent = formatSize(item.size);
info.appendChild(sizeEl);
// 状态
const statusEl = document.createElement('div');
statusEl.className = 'file-status';
statusEl.textContent = item.status === 'pending' ? '待处理' :
item.status === 'processing' ? '处理中...' :
item.status === 'done' ? '✅ 完成' :
item.status === 'error' ? '❌ 失败' : '';
if (item.status === 'done') statusEl.classList.add('done');
else if (item.status === 'error') statusEl.classList.add('error');
else if (item.status === 'processing') statusEl.classList.add('processing');
info.appendChild(statusEl);
// 压缩后大小
if (item.status === 'done' && item.compressedSize) {
const resSize = document.createElement('div');
resSize.className = 'result-size';
resSize.textContent = '📉 ' + formatSize(item.compressedSize);
info.appendChild(resSize);
// 单个下载按钮
const dlBtn = document.createElement('button');
dlBtn.className = 'btn-download-single';
dlBtn.textContent = '⬇ 下载此图';
dlBtn.addEventListener('click', (e) => {
e.stopPropagation();
downloadSingle(item.id);
});
info.appendChild(dlBtn);
} else if (item.status === 'error') {
const errMsg = document.createElement('div');
errMsg.className = 'result-size';
errMsg.style.color = '#e53e3e';
errMsg.textContent = '⚠️ ' + (item.error || '压缩失败');
info.appendChild(errMsg);
}
card.appendChild(info);
imageGrid.appendChild(card);
});
updateStats();
}
// ----- 添加图片 -----
function addFiles(files) {
const validFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
if (validFiles.length === 0) {
showToast('请上传图片文件', 'error');
return;
}
let added = 0;
validFiles.forEach(file => {
// 避免重复(按名称和大小简单去重)
const exist = imageItems.some(item =>
item.name === file.name && item.size === file.size
);
if (exist) return;
const url = URL.createObjectURL(file);
imageItems.push({
id: ++idCounter,
file: file,
name: file.name,
size: file.size,
url: url,
blob: null,
status: 'pending', // pending | processing | done | error
compressedSize: null,
compressedBlob: null,
error: null,
});
added++;
});
if (added === 0) {
showToast('没有新图片可添加', '');
return;
}
renderGrid();
showToast(`✅ 成功添加 ${added} 张图片`, 'success');
}
// ----- 移除图片 -----
function removeItem(id) {
const idx = imageItems.findIndex(item => item.id === id);
if (idx === -1) return;
const item = imageItems[idx];
if (item.url) URL.revokeObjectURL(item.url);
imageItems.splice(idx, 1);
renderGrid();
// 如果全部清空,重置进度
if (imageItems.length === 0) {
progressWrap.classList.remove('active');
progressFill.style.width = '0%';
}
showToast('已移除', '');
}
// ----- 清空全部 -----
function clearAll() {
if (isProcessing) {
showToast('请等待处理完成', 'error');
return;
}
if (imageItems.length === 0) return;
if (!confirm('确定要清空所有图片吗?')) return;
imageItems.forEach(item => { if (item.url) URL.revokeObjectURL(item.url); });
imageItems = [];
renderGrid();
progressWrap.classList.remove('active');
progressFill.style.width = '0%';
showToast('已清空', '');
}
// ============================================================
// 核心:图片压缩
// ============================================================
/**
* 压缩单张图片
* @param {Object} item - imageItems 中的一项
* @param {string} format - 'jpg' | 'png' | 'webp'
* @param {number} targetKB - 目标大小(KB)
* @param {Function} onProgress - 进度回调 (0-1)
* @returns {Promise<Blob>} 压缩后的 Blob
*/
async function compressImage(item, format, targetKB, onProgress) {
const targetBytes = targetKB * 1024;
// 1. 加载图片到 canvas
const img = new Image();
img.src = item.url;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = () => reject(new Error('图片加载失败'));
});
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// 绘制(对于 JPG,填充白色背景)
if (format === 'jpg') {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0);
// 辅助:导出为 Blob
function toBlob(quality) {
return new Promise((resolve) => {
const mime = format === 'jpg' ? 'image/jpeg' :
format === 'png' ? 'image/png' :
'image/webp';
// PNG 的 quality 参数会被忽略,但为了统一我们传递
canvas.toBlob((blob) => resolve(blob), mime, quality);
});
}
// 先尝试用高质量导出,看是否已经符合要求
let bestBlob = await toBlob(0.92);
let bestSize = bestBlob.size;
// 如果已经小于目标,直接返回
if (bestSize <= targetBytes) {
onProgress && onProgress(1);
return bestBlob;
}
// ---- 对于 JPG 和 WebP:使用二分查找 quality ----
if (format === 'jpg' || format === 'webp') {
let low = 0.0001;
let high = 0.999;
let iteration = 0;
const maxIter = 1000;
while (iteration < maxIter) {
const mid = (low + high) / 2;
const blob = await toBlob(mid);
const size = blob.size;
if (size <= targetBytes) {
// 满足要求,记录并尝试提高质量
bestBlob = blob;
bestSize = size;
low = mid;
} else {
high = mid;
}
iteration++;
// 进度反馈(粗略)
onProgress && onProgress(0.3 + 0.6 * (iteration / maxIter));
// 如果已经很接近,提前结束
if (Math.abs(size - targetBytes) / targetBytes < 0.00005) {
break;
}
// 如果 low 和 high 相差很小,结束
if (high - low < 0.00001) break;
}
// 如果 bestBlob 仍然大于目标,尝试缩小尺寸
if (bestSize > targetBytes) {
let scale = 0.1;
let attempts = 0;
while (bestSize > targetBytes && scale > 0 && attempts < 2048) {
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const tempCanvas = document.createElement('canvas');
tempCanvas.width = w;
tempCanvas.height = h;
const tCtx = tempCanvas.getContext('2d');
if (format === 'jpg') {
tCtx.fillStyle = '#ffffff';
tCtx.fillRect(0, 0, w, h);
}
tCtx.drawImage(canvas, 0, 0, w, h);
const tempBlob = await new Promise((resolve) => {
const mime = format === 'jpg' ? 'image/jpeg' :
format === 'png' ? 'image/png' :
'image/webp';
tempCanvas.toBlob((b) => resolve(b), mime, 0.88);
});
if (tempBlob.size < bestSize) {
bestBlob = tempBlob;
bestSize = tempBlob.size;
}
scale -= 0.0001;
attempts++;
onProgress && onProgress(0.7 + 0.25 * (attempts / 30));
}
}
onProgress && onProgress(1);
return bestBlob;
}
// ---- 对于 PNG:通过缩小尺寸来压缩 ----
if (format === 'png') {
// PNG 的 quality 参数无效,我们通过缩小尺寸来压缩
let scale = 1.0;
let bestPngBlob = await toBlob(1);
let bestPngSize = bestPngBlob.size;
if (bestPngSize <= targetBytes) {
onProgress && onProgress(1);
return bestPngBlob;
}
let attempts = 0;
const maxAttempts = 35;
while (bestPngSize > targetBytes && scale > 0.12 && attempts < maxAttempts) {
scale -= 0.035;
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const tempCanvas = document.createElement('canvas');
tempCanvas.width = w;
tempCanvas.height = h;
const tCtx = tempCanvas.getContext('2d');
tCtx.drawImage(canvas, 0, 0, w, h);
const blob = await new Promise((resolve) => {
tempCanvas.toBlob((b) => resolve(b), 'image/png');
});
if (blob.size < bestPngSize) {
bestPngBlob = blob;
bestPngSize = blob.size;
}
attempts++;
onProgress && onProgress(0.4 + 0.55 * (attempts / maxAttempts));
}
onProgress && onProgress(1);
return bestPngBlob;
}
// fallback
return bestBlob;
}
// ============================================================
// 批量处理
// ============================================================
async function startProcess() {
if (isProcessing) return;
const pendingItems = imageItems.filter(item => item.status === 'pending' || item.status === 'error');
if (pendingItems.length === 0) {
showToast('没有待处理的图片', '');
return;
}
const targetKB = parseInt(targetSizeInput.value);
if (isNaN(targetKB) || targetKB < -1) {
showToast('请输入有效的目标大小 (≥-1 KB)', 'error');
return;
}
const format = formatSelect.value;
isProcessing = true;
btnProcess.disabled = true;
btnDownloadAll.disabled = true;
progressWrap.classList.add('active');
let processed = 0;
const total = pendingItems.length;
// 更新所有待处理项状态为 processing
pendingItems.forEach(item => {
item.status = 'processing';
item.error = null;
});
renderGrid();
for (const item of pendingItems) {
try {
const idx = imageItems.indexOf(item);
if (idx === -1) continue;
const progressCb = (p) => {
// 更新进度条(整体进度)
const overall = (processed + p) / total;
progressFill.style.width = (overall * 100).toFixed(1) + '%';
progressPercent.textContent = Math.round(overall * 100) + '%';
progressLabel.textContent = `处理中 ${item.name} ...`;
};
const compressedBlob = await compressImage(item, format, targetKB, progressCb);
// 保存结果
item.compressedBlob = compressedBlob;
item.compressedSize = compressedBlob.size;
item.status = 'done';
item.error = null;
// 更新该卡的显示
renderGrid();
processed++;
progressFill.style.width = ((processed / total) * 100).toFixed(1) + '%';
progressPercent.textContent = Math.round((processed / total) * 100) + '%';
progressLabel.textContent = `已完成 ${processed}/${total}`;
} catch (err) {
console.error('压缩失败:', err);
item.status = 'error';
item.error = err.message || '压缩失败';
renderGrid();
processed++;
progressLabel.textContent = `⚠️ ${item.name} 失败`;
showToast(`"${item.name}" 压缩失败: ${item.error}`, 'error');
}
}
// 完成
isProcessing = false;
btnProcess.disabled = false;
btnDownloadAll.disabled = false;
progressFill.style.width = '100%';
progressPercent.textContent = '100%';
progressLabel.textContent = `✅ 全部处理完成 (${imageItems.filter(i=>i.status==='done').length}/${imageItems.length})`;
const doneCount = imageItems.filter(i => i.status === 'done').length;
if (doneCount > 0) {
showToast(`🎉 成功处理 ${doneCount} 张图片`, 'success');
}
updateStats();
// 2秒后自动隐藏进度条(如果不继续操作)
setTimeout(() => {
if (!isProcessing) {
// 但保留,用户可以手动查看
}
}, 3000);
}
// ============================================================
// 下载功能
// ============================================================
function downloadSingle(id) {
const item = imageItems.find(i => i.id === id);
if (!item || item.status !== 'done' || !item.compressedBlob) {
showToast('该图片尚未处理完成', 'error');
return;
}
const ext = formatSelect.value === 'jpg' ? 'jpg' :
formatSelect.value === 'png' ? 'png' : 'webp';
const baseName = getBaseName(item.name);
const fileName = `${baseName}_compressed.${ext}`;
const link = document.createElement('a');
link.href = URL.createObjectURL(item.compressedBlob);
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(link.href), 5000);
showToast(`⬇ 下载 ${fileName}`, 'success');
}
async function downloadAll() {
const doneItems = imageItems.filter(i => i.status === 'done' && i.compressedBlob);
if (doneItems.length === 0) {
showToast('没有可下载的图片', 'error');
return;
}
if (doneItems.length === 1) {
downloadSingle(doneItems[0].id);
return;
}
// 多张:打包为 ZIP
try {
const zip = new JSZip();
const ext = formatSelect.value === 'jpg' ? 'jpg' :
formatSelect.value === 'png' ? 'png' : 'webp';
for (const item of doneItems) {
const baseName = getBaseName(item.name);
const fileName = `${baseName}_compressed.${ext}`;
zip.file(fileName, item.compressedBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
const link = document.createElement('a');
link.href = URL.createObjectURL(zipBlob);
link.download = `压缩图片_${Date.now()}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(link.href), 5000);
showToast(`📦 成功打包下载 ${doneItems.length} 张图片`, 'success');
} catch (err) {
console.error('打包失败:', err);
showToast('打包下载失败,请尝试单个下载', 'error');
}
}
// ============================================================
// 事件绑定
// ============================================================
// 文件选择
fileInput.addEventListener('change', (e) => {
addFiles(e.target.files);
fileInput.value = '';
});
// 拖拽
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
addFiles(e.dataTransfer.files);
}
});
// 按钮
btnProcess.addEventListener('click', startProcess);
btnDownloadAll.addEventListener('click', downloadAll);
btnClearAll.addEventListener('click', clearAll);
// 目标大小输入框:限制范围
targetSizeInput.addEventListener('change', function() {
let val = parseInt(this.value);
if (isNaN(val) || val < 0.001) this.value = 0.001;
if (val > 10000) this.value = 10000;
});
// 快捷键:Ctrl+Enter 快速处理
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
if (!btnProcess.disabled) btnProcess.click();
}
});
// ----- 初始化 -----
renderGrid();
showToast('👋 上传图片开始处理', '');
// 一些示例提示
console.log('🖼️ 图片批量压缩转换器已加载');
console.log('💡 支持拖拽上传,Ctrl+Enter 快速处理');
})();
</script>
</body>
</html>

浙公网安备 33010602011771号