python
复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>批量图片加灰色渐变相框</title>
<style>
:root{
--bg:#0f1115;--panel:#151821;--muted:#8087a2;--accent:#4f8cff;--text:#e8ecf1;--card:#0d0f14;--border:#242837;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:linear-gradient(180deg,#0c0f14 0%,#0b0d12 100%);color:var(--text);font:15px/1.4 system-ui,Segoe UI,Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji"}
header{position:sticky;top:0;z-index:5;background:rgba(13,15,20,.7);backdrop-filter:saturate(140%) blur(8px);border-bottom:1px solid var(--border)}
.wrap{max-width:1100px;margin:0 auto;padding:18px}
h1{font-size:20px;margin:0}
.controls{display:grid;grid-template-columns:repeat(12,1fr);gap:12px;margin-top:12px}
.card{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:14px}
.ctrl{display:flex;flex-direction:column;gap:8px}
.ctrl label{font-size:12px;color:var(--muted)}
input[type="number"],select,input[type="color"],button{width:100%;padding:10px;border-radius:10px;border:1px solid var(--border);background:var(--card);color:var(--text)}
button{cursor:pointer;border:1px solid #2c3347}
button.primary{background:linear-gradient(180deg,#3a79ff,#2f67da);border:none}
button.ghost{background:transparent;border:1px dashed #2c3347}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px;margin:18px 0 80px}
.item{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden}
.thumb{display:flex;align-items:center;justify-content:center;background:#0b0d12}
.thumb canvas{max-width:100%;height:auto;display:block}
.meta{padding:10px;display:flex;gap:8px}
.meta button{flex:1}
.drop{display:flex;align-items:center;justify-content:center;border:2px dashed #39508a;border-radius:14px;padding:28px;color:#aab2cc;background:#0c1220}
.drop.drag{border-color:#6aa2ff;background:#0e1730}
footer{position:fixed;left:0;right:0;bottom:0;background:rgba(13,15,20,.85);backdrop-filter:blur(8px);border-top:1px solid var(--border)}
.bar{display:flex;gap:10px;align-items:center;justify-content:space-between}
.left, .right{display:flex;gap:10px;align-items:center}
.hint{font-size:12px;color:#93a0c3}
.hidden{display:none !important}
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>批量图片加灰色渐变相框</h1>
<div class="controls">
<div class="card ctrl" style="grid-column:span 5">
<label>选择图片(可多选)</label>
<div class="drop" id="drop">
将图片拖拽到此处,或
<label style="margin-left:8px"><input id="file" type="file" accept="image/*" multiple class="hidden"> <button class="ghost" id="pickBtn" type="button">浏览文件</button></label>
</div>
<span class="hint">支持 JPG、PNG、WebP、BMP、GIF(取首帧)</span>
</div>
<div class="card ctrl" style="grid-column:span 7">
<label>相框样式</label>
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px">
<div class="ctrl"><label>相框宽度(px)</label><input id="frameWidth" type="number" min="1" max="300" value="28"></div>
<div class="ctrl"><label>圆角半径(px)</label><input id="radius" type="number" min="0" max="200" value="14"></div>
<div class="ctrl"><label>渐变类型</label>
<select id="gradType">
<option value="radial">径向渐变</option>
<option value="linear">线性渐变</option>
</select>
</div>
<div class="ctrl"><label>外侧颜色</label><input id="outerColor" type="color" value="#3a3a3a"/></div>
<div class="ctrl"><label>内侧颜色</label><input id="innerColor" type="color" value="#bdbdbd"/></div>
<div class="ctrl"><label>阴影强度</label>
<select id="shadowLevel">
<option value="0">无</option>
<option value="1" selected>轻</option>
<option value="2">中</option>
<option value="3">重</option>
</select>
</div>
</div>
<div style="margin-top:10px;display:flex;gap:10px">
<button class="primary" id="applyAll" type="button">应用相框</button>
<button id="clearAll" type="button">清空列表</button>
</div>
</div>
</div>
</div>
</header>
<main class="wrap">
<div id="list" class="grid"></div>
</main>
<footer>
<div class="wrap bar">
<div class="left">
<button id="downloadAll" class="primary" type="button">全部打包下载</button>
<span class="hint" id="countHint">尚未添加图片</span>
</div>
<div class="right">
<span class="hint">相框建议:外深内浅灰,保持 CS------哦不,是保持整体风格统一 🙂</span>
</div>
</div>
</footer>
<!-- 可选:打包下载依赖(在线) -->
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js"></script>
<script>
const el = {
file: document.getElementById('file'),
pickBtn: document.getElementById('pickBtn'),
drop: document.getElementById('drop'),
list: document.getElementById('list'),
applyAll: document.getElementById('applyAll'),
clearAll: document.getElementById('clearAll'),
downloadAll: document.getElementById('downloadAll'),
countHint: document.getElementById('countHint'),
frameWidth: document.getElementById('frameWidth'),
radius: document.getElementById('radius'),
gradType: document.getElementById('gradType'),
outerColor: document.getElementById('outerColor'),
innerColor: document.getElementById('innerColor'),
shadowLevel: document.getElementById('shadowLevel'),
};
const items = []; // { file, name, img, canvas }
// ---------- UI helpers ----------
function updateCount(){
el.countHint.textContent = items.length ? `共 ${items.length} 张图片` : '尚未添加图片';
}
function addFiles(files){
const arr = Array.from(files || []).filter(f => /^image\//.test(f.type));
if(!arr.length) return;
arr.forEach(file => addItem(file));
updateCount();
}
function addItem(file){
const url = URL.createObjectURL(file);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
renderItem({file, name:file.name.replace(/\.(\w+)$/, ''), img});
URL.revokeObjectURL(url);
};
img.src = url;
}
function renderItem(obj){
items.push(obj);
const wrap = document.createElement('div');
wrap.className = 'item';
wrap.innerHTML = `
<div class="thumb"><canvas></canvas></div>
<div class="meta">
<button class="ghost one">下载</button>
<button class="ghost rerender">重绘</button>
<span class="hint" style="margin-left:auto">${escapeHtml(obj.name)}</span>
</div>`;
obj.canvas = wrap.querySelector('canvas');
el.list.prepend(wrap);
drawWithFrame(obj); // 初次渲染
wrap.querySelector('.one').addEventListener('click', async()=>{
const blob = await canvasToBlob(obj.canvas);
saveAs(blob, `${obj.name}_framed.png`);
});
wrap.querySelector('.rerender').addEventListener('click',()=> drawWithFrame(obj));
}
// ---------- Core drawing ----------
function drawWithFrame(obj){
const fw = clamp(parseInt(el.frameWidth.value||0,10), 0, 300);
const r = clamp(parseInt(el.radius.value||0,10), 0, 400);
const type = el.gradType.value;
const outer = el.outerColor.value;
const inner = el.innerColor.value;
const shadow = parseInt(el.shadowLevel.value,10);
const img = obj.img;
const w = img.naturalWidth + fw*2;
const h = img.naturalHeight + fw*2;
const c = obj.canvas;
c.width = w; c.height = h;
const ctx = c.getContext('2d');
ctx.clearRect(0,0,w,h);
// 背景 + 渐变相框(外深内浅)
const grad = (type === 'radial')
? radialGrad(ctx, w, h, fw, outer, inner)
: linearGrad(ctx, w, h, fw, outer, inner);
// 画圆角外框
roundRectPath(ctx, 0.5, 0.5, w-1, h-1, r);
ctx.fillStyle = grad;
ctx.fill();
// 可选阴影(内缘轻微暗角)
if(shadow>0){
const alpha = [0, 0.10, 0.17, 0.24][shadow];
const g2 = ctx.createRadialGradient(w/2,h/2,Math.max(w,h)/3, w/2,h/2, Math.max(w,h)/1.2);
g2.addColorStop(0, `rgba(0,0,0,0)`);
g2.addColorStop(1, `rgba(0,0,0,${alpha})`);
roundRectPath(ctx, 0.5, 0.5, w-1, h-1, r);
ctx.fillStyle = g2;
ctx.fill();
}
// 镂空内窗
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
roundRectPath(ctx, fw + 0.5, fw + 0.5, img.naturalWidth -1, img.naturalHeight -1, Math.max(0, r - Math.min(r, fw)));
ctx.fill();
ctx.restore();
// 绘制图片(裁切到内窗)
ctx.save();
roundRectPath(ctx, fw, fw, img.naturalWidth, img.naturalHeight, Math.max(0, r - Math.min(r, fw)));
ctx.clip();
ctx.drawImage(img, fw, fw);
ctx.restore();
}
function roundRectPath(ctx, x,y,w,h,r){
const rr = Math.min(r, w/2, h/2);
ctx.beginPath();
ctx.moveTo(x+rr, y);
ctx.arcTo(x+w, y, x+w, y+h, rr);
ctx.arcTo(x+w, y+h, x, y+h, rr);
ctx.arcTo(x, y+h, x, y, rr);
ctx.arcTo(x, y, x+w, y, rr);
ctx.closePath();
}
function radialGrad(ctx, w,h, fw, outer, inner){
const g = ctx.createRadialGradient(w/2,h/2, Math.max(8, Math.min(w,h)/8), w/2,h/2, Math.max(w,h)/2);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
return g;
}
function linearGrad(ctx, w,h, fw, outer, inner){
const g = ctx.createLinearGradient(0,0,w,h);
g.addColorStop(0, outer);
g.addColorStop(0.5, inner);
g.addColorStop(1, outer);
return g;
}
function clamp(n,min,max){return Math.max(min, Math.min(max,n))}
function escapeHtml(s){return s.replace(/[&<>"']/g, m=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[m]))}
function canvasToBlob(canvas){
return new Promise(res=>canvas.toBlob(b=>res(b),'image/png'))
}
// ---------- Events ----------
el.pickBtn.addEventListener('click',()=> el.file.click());
el.file.addEventListener('change', e=> addFiles(e.target.files));
;['dragenter','dragover'].forEach(t=> el.drop.addEventListener(t, e=>{e.preventDefault(); e.dataTransfer.dropEffect='copy'; el.drop.classList.add('drag')}));
;['dragleave','drop'].forEach(t=> el.drop.addEventListener(t, e=>{e.preventDefault(); el.drop.classList.remove('drag')}));
el.drop.addEventListener('drop', e=> addFiles(e.dataTransfer.files));
el.applyAll.addEventListener('click', ()=> items.forEach(drawWithFrame));
el.clearAll.addEventListener('click', ()=>{ items.length=0; el.list.innerHTML=''; updateCount(); });
el.downloadAll.addEventListener('click', async()=>{
if(!items.length) return;
if(!(window.JSZip && window.saveAs)){ alert('缺少打包依赖,已自动改为逐张下载。'); for(const it of items){ const b=await canvasToBlob(it.canvas); saveAs(b, `${it.name}_framed.png`);} return; }
const zip = new JSZip();
const folder = zip.folder('framed');
for(const it of items){
await new Promise(r => setTimeout(r,0));
const blob = await canvasToBlob(it.canvas);
folder.file(`${it.name}_framed.png`, blob);
}
const content = await zip.generateAsync({type:'blob'});
saveAs(content, `framed_${new Date().toISOString().slice(0,10)}.zip`);
});
</script>
</body>
</html>