<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">图片批量翻转工具 · 水平垂直</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: #f5f7fb;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
}
.card {
max-width: 820px;
width: 100%;
background: #ffffff;
border-radius: 32px;
box-shadow: 0 20px 40px rgba(0, 10, 30, 0.1);
padding: 28px 24px 32px;
transition: all 0.2s ease;
}
h1 {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.3px;
color: #0b1a33;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
h1 small {
font-size: 18px;
font-weight: 400;
color: #64748b;
margin-left: 4px;
}
.subtitle {
color: #475569;
font-size: 16px;
border-left: 4px solid #3b82f6;
padding-left: 14px;
margin-bottom: 28px;
line-height: 1.4;
background: #f8fafc;
border-radius: 0 12px 12px 0;
}
.control-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px 12px;
background: #f1f5f9;
padding: 16px 18px;
border-radius: 40px;
margin-bottom: 24px;
}
.flip-options {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
align-items: center;
}
.flip-options label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #1e293b;
background: white;
padding: 6px 16px 6px 12px;
border-radius: 40px;
box-shadow: 0 2px 6px rgba(0,0,0,0.02);
cursor: pointer;
transition: 0.15s;
border: 1px solid #d1d9e6;
}
.flip-options label:hover {
border-color: #94a3b8;
background: #fcfdff;
}
.flip-options input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #2563eb;
border-radius: 4px;
cursor: pointer;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.btn {
background: white;
border: 1px solid #cdd9ed;
color: #1e293b;
padding: 8px 20px;
border-radius: 40px;
font-weight: 500;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: 0.15s;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
letter-spacing: 0.3px;
}
.btn-primary {
background: #1e3a8a;
border-color: #1e3a8a;
color: white;
box-shadow: 0 4px 8px rgba(30, 58, 138, 0.2);
}
.btn-primary:hover {
background: #172554;
border-color: #172554;
transform: scale(0.97);
}
.btn-outline {
background: transparent;
border: 1px solid #b9c7dd;
}
.btn-outline:hover {
background: #eef2f7;
border-color: #94a3b8;
}
.btn:active {
transform: scale(0.96);
}
.btn svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.upload-area {
border: 2px dashed #cbd5e1;
border-radius: 28px;
padding: 32px 12px;
text-align: center;
background: #fafcfd;
transition: 0.2s;
cursor: pointer;
margin-bottom: 20px;
}
.upload-area:hover {
border-color: #3b82f6;
background: #f0f7ff;
}
.upload-icon {
font-size: 48px;
line-height: 1;
margin-bottom: 6px;
opacity: 0.7;
}
.upload-text {
font-weight: 500;
color: #1e293b;
}
.upload-hint {
font-size: 14px;
color: #64748b;
margin-top: 6px;
}
#fileInput {
display: none;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 16px;
margin-top: 24px;
margin-bottom: 8px;
}
.preview-item {
background: #f8fafc;
border-radius: 20px;
padding: 12px 8px 10px;
box-shadow: 0 4px 12px rgba(0,0,0,0.04);
display: flex;
flex-direction: column;
align-items: center;
transition: 0.1s;
border: 1px solid #e9edf2;
}
.preview-item canvas {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
border-radius: 12px;
background: #ffffff;
display: block;
box-shadow: 0 2px 6px rgba(0,0,0,0.03);
}
.preview-name {
font-size: 13px;
font-weight: 500;
margin-top: 8px;
color: #1e293b;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
padding: 0 4px;
}
.preview-size {
font-size: 11px;
color: #64748b;
}
.empty-state {
color: #64748b;
text-align: center;
padding: 40px 10px;
font-size: 15px;
}
.footer-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
margin-top: 28px;
border-top: 1px solid #e2e8f0;
padding-top: 22px;
}
.badge {
background: #eef2ff;
color: #1e3a8a;
padding: 2px 14px;
border-radius: 30px;
font-size: 14px;
font-weight: 500;
}
.btn-success {
background: #0f973d;
border-color: #0f973d;
color: white;
box-shadow: 0 4px 10px rgba(15, 151, 61, 0.25);
}
.btn-success:hover {
background: #0c7c32;
border-color: #0c7c32;
}
@media (max-width: 500px) {
.card { padding: 20px 14px; }
.control-bar { flex-direction: column; align-items: stretch; }
.flip-options { justify-content: center; }
.action-buttons { justify-content: center; }
h1 { font-size: 24px; }
}
</style>
</head>
<body>
<div class="card">
<h1>🔄 图片翻转 <small>批量 · 水平垂直</small></h1>
<div class="subtitle">同时翻转多张图片,支持水平(H)、垂直(V)或组合翻转,预览即时生成,一键批量下载。</div>
<!-- 控制栏 -->
<div class="control-bar">
<div class="flip-options">
<label>
<input type="checkbox" id="flipH" checked> 水平翻转
</label>
<label>
<input type="checkbox" id="flipV"> 垂直翻转
</label>
<span class="badge" id="modeLabel">水平</span>
</div>
<div class="action-buttons">
<button class="btn btn-outline" id="clearBtn" title="清空所有图片">
<svg viewBox="0 0 24 24"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6"/></svg>
清空
</button>
</div>
</div>
<!-- 上传区域 -->
<div class="upload-area" id="dropZone">
<div class="upload-icon">📸</div>
<div class="upload-text">点击选择图片 或 拖放图片到这里</div>
<div class="upload-hint">支持 JPG / PNG / WebP / BMP · 可多选</div>
</div>
<input type="file" id="fileInput" accept="image/*" multiple>
<!-- 预览网格 -->
<div id="previewContainer" class="preview-grid"></div>
<!-- 底部批量下载 -->
<div class="footer-actions" id="footerActions" style="display: none;">
<span id="fileCount" class="badge">0 张图片</span>
<button class="btn btn-success" id="downloadAllBtn">
⬇️ 批量下载翻转后图片
</button>
</div>
</div>
<script>
(function() {
// DOM 元素
const fileInput = document.getElementById('fileInput');
const dropZone = document.getElementById('dropZone');
const previewContainer = document.getElementById('previewContainer');
const clearBtn = document.getElementById('clearBtn');
const downloadAllBtn = document.getElementById('downloadAllBtn');
const footerActions = document.getElementById('footerActions');
const fileCount = document.getElementById('fileCount');
const flipHCheck = document.getElementById('flipH');
const flipVCheck = document.getElementById('flipV');
const modeLabel = document.getElementById('modeLabel');
// 状态存储:所有图片的原始数据 (File 对象 + 预览 URL)
let imageFiles = []; // 存储 File 对象
let imagePreviewURLs = []; // 存储 createObjectURL 生成的 url (用于预览)
let imageDataMapping = []; // 同时存储原始文件名和类型
// 更新模式标签
function updateModeLabel() {
const h = flipHCheck.checked;
const v = flipVCheck.checked;
if (h && v) modeLabel.textContent = '水平+垂直';
else if (h) modeLabel.textContent = '水平';
else if (v) modeLabel.textContent = '垂直';
else modeLabel.textContent = '无翻转 (原图)';
// 重新渲染所有预览 (翻转参数改变)
renderAllPreviews();
}
// 监听翻转复选框
flipHCheck.addEventListener('change', updateModeLabel);
flipVCheck.addEventListener('change', updateModeLabel);
// ----- 渲染预览 (根据当前翻转设置) -----
function renderAllPreviews() {
if (!previewContainer) return;
// 清空预览区
previewContainer.innerHTML = '';
if (imageFiles.length === 0) {
footerActions.style.display = 'none';
fileCount.textContent = '0 张图片';
// 显示空状态
const emptyDiv = document.createElement('div');
emptyDiv.className = 'empty-state';
emptyDiv.style.gridColumn = '1 / -1';
emptyDiv.textContent = '✨ 拖入或选择图片开始翻转';
previewContainer.appendChild(emptyDiv);
return;
}
// 显示底部操作
footerActions.style.display = 'flex';
fileCount.textContent = `${imageFiles.length} 张图片`;
const hFlip = flipHCheck.checked;
const vFlip = flipVCheck.checked;
// 遍历所有图片生成 canvas 预览
imageFiles.forEach((file, index) => {
const img = new Image();
const objectURL = imagePreviewURLs[index];
if (!objectURL) return;
img.onload = function() {
// 确保 index 依然有效 (如果图片被清空则忽略)
if (index >= imageFiles.length) return;
// 创建 canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
// 应用翻转
ctx.save();
// 水平翻转: scale(-1, 1) 且 translate(-width, 0)
// 垂直翻转: scale(1, -1) 且 translate(0, -height)
let scaleX = 1, scaleY = 1;
let transX = 0, transY = 0;
if (hFlip) { scaleX = -1; transX = -canvas.width; }
if (vFlip) { scaleY = -1; transY = -canvas.height; }
ctx.translate(transX, transY);
ctx.scale(scaleX, scaleY);
ctx.drawImage(img, 0, 0);
ctx.restore();
// 创建预览容器
const itemDiv = document.createElement('div');
itemDiv.className = 'preview-item';
// 插入 canvas
const previewCanvas = canvas.cloneNode(true);
itemDiv.appendChild(previewCanvas);
// 文件名 + 尺寸
const nameSpan = document.createElement('div');
nameSpan.className = 'preview-name';
const baseName = file.name.length > 16 ? file.name.slice(0, 12) + '…' : file.name;
nameSpan.textContent = baseName;
itemDiv.appendChild(nameSpan);
const sizeSpan = document.createElement('div');
sizeSpan.className = 'preview-size';
sizeSpan.textContent = `${canvas.width} × ${canvas.height}`;
itemDiv.appendChild(sizeSpan);
// 如果预览项数量对得上,才追加 (防止异步乱序)
// 但多个图片加载完成顺序可能不一致,我们用 dataset 标记 index 来插入正确位置?
// 简便方式:全部追加到最后,但顺序可能乱,更优:根据 index 插入到对应位置
// 但我们保存所有item的顺序为files顺序,采用插入到指定位置方式
const existingItems = previewContainer.children;
let inserted = false;
for (let i = 0; i < existingItems.length; i++) {
const item = existingItems[i];
const idx = parseInt(item.dataset.index);
if (isNaN(idx) || idx > index) {
previewContainer.insertBefore(itemDiv, item);
inserted = true;
break;
}
}
if (!inserted) {
previewContainer.appendChild(itemDiv);
}
itemDiv.dataset.index = index;
};
img.onerror = function() {
// 图片加载失败占位
const itemDiv = document.createElement('div');
itemDiv.className = 'preview-item';
itemDiv.style.justifyContent = 'center';
itemDiv.style.padding = '20px 0';
itemDiv.textContent = '⚠️ 加载失败';
previewContainer.appendChild(itemDiv);
};
// 重新设置 src 触发加载 (使用缓存的 URL)
img.src = objectURL;
});
}
// ----- 添加文件 -----
function addFiles(files) {
if (!files || files.length === 0) return;
// 过滤仅图片
const validFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
if (validFiles.length === 0) {
alert('请选择图片文件 (JPG, PNG, WebP, BMP 等)');
return;
}
// 存储文件以及生成预览URL
validFiles.forEach(file => {
imageFiles.push(file);
const url = URL.createObjectURL(file);
imagePreviewURLs.push(url);
imageDataMapping.push({ name: file.name, type: file.type });
});
// 重新渲染所有预览 (异步加载)
renderAllPreviews();
}
// ----- 清空所有图片 -----
function clearAll() {
// 释放所有 objectURL
imagePreviewURLs.forEach(url => {
if (url) URL.revokeObjectURL(url);
});
imageFiles = [];
imagePreviewURLs = [];
imageDataMapping = [];
previewContainer.innerHTML = '';
footerActions.style.display = 'none';
fileCount.textContent = '0 张图片';
// 显示空状态
const emptyDiv = document.createElement('div');
emptyDiv.className = 'empty-state';
emptyDiv.style.gridColumn = '1 / -1';
emptyDiv.textContent = '✨ 拖入或选择图片开始翻转';
previewContainer.appendChild(emptyDiv);
}
// ----- 批量下载 (每个文件单独下载) -----
function downloadAll() {
if (imageFiles.length === 0) {
alert('没有图片可下载');
return;
}
const hFlip = flipHCheck.checked;
const vFlip = flipVCheck.checked;
// 用 Promise 处理所有图片的异步导出
const downloadPromises = imageFiles.map((file, index) => {
return new Promise((resolve, reject) => {
const img = new Image();
const url = imagePreviewURLs[index];
if (!url) return reject('No URL');
img.onload = function() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
ctx.save();
let scaleX = 1, scaleY = 1;
let transX = 0, transY = 0;
if (hFlip) { scaleX = -1; transX = -canvas.width; }
if (vFlip) { scaleY = -1; transY = -canvas.height; }
ctx.translate(transX, transY);
ctx.scale(scaleX, scaleY);
ctx.drawImage(img, 0, 0);
ctx.restore();
// 转为 Blob (尽量保持原格式)
const mimeType = file.type || 'image/png';
// 如果浏览器不支持原始格式,降级为 png
const outputType = (mimeType === 'image/webp' || mimeType === 'image/bmp' || mimeType === 'image/jpeg' || mimeType === 'image/png') ? mimeType : 'image/png';
canvas.toBlob((blob) => {
if (blob) {
// 构建新文件名: 原名称_翻转后缀
const dotIndex = file.name.lastIndexOf('.');
const baseName = dotIndex > 0 ? file.name.slice(0, dotIndex) : file.name;
const ext = dotIndex > 0 ? file.name.slice(dotIndex) : '.png';
const flipTag = [];
if (hFlip) flipTag.push('H');
if (vFlip) flipTag.push('V');
const suffix = flipTag.length ? '_' + flipTag.join('') : '_origin';
const newFileName = `${baseName}${suffix}${ext}`;
resolve({ blob, fileName: newFileName });
} else {
reject('Blob conversion failed');
}
}, outputType, 0.92);
};
img.onerror = () => reject('Image load error');
img.src = url;
});
});
// 逐个下载 (触发浏览器下载)
Promise.allSettled(downloadPromises).then(results => {
let successCount = 0;
results.forEach((result, idx) => {
if (result.status === 'fulfilled' && result.value) {
const { blob, fileName } = result.value;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放临时 blob URL
setTimeout(() => URL.revokeObjectURL(link.href), 100);
successCount++;
} else {
console.warn(`下载失败: 索引${idx}`, result.reason);
}
});
if (successCount === 0) {
alert('下载失败,请重试');
} else {
// 可提示
}
});
}
// ----- 事件绑定 -----
// 点击上传区域触发 file input
dropZone.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
addFiles(e.target.files);
fileInput.value = ''; // 允许重复选择相同文件
});
// 拖拽上传
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#2563eb';
dropZone.style.background = '#eef2ff';
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#cbd5e1';
dropZone.style.background = '#fafcfd';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#cbd5e1';
dropZone.style.background = '#fafcfd';
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
}
});
// 清空
clearBtn.addEventListener('click', clearAll);
// 批量下载
downloadAllBtn.addEventListener('click', downloadAll);
// 初始化空状态
clearAll();
// 额外:当窗口尺寸变化时,不额外操作,grid自动适应
// 但如果图片数量很多,渲染异步可能重复,但我们做了index保护
// 如果多次切换翻转,需要重新渲染;但 renderAllPreviews 会先清空再全部重建,可能会闪烁。
// 但为简单起见,直接清空重建。
// 优化:防止快速切换时多次重建,加防抖?但影响不大。
// 采用防抖简单处理
let debounceTimer;
function debounceRender() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
renderAllPreviews();
}, 80);
}
// 覆写 updateModeLabel 的渲染为防抖
const originalRender = updateModeLabel;
updateModeLabel = function() {
const h = flipHCheck.checked;
const v = flipVCheck.checked;
if (h && v) modeLabel.textContent = '水平+垂直';
else if (h) modeLabel.textContent = '水平';
else if (v) modeLabel.textContent = '垂直';
else modeLabel.textContent = '无翻转 (原图)';
debounceRender();
};
// 重新绑定事件(移除之前的监听,重新添加新函数)
flipHCheck.removeEventListener('change', originalRender);
flipVCheck.removeEventListener('change', originalRender);
flipHCheck.addEventListener('change', updateModeLabel);
flipVCheck.addEventListener('change', updateModeLabel);
// 首次调用更新
updateModeLabel();
})();
</script>
</body>
</html>
工具功能与操作流程
您可以通过简单的勾选和拖拽,快速完成图片的批量翻转与下载,其核心操作流程如下:

- 选择翻转模式:通过勾选“水平翻转”和/或“垂直翻转”复选框,您可以自由组合翻转效果,当前模式会实时显示在标签中。
- 导入图片:您可以通过点击上传区域或直接拖拽图片文件到该区域来导入图片,工具支持 JPG、PNG 等常见格式,并允许一次选择多张图片。
- 预览与下载:所有图片导入后,会立即根据当前翻转设置生成预览,确认效果后,点击底部的“批量下载翻转后图片”按钮,即可将每张图片按所选翻转模式处理并逐一保存到本地。
标签: 水平垂直翻转
版权声明:除非特别标注,否则均为本站原创文章,转载时请以链接形式注明文章出处。