feat: 更新项目代码

This commit is contained in:
2026-04-01 22:03:57 +08:00
parent 1c81d4e6ea
commit 284b5a5260
53 changed files with 6240 additions and 22665 deletions

View File

@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PNG ↔ JPG 图片转换器</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
body { font-family: 'Noto Sans SC
', system-ui, sans-serif; }
.drop-zone {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.drop-zone.dragover {
border-color: #3b82f6;
background-color: #eff6ff;
transform: scale(1.02);
}
canvas { display: none; }
</style>
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-50 min-h-screen py-12">
<div class="max-w-6xl mx-auto px-6">
<!-- 头部 -->
<div class="text-center mb-10">
<div class="inline-flex items-center gap-3 bg-white shadow-md rounded-3xl px-8 py-4">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center text-white text-3xl">🖼️</div>
<h1 class="text-4xl font-bold text-gray-900 tracking-tight">PNG ↔ JPG 转换器</h1>
</div>
<p class="mt-4 text-gray-600 max-w-md mx-auto">纯浏览器本地转换 • 零上传 • 安全隐私 • 秒级处理</p>
</div>
<div class="bg-white rounded-3xl shadow-2xl overflow-hidden">
<div class="p-8">
<!-- 上传区域 -->
<div id="drop-zone"
class="drop-zone border-4 border-dashed border-gray-300 rounded-3xl p-16 text-center cursor-pointer hover:border-blue-500">
<input type="file" id="file-input" accept="image/png,image/jpeg" class="hidden">
<div class="mx-auto w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v-4m0 0l4 4m-4-4l4-4m12 4v4m0 0l-4-4m4 4l-4 4" />
</svg>
</div>
<p class="text-2xl font-semibold text-gray-700">点击或拖拽图片到此区域</p>
<p class="text-sm text-gray-500 mt-3">支持 PNG / JPG 格式,最大 50MB</p>
</div>
<!-- 主内容区 -->
<div id="main-content" class="hidden mt-12 grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 原图 -->
<div class="lg:col-span-5 bg-gray-50 rounded-3xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg text-gray-800 flex items-center gap-2">
<span class="w-3 h-3 bg-green-500 rounded-full"></span>
原图
</h3>
<span id="original-info" class="text-xs text-gray-500 font-mono"></span>
</div>
<div class="aspect-video bg-white rounded-2xl overflow-hidden border border-gray-200 flex items-center justify-center">
<img id="original-img" class="max-h-full max-w-full object-contain" alt="原图预览">
</div>
</div>
<!-- 控制面板 -->
<div class="lg:col-span-2 flex flex-col justify-center gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">转换目标格式</label>
<div class="flex gap-2">
<button id="btn-jpg"
class="flex-1 py-3 px-6 rounded-2xl font-medium transition-all active:scale-95 border-2 border-transparent bg-white shadow-sm hover:shadow-md data-[active=true]:border-blue-600 data-[active=true]:bg-blue-50">
转为 JPG
</button>
<button id="btn-png"
class="flex-1 py-3 px-6 rounded-2xl font-medium transition-all active:scale-95 border-2 border-transparent bg-white shadow-sm hover:shadow-md data-[active=true]:border-blue-600 data-[active=true]:bg-blue-50">
转为 PNG
</button>
</div>
</div>
<!-- JPG 选项 -->
<div id="jpg-options" class="hidden space-y-5">
<div>
<div class="flex justify-between text-sm mb-1.5">
<span class="font-medium text-gray-700">JPG 质量</span>
<span id="quality-value" class="font-mono text-blue-600">92%</span>
</div>
<input type="range" id="quality-slider" min="10" max="100" value="92"
class="w-full accent-blue-600">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">透明背景填充颜色PNG→JPG</label>
<div class="flex gap-3 items-center">
<input type="color" id="bg-color" value="#ffffff"
class="w-12 h-10 rounded-xl border border-gray-300 cursor-pointer">
<div id="bg-color-hex" class="font-mono text-sm text-gray-500">#ffffff</div>
</div>
</div>
</div>
<button onclick="convertImage()"
class="mt-4 w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-semibold py-4 rounded-3xl shadow-lg transition-all active:scale-95 flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7-7 7" />
</svg>
开始转换
</button>
</div>
<!-- 转换后 -->
<div class="lg:col-span-5 bg-gray-50 rounded-3xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg text-gray-800 flex items-center gap-2">
<span class="w-3 h-3 bg-purple-500 rounded-full"></span>
转换结果
</h3>
<span id="converted-info" class="text-xs text-gray-500 font-mono"></span>
</div>
<div class="aspect-video bg-white rounded-2xl overflow-hidden border border-gray-200 flex items-center justify-center relative">
<img id="converted-img" class="max-h-full max-w-full object-contain" alt="转换后预览">
<div id="converted-placeholder"
class="absolute inset-0 flex flex-col items-center justify-center text-gray-400">
<div class="w-16 h-16 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin mb-4"></div>
<p class="text-sm">转换后图片将显示在这里</p>
</div>
</div>
</div>
</div>
</div>
<!-- 下载栏 -->
<div id="download-bar" class="hidden border-t bg-gray-50 px-8 py-5 flex items-center justify-between">
<div class="text-sm text-gray-600">
文件已转换完成 • <span id="file-size" class="font-mono"></span>
</div>
<button onclick="downloadImage()"
class="bg-emerald-600 hover:bg-emerald-700 text-white px-8 py-3 rounded-2xl font-semibold flex items-center gap-3 transition-all active:scale-95">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v-4m0 0l4 4m-4-4l4-4m12 4v4m0 0l-4-4m4 4l-4 4" />
</svg>
下载转换后的图片
</button>
</div>
</div>
<div class="text-center mt-8 text-xs text-gray-400">
完全本地运行 • 无需联网 • 图片不会离开您的浏览器
</div>
</div>
<canvas id="canvas" class="hidden"></canvas>
<script>
// 全局变量
let originalFile = null;
let originalDataURL = null;
let convertedDataURL = null;
let outputFormat = 'image/jpeg';
let currentQuality = 0.92;
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d', { willReadFrequently: true });
// Tailwind 脚本已加载,无需额外初始化
// 拖拽与点击上传
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) handleFile(e.target.files[0]);
});
// 拖拽事件
['dragover', 'dragenter'].forEach(evt => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
});
['dragleave', 'dragend'].forEach(evt => {
dropZone.addEventListener(evt, () => dropZone.classList.remove('dragover'));
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
function handleFile(file) {
if (!file.type.startsWith('image/') || !['image/png', 'image/jpeg'].includes(file.type)) {
alert('❌ 请上传 PNG 或 JPG 格式的图片!');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('❌ 文件过大!请上传小于 50MB 的图片。');
return;
}
originalFile = file;
const reader = new FileReader();
reader.onload = function(e) {
originalDataURL = e.target.result;
document.getElementById('original-img').src = originalDataURL;
// 显示信息
const img = new Image();
img.onload = () => {
const sizeKB = (file.size / 1024).toFixed(1);
document.getElementById('original-info').textContent =
`${img.width}×${img.height}${sizeKB} KB`;
};
img.src = originalDataURL;
// 显示主界面
document.getElementById('main-content').classList.remove('hidden');
document.getElementById('download-bar').classList.add('hidden');
document.getElementById('converted-img').src = '';
document.getElementById('converted-placeholder').style.display = 'flex';
};
reader.readAsDataURL(file);
}
// 格式切换
const btnJPG = document.getElementById('btn-jpg');
const btnPNG = document.getElementById('btn-png');
const jpgOptions = document.getElementById('jpg-options');
btnJPG.addEventListener('click', () => {
outputFormat = 'image/jpeg';
btnJPG.dataset.active = 'true';
btnPNG.dataset.active = 'false';
jpgOptions.classList.remove('hidden');
});
btnPNG.addEventListener('click', () => {
outputFormat = 'image/png';
btnPNG.dataset.active = 'true';
btnJPG.dataset.active = 'false';
jpgOptions.classList.add('hidden');
});
// 默认选择 JPG
btnJPG.click();
// 质量滑块
const qualitySlider = document.getElementById('quality-slider');
const qualityValue = document.getElementById('quality-value');
qualitySlider.addEventListener('input', () => {
currentQuality = qualitySlider.value / 100;
qualityValue.textContent = qualitySlider.value + '%';
});
// 背景颜色
const bgColorInput = document.getElementById('bg-color');
const bgColorHex = document.getElementById('bg-color-hex');
bgColorInput.addEventListener('input', () => {
bgColorHex.textContent = bgColorInput.value.toUpperCase();
});
// 转换函数
window.convertImage = function() {
if (!originalDataURL) {
alert('请先上传图片!');
return;
}
const loadingPlaceholder = document.getElementById('converted-placeholder');
loadingPlaceholder.style.display = 'none';
const img = new Image();
img.onload = function() {
canvas.width = img.width;
canvas.height = img.height;
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (outputFormat === 'image/jpeg') {
// PNG→JPG 时填充背景色
ctx.fillStyle = bgColorInput.value;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// 绘制图片
ctx.drawImage(img, 0, 0);
// 生成新 DataURL
convertedDataURL = canvas.toDataURL(outputFormat, currentQuality);
// 显示转换结果
document.getElementById('converted-img').src = convertedDataURL;
// 计算新大小
const newSize = Math.round((convertedDataURL.length * 3 / 4) / 1024);
document.getElementById('converted-info').textContent =
`${img.width}×${img.height}${newSize} KB`;
// 显示下载栏
document.getElementById('download-bar').classList.remove('hidden');
document.getElementById('file-size').textContent = `${newSize} KB`;
};
img.src = originalDataURL;
};
// 下载函数
window.downloadImage = function() {
if (!convertedDataURL) return;
const link = document.createElement('a');
link.href = convertedDataURL;
// 生成新文件名
let originalName = originalFile.name;
const ext = outputFormat === 'image/jpeg' ? '.jpg' : '.png';
const baseName = originalName.substring(0, originalName.lastIndexOf('.')) || originalName;
link.download = baseName + ext;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.getElementById('main-content').classList.contains('hidden')) {
fileInput.click();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,728 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>水印工具 · WaterMark</title>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0e0e0f;
--surface: #18181b;
--surface2: #222226;
--border: #2e2e34;
--accent: #f0a500;
--accent2: #e07b00;
--text: #e8e8e8;
--muted: #888;
--danger: #e05555;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Noto Sans SC', sans-serif;
font-weight: 300;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ── */
header {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 28px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
header svg { flex-shrink: 0; }
.brand {
font-family: 'Bebas Neue', cursive;
font-size: 1.7rem;
letter-spacing: 2px;
color: var(--accent);
line-height: 1;
}
.brand span { color: var(--text); }
.subtitle {
font-size: .7rem;
color: var(--muted);
letter-spacing: 3px;
text-transform: uppercase;
margin-left: 2px;
}
/* ── Layout ── */
main {
flex: 1;
display: grid;
grid-template-columns: 340px 1fr;
gap: 0;
height: calc(100vh - 65px);
}
/* ── Sidebar ── */
aside {
background: var(--surface);
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.panel {
padding: 20px;
border-bottom: 1px solid var(--border);
}
.panel-title {
font-size: .65rem;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 14px;
font-weight: 500;
}
/* Upload zone */
.upload-zone {
border: 1.5px dashed var(--border);
border-radius: 10px;
padding: 28px 16px;
text-align: center;
cursor: pointer;
transition: all .25s;
position: relative;
}
.upload-zone:hover, .upload-zone.drag { border-color: var(--accent); background: rgba(240,165,0,.05); }
.upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
.upload-icon { font-size: 2rem; margin-bottom: 8px; }
.upload-zone p { font-size: .82rem; color: var(--muted); line-height: 1.6; }
.upload-zone strong { color: var(--accent); }
/* Controls */
.field { margin-bottom: 14px; }
.field label {
display: block;
font-size: .72rem;
color: var(--muted);
margin-bottom: 6px;
letter-spacing: 1px;
}
.field input[type="text"],
.field input[type="number"],
.field select,
.field textarea {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 9px 12px;
color: var(--text);
font-size: .85rem;
font-family: inherit;
outline: none;
transition: border-color .2s;
}
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
.field textarea { resize: vertical; min-height: 64px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
/* Slider */
.slider-wrap { display: flex; align-items: center; gap: 10px; }
.slider-wrap input[type="range"] {
flex: 1;
-webkit-appearance: none;
height: 4px;
background: var(--border);
border-radius: 4px;
outline: none;
cursor: pointer;
}
.slider-wrap input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
.slider-val {
min-width: 36px;
text-align: right;
font-size: .8rem;
color: var(--accent);
font-weight: 500;
}
/* Color input */
.color-wrap { display: flex; align-items: center; gap: 10px; }
.color-wrap input[type="color"] {
width: 40px; height: 36px;
border: 1px solid var(--border);
border-radius: 7px;
padding: 2px;
background: var(--surface2);
cursor: pointer;
flex-shrink: 0;
}
.color-wrap input[type="text"] { flex: 1; }
/* Checkbox toggle */
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.toggle-label { font-size: .82rem; color: var(--text); }
.toggle {
position: relative;
width: 42px; height: 24px;
flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-track {
position: absolute; inset: 0;
background: var(--border);
border-radius: 24px;
cursor: pointer;
transition: background .2s;
}
.toggle-track::after {
content: '';
position: absolute;
width: 18px; height: 18px;
background: #fff;
border-radius: 50%;
top: 3px; left: 3px;
transition: transform .2s;
}
.toggle input:checked + .toggle-track { background: var(--accent); }
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 20px;
border-radius: 8px;
font-size: .85rem;
font-family: inherit;
font-weight: 500;
cursor: pointer;
border: none;
transition: all .2s;
width: 100%;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover { background: var(--accent2); }
.btn-primary:disabled { opacity: .4; cursor: not-allowed; }
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.actions { padding: 16px 20px; margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
/* ── Canvas area ── */
.canvas-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
position: relative;
overflow: hidden;
padding: 24px;
}
.canvas-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 32px 32px;
opacity: .35;
pointer-events: none;
}
.empty-state {
text-align: center;
position: relative;
z-index: 1;
}
.empty-state .big-icon {
font-size: 4rem;
margin-bottom: 16px;
opacity: .3;
}
.empty-state p { color: var(--muted); font-size: .9rem; line-height: 1.7; }
#previewWrap {
position: relative;
z-index: 1;
display: none;
box-shadow: 0 20px 60px rgba(0,0,0,.6);
border-radius: 4px;
overflow: hidden;
max-width: 100%;
max-height: calc(100vh - 140px);
}
#preview {
display: block;
max-width: 100%;
max-height: calc(100vh - 140px);
object-fit: contain;
}
/* info bar */
.info-bar {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.7);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: .7rem;
color: var(--muted);
white-space: nowrap;
z-index: 10;
display: none;
}
.info-bar span { color: var(--text); }
/* Mobile */
@media (max-width: 768px) {
main {
grid-template-columns: 1fr;
height: auto;
}
aside {
border-right: none;
border-bottom: 1px solid var(--border);
}
.canvas-area {
min-height: 55vw;
padding: 16px;
}
header { padding: 14px 16px; }
.brand { font-size: 1.4rem; }
}
</style>
</head>
<body>
<header>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#f0a500" opacity=".15"/>
<rect x="4" y="8" width="24" height="16" rx="3" stroke="#f0a500" stroke-width="1.5" fill="none"/>
<path d="M4 13h24" stroke="#f0a500" stroke-width="1.2" stroke-dasharray="3 2"/>
<text x="16" y="23" text-anchor="middle" font-size="7" fill="#f0a500" font-family="serif" font-style="italic">WM</text>
</svg>
<div>
<div class="brand">Water<span>Mark</span></div>
<div class="subtitle">图片文字水印工具</div>
</div>
</header>
<main>
<aside>
<!-- Upload -->
<div class="panel">
<div class="panel-title">01 · 上传图片</div>
<div class="upload-zone" id="dropZone">
<input type="file" id="fileInput" accept="image/*">
<div class="upload-icon">🖼️</div>
<p><strong>点击选择</strong>或拖拽图片到此处<br>支持 JPG · PNG · WEBP · GIF</p>
</div>
</div>
<!-- Text -->
<div class="panel">
<div class="panel-title">02 · 水印文字</div>
<div class="field">
<label>水印内容</label>
<textarea id="wmText" placeholder="输入水印文字…">© 版权所有</textarea>
</div>
<div class="row2">
<div class="field">
<label>字号 (px)</label>
<input type="number" id="wmSize" value="36" min="8" max="300">
</div>
<div class="field">
<label>字体</label>
<select id="wmFont">
<option value="'Noto Sans SC', sans-serif">思源黑体</option>
<option value="serif">宋体</option>
<option value="'Noto Serif SC', serif">思源宋体</option>
<option value="monospace">等宽体</option>
<option value="cursive">手写体</option>
</select>
</div>
</div>
<div class="field">
<label>颜色</label>
<div class="color-wrap">
<input type="color" id="wmColorPicker" value="#ffffff">
<input type="text" id="wmColorHex" value="#ffffff" maxlength="9" placeholder="#ffffff">
</div>
</div>
<div class="field">
<label>透明度</label>
<div class="slider-wrap">
<input type="range" id="wmOpacity" min="1" max="100" value="40">
<div class="slider-val" id="opacityVal">40%</div>
</div>
</div>
</div>
<!-- Position -->
<div class="panel">
<div class="panel-title">03 · 位置 & 旋转</div>
<div class="field">
<label>预设位置</label>
<select id="wmPosition">
<option value="center">居中</option>
<option value="top-left">左上角</option>
<option value="top-right">右上角</option>
<option value="bottom-left">左下角</option>
<option value="bottom-right" selected>右下角</option>
<option value="tile">平铺</option>
</select>
</div>
<div class="row2">
<div class="field">
<label>旋转角度 (°)</label>
<div class="slider-wrap">
<input type="range" id="wmAngle" min="-180" max="180" value="-30">
<div class="slider-val" id="angleVal">-30°</div>
</div>
</div>
</div>
<div class="field">
<label>边距 (px)</label>
<input type="number" id="wmPadding" value="30" min="0" max="500">
</div>
<!-- Tile spacing (only when tile) -->
<div class="field" id="tileField" style="display:none">
<label>平铺间距 (px)</label>
<div class="row2">
<input type="number" id="tileX" value="200" min="20">
<input type="number" id="tileY" value="160" min="20">
</div>
</div>
<div class="field" style="margin-top:10px">
<div class="toggle-row">
<span class="toggle-label">描边效果</span>
<label class="toggle">
<input type="checkbox" id="wmStroke">
<div class="toggle-track"></div>
</label>
</div>
</div>
<div class="field" id="strokeField" style="display:none">
<label>描边颜色</label>
<div class="color-wrap">
<input type="color" id="strokeColorPicker" value="#000000">
<input type="text" id="strokeColorHex" value="#000000" maxlength="9">
</div>
</div>
<div class="field">
<div class="toggle-row">
<span class="toggle-label">阴影效果</span>
<label class="toggle">
<input type="checkbox" id="wmShadow" checked>
<div class="toggle-track"></div>
</label>
</div>
</div>
</div>
<!-- Output -->
<div class="panel">
<div class="panel-title">04 · 输出设置</div>
<div class="row2">
<div class="field">
<label>格式</label>
<select id="outFormat">
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="image/webp">WEBP</option>
</select>
</div>
<div class="field">
<label>质量</label>
<div class="slider-wrap">
<input type="range" id="outQuality" min="10" max="100" value="92">
<div class="slider-val" id="qualityVal">92</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="actions">
<button class="btn btn-primary" id="applyBtn" disabled>
✦ 生成水印预览
</button>
<button class="btn btn-ghost" id="downloadBtn" style="display:none">
↓ 下载图片
</button>
</div>
</aside>
<!-- Preview -->
<div class="canvas-area" id="canvasArea">
<div class="canvas-bg"></div>
<div class="empty-state" id="emptyState">
<div class="big-icon">🖼️</div>
<p>上传图片后<br>在左侧调整参数<br>即可预览水印效果</p>
</div>
<div id="previewWrap">
<img id="preview" alt="预览">
</div>
<canvas id="canvas" style="display:none"></canvas>
<div class="info-bar" id="infoBar">尺寸:<span id="dimInfo"></span> &nbsp;|&nbsp; 文件:<span id="sizeInfo"></span></div>
</div>
</main>
<script>
const $ = id => document.getElementById(id);
const fileInput = $('fileInput');
const dropZone = $('dropZone');
const applyBtn = $('applyBtn');
const downloadBtn = $('downloadBtn');
const preview = $('preview');
const previewWrap = $('previewWrap');
const emptyState = $('emptyState');
const canvas = $('canvas');
const ctx = canvas.getContext('2d');
const infoBar = $('infoBar');
let originalImage = null;
let currentBlob = null;
// ── Sliders ──────────────────────────────────────
function linkSlider(sliderId, valId, suffix) {
const s = $(sliderId), v = $(valId);
s.addEventListener('input', () => { v.textContent = s.value + suffix; debounceRender(); });
}
linkSlider('wmOpacity', 'opacityVal', '%');
linkSlider('wmAngle', 'angleVal', '°');
linkSlider('outQuality','qualityVal', '');
// ── Color sync ───────────────────────────────────
function syncColor(pickerId, hexId) {
const picker = $(pickerId), hex = $(hexId);
picker.addEventListener('input', () => { hex.value = picker.value; debounceRender(); });
hex.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; debounceRender(); }
});
}
syncColor('wmColorPicker', 'wmColorHex');
syncColor('strokeColorPicker', 'strokeColorHex');
// ── Toggle stroke ─────────────────────────────────
$('wmStroke').addEventListener('change', e => {
$('strokeField').style.display = e.target.checked ? 'block' : 'none';
debounceRender();
});
// ── Toggle tile ───────────────────────────────────
$('wmPosition').addEventListener('change', e => {
$('tileField').style.display = e.target.value === 'tile' ? 'block' : 'none';
debounceRender();
});
// ── Auto render on any change ─────────────────────
let renderTimer;
function debounceRender() {
if (!originalImage) return;
clearTimeout(renderTimer);
renderTimer = setTimeout(render, 200);
}
document.querySelectorAll('input,select,textarea').forEach(el => {
if (el.type !== 'file') el.addEventListener('input', debounceRender);
});
// ── File handling ─────────────────────────────────
function loadFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
originalImage = img;
applyBtn.disabled = false;
render();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
fileInput.addEventListener('change', e => loadFile(e.target.files[0]));
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag');
loadFile(e.dataTransfer.files[0]);
});
// ── Paste image ───────────────────────────────────
document.addEventListener('paste', e => {
const item = [...e.clipboardData.items].find(i => i.type.startsWith('image/'));
if (item) loadFile(item.getAsFile());
});
// ── Render ────────────────────────────────────────
applyBtn.addEventListener('click', render);
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${alpha})`;
}
function render() {
if (!originalImage) return;
applyBtn.disabled = true;
applyBtn.textContent = '⟳ 渲染中…';
requestAnimationFrame(() => {
canvas.width = originalImage.naturalWidth;
canvas.height = originalImage.naturalHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(originalImage, 0, 0);
const text = $('wmText').value || '水印';
const size = parseInt($('wmSize').value) || 36;
const font = $('wmFont').value;
const color = $('wmColorHex').value;
const opacity = parseInt($('wmOpacity').value) / 100;
const angle = parseInt($('wmAngle').value) * Math.PI / 180;
const pos = $('wmPosition').value;
const pad = parseInt($('wmPadding').value) || 30;
const stroke = $('wmStroke').checked;
const strokeC = $('strokeColorHex').value;
const shadow = $('wmShadow').checked;
ctx.font = `${size}px ${font}`;
ctx.globalAlpha = opacity;
const tw = ctx.measureText(text).width;
const th = size;
if (shadow) {
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = size * 0.2;
ctx.shadowOffsetX = size * 0.05;
ctx.shadowOffsetY = size * 0.05;
} else {
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
}
function drawText(x, y) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.fillStyle = hexToRgba(color, opacity);
ctx.globalAlpha = 1;
if (shadow) {
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = size * 0.2;
ctx.shadowOffsetX = size * 0.05;
ctx.shadowOffsetY = size * 0.05;
}
if (stroke) {
ctx.strokeStyle = hexToRgba(strokeC, opacity);
ctx.lineWidth = size * 0.06;
ctx.lineJoin = 'round';
ctx.strokeText(text, 0, 0);
}
ctx.fillStyle = hexToRgba(color, opacity);
ctx.fillText(text, 0, 0);
ctx.restore();
}
ctx.textBaseline = 'middle';
const W = canvas.width, H = canvas.height;
if (pos === 'tile') {
const gx = parseInt($('tileX').value) || 200;
const gy = parseInt($('tileY').value) || 160;
for (let y = gy/2; y < H + gy; y += gy) {
for (let x = gx/2; x < W + gx; x += gx) {
drawText(x, y);
}
}
} else {
let x, y;
if (pos === 'center') { x = W/2 - tw/2; y = H/2; }
else if (pos === 'top-left') { x = pad; y = pad + th/2; }
else if (pos === 'top-right') { x = W - tw - pad; y = pad + th/2; }
else if (pos === 'bottom-left') { x = pad; y = H - pad - th/2; }
else { x = W - tw - pad; y = H - pad - th/2; }
drawText(x, y);
}
// export
const fmt = $('outFormat').value;
const quality = parseInt($('outQuality').value) / 100;
canvas.toBlob(blob => {
currentBlob = blob;
const url = URL.createObjectURL(blob);
preview.src = url;
previewWrap.style.display = 'block';
emptyState.style.display = 'none';
downloadBtn.style.display = 'block';
infoBar.style.display = 'block';
$('dimInfo').textContent = `${W} × ${H}`;
$('sizeInfo').textContent = formatBytes(blob.size);
applyBtn.disabled = false;
applyBtn.textContent = '✦ 重新渲染';
}, fmt, quality);
});
}
// ── Download ──────────────────────────────────────
downloadBtn.addEventListener('click', () => {
if (!currentBlob) return;
const fmt = $('outFormat').value;
const ext = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }[fmt] || 'jpg';
const a = document.createElement('a');
a.href = URL.createObjectURL(currentBlob);
a.download = `watermarked.${ext}`;
a.click();
});
function formatBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
return (b/1024/1024).toFixed(2) + ' MB';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>在线图片压缩工具</title>
<style>
:root {
--primary-color: #007aff;
--bg-color: #f5f5f7;
--card-bg: #ffffff;
--text-main: #333333;
--text-muted: #888888;
--border-color: #e5e5ea;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: var(--card-bg);
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 24px;
}
.upload-area {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.upload-area:hover, .upload-area.dragover {
border-color: var(--primary-color);
background-color: rgba(0, 122, 255, 0.05);
}
#fileInput {
display: none;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 30px;
padding: 20px;
background: #fafafa;
border-radius: 8px;
display: none; /* 默认隐藏,上传后显示 */
}
.control-group {
flex: 1;
min-width: 200px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
input[type="range"] {
width: 100%;
margin-bottom: 5px;
}
select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: white;
}
.preview-area {
display: none; /* 默认隐藏 */
gap: 20px;
margin-bottom: 20px;
}
@media (min-width: 600px) {
.preview-area {
display: flex; /* 电脑端并排 */
}
}
.preview-box {
flex: 1;
background: #fafafa;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 15px;
text-align: center;
margin-bottom: 20px; /* 手机端间距 */
}
.preview-box img {
max-width: 100%;
max-height: 300px;
border-radius: 4px;
margin-top: 10px;
object-fit: contain;
}
.info {
font-size: 14px;
color: var(--text-muted);
margin-top: 10px;
}
.btn {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
text-decoration: none;
transition: background-color 0.3s;
width: 100%;
text-align: center;
}
.btn:hover {
background-color: #005bb5;
}
.btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
#downloadBtn {
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>图片压缩工具</h1>
<div class="upload-area" id="uploadArea">
<p>点击这里或将图片拖拽到此处上传</p>
<p style="font-size: 12px; color: #888; margin-top: 8px;">支持 JPG, PNG, WebP 等常见格式</p>
<input type="file" id="fileInput" accept="image/*">
</div>
<div class="controls" id="controls">
<div class="control-group">
<label for="qualitySlider">压缩质量: <span id="qualityValue">0.8</span></label>
<input type="range" id="qualitySlider" min="0.1" max="1" step="0.1" value="0.8">
<div style="font-size: 12px; color: #888;">数值越小,体积越小,画质越低</div>
</div>
<div class="control-group">
<label for="formatSelect">输出格式:</label>
<select id="formatSelect">
<option value="image/jpeg">JPEG (体积较小)</option>
<option value="image/webp">WebP (推荐,体积最小)</option>
<option value="image/png">PNG (保留透明度,体积较大)</option>
</select>
</div>
</div>
<div class="preview-area" id="previewArea">
<div class="preview-box">
<h3>原图</h3>
<div class="info" id="originalInfo">等待上传...</div>
<img id="originalImg" src="" alt="原图预览" style="display: none;">
</div>
<div class="preview-box">
<h3>压缩后</h3>
<div class="info" id="compressedInfo">等待处理...</div>
<img id="compressedImg" src="" alt="压缩后预览" style="display: none;">
</div>
</div>
<a id="downloadBtn" class="btn" download="compressed_image.jpg">下载压缩后的图片</a>
</div>
<script>
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const controls = document.getElementById('controls');
const previewArea = document.getElementById('previewArea');
const originalImg = document.getElementById('originalImg');
const compressedImg = document.getElementById('compressedImg');
const originalInfo = document.getElementById('originalInfo');
const compressedInfo = document.getElementById('compressedInfo');
const qualitySlider = document.getElementById('qualitySlider');
const qualityValue = document.getElementById('qualityValue');
const formatSelect = document.getElementById('formatSelect');
const downloadBtn = document.getElementById('downloadBtn');
let currentFile = null;
// 格式化文件大小
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// 处理上传点击和拖拽
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
// 接收文件并读取
function handleFile(file) {
if (!file.type.startsWith('image/')) {
alert('请上传图片文件!');
return;
}
currentFile = file;
// 显示原图信息
originalInfo.innerText = `大小: ${formatBytes(file.size)} \n格式: ${file.type.split('/')[1].toUpperCase()}`;
const reader = new FileReader();
reader.onload = (e) => {
originalImg.src = e.target.result;
originalImg.style.display = 'inline-block';
// 显示控制面板和预览区
controls.style.display = 'flex';
previewArea.style.display = 'flex';
// 默认选中 WebP如果原图是 PNG 且用户可能需要透明度,可以在这里做额外判断
if(file.type === 'image/png') {
formatSelect.value = 'image/png';
} else {
formatSelect.value = 'image/jpeg';
}
compressImage();
};
reader.readAsDataURL(file);
}
// 监听调节事件
qualitySlider.addEventListener('input', (e) => {
qualityValue.innerText = e.target.value;
});
qualitySlider.addEventListener('change', compressImage);
formatSelect.addEventListener('change', compressImage);
// 核心压缩逻辑 (利用 Canvas)
function compressImage() {
if (!currentFile || !originalImg.src) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 创建一个新的 Image 对象用来绘制
const img = new Image();
img.onload = () => {
// 保持原始宽高
canvas.width = img.width;
canvas.height = img.height;
// 如果导出为 JPEG将透明背景填充为白色
if (formatSelect.value === 'image/jpeg') {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const quality = parseFloat(qualitySlider.value);
const outputFormat = formatSelect.value;
// 将 canvas 转为 Blob
canvas.toBlob((blob) => {
if(!blob) return;
// 显示压缩后信息
compressedInfo.innerText = `大小: ${formatBytes(blob.size)} \n格式: ${outputFormat.split('/')[1].toUpperCase()}`;
// 计算压缩率
const ratio = ((1 - (blob.size / currentFile.size)) * 100).toFixed(1);
if(ratio > 0) {
compressedInfo.innerText += `\n节省了 ${ratio}% 的空间`;
}
// 创建预览 URL 和下载链接
const compressUrl = URL.createObjectURL(blob);
compressedImg.src = compressUrl;
compressedImg.style.display = 'inline-block';
downloadBtn.style.display = 'block';
downloadBtn.href = compressUrl;
// 设置下载文件名
const ext = outputFormat.split('/')[1];
const originalName = currentFile.name.split('.')[0];
downloadBtn.download = `${originalName}_compressed.${ext}`;
}, outputFormat, quality);
};
img.src = originalImg.src;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Wasmer Clang in Browser</title>
</head>
<body>
<h1>浏览器里直接编译 C → WASM</h1>
<textarea id="code" rows="10" cols="80">#include <stdio.h>
int main() {
printf("Hello from Clang in WASM!\n");
return 0;
}</textarea>
<br>
<button onclick="compileC()">编译并运行</button>
<pre id="output"></pre>
<script type="module">
import { init, Wasmer, Directory } from "https://unpkg.com/@wasmer/sdk@latest/dist/index.mjs";
async function compileC() {
await init(); // 加载 Wasmer SDK
const clang = await Wasmer.fromRegistry("clang/clang");
const project = new Directory();
const source = document.getElementById("code").value;
await project.writeFile("hello.c", source);
// 调用 Clang 编译成 WASM
const compileResult = await clang.entrypoint.run({
args: ["hello.c", "-o", "hello.wasm"],
mount: { "/project": project },
});
await compileResult.wait();
// 读取生成的 WASM 并运行
const wasmBytes = await project.readFile("hello.wasm");
const instance = await Wasmer.fromFile(wasmBytes);
const runResult = await instance.entrypoint.run();
const output = await runResult.wait();
document.getElementById("output").textContent = output.stdout || "编译成功!";
}
</script>
</body>
</html>