feat: web 端 GUI 风格界面 + SVG 支持 + 预览清晰度修复

- index.html: 完整复刻 GUI 三栏布局(模式/预览/设置)
  7 种编码模式切换,实时防抖预览,下载 PNG/SVG/复制
- main.rs: API 新增 fmt=svg 参数,预览用 PNG size=8 避免拉伸模糊
- Dockerfile: 多阶段构建(alpine),部署就绪

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:52:08 +08:00
parent 6ba79a99d3
commit dcd53b2691
2 changed files with 253 additions and 102 deletions
+13 -7
View File
@@ -19,6 +19,9 @@ struct QrParams {
margin: u8,
#[serde(default = "default_size")]
size: u8,
/// fmt=svg 返回 SVG,否则返回 PNG
#[serde(default)]
fmt: String,
}
fn default_level() -> String { "M".into() }
@@ -40,7 +43,7 @@ async fn index() -> Html<&'static str> {
Html(include_str!("templates/index.html"))
}
/// QR 码生成 API → PNG 图片
/// QR 码生成 API → PNG 或 SVG
async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
let level = match parse_level(&params.level) {
Ok(l) => l,
@@ -58,12 +61,15 @@ async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
let png = match qr.to_png_bytes(params.size) {
Ok(b) => b,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
([(header::CONTENT_TYPE, "image/png")], png).into_response()
if params.fmt == "svg" {
let svg = qr.to_svg();
([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response()
} else {
match qr.to_png_bytes(params.size) {
Ok(b) => ([(header::CONTENT_TYPE, "image/png")], b).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
}
#[tokio::main]
+240 -95
View File
@@ -6,117 +6,262 @@
<title>QRGen Web</title>
<style>
:root { color-scheme: light dark; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: #f5f5f5;
}
@media (prefers-color-scheme: dark) { body { background: #1a1a2e; color: #e0e0e0; } }
.card {
background: #fff; border-radius: 16px; padding: 32px;
box-shadow: 0 2px 16px rgba(0,0,0,.08); max-width: 420px; width: 90%;
display: flex; flex-direction: column; gap: 16px;
}
@media (prefers-color-scheme: dark) { .card { background: #16213e; } }
h1 { font-size: 20px; font-weight: 600; text-align: center; }
textarea, input, select {
width: 100%; padding: 10px 12px; border: 1px solid #ddd;
border-radius: 8px; font-size: 14px; background: #fafafa;
font-family: inherit;
}
@media (prefers-color-scheme: dark) {
textarea, input, select { background: #0f3460; border-color: #1a1a2e; color: #e0e0e0; }
}
textarea { resize: vertical; min-height: 80px; }
.row { display: flex; gap: 12px; align-items: center; }
.row label { font-size: 13px; white-space: nowrap; min-width: 60px; }
button {
padding: 10px 24px; border: none; border-radius: 8px;
background: #2563eb; color: #fff; font-size: 14px; font-weight: 500;
cursor: pointer; transition: background .15s;
}
button:hover { background: #1d4ed8; }
button:disabled { opacity: .4; cursor: not-allowed; }
.preview {
display: flex; justify-content: center; padding: 16px;
background: #fff; border-radius: 12px; min-height: 200px;
align-items: center;
}
.preview img { max-width: 240px; }
.info { font-size: 12px; color: #888; text-align: center; }
.actions { display: flex; gap: 8px; }
.actions button { flex: 1; }
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;height:100vh;display:flex;flex-direction:column;background:#f3f4f6;overflow:hidden}
@media(prefers-color-scheme:dark){body{background:#0a0a0f;color:#e0e0e0}}
/* 标题栏 */
.titlebar{height:40px;display:flex;align-items:center;padding:0 16px;background:rgba(255,255,255,.8);border-bottom:1px solid #e5e7eb;backdrop-filter:blur(20px);font-size:13px;font-weight:600;color:#374151}
@media(prefers-color-scheme:dark){.titlebar{background:rgba(15,15,25,.8);border-color:#1e1e30;color:#d1d5db}}
/* 主体三栏 */
.main{flex:1;display:flex;overflow:hidden}
/* 左栏 — 模式 */
.sidebar-l{width:160px;display:flex;flex-direction:column;padding:8px;gap:2px;background:rgba(255,255,255,.5);border-right:1px solid #e5e7eb;overflow-y:auto}
@media(prefers-color-scheme:dark){.sidebar-l{background:rgba(15,15,25,.5);border-color:#1e1e30}}
.sidebar-l .label{font-size:10px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;padding:4px 8px;margin-bottom:4px}
.sidebar-l button{all:unset;display:block;padding:8px 12px;border-radius:8px;font-size:13px;cursor:pointer;transition:all .15s;color:#4b5563}
@media(prefers-color-scheme:dark){.sidebar-l button{color:#9ca3af}}
.sidebar-l button:hover{background:#f3f4f6}
@media(prefers-color-scheme:dark){.sidebar-l button:hover{background:#1e1e30}}
.sidebar-l button.active{background:linear-gradient(135deg,#2563eb,#06b6d4);color:#fff;box-shadow:0 2px 8px rgba(37,99,235,.25)}
/* 中栏 — 预览 */
.center{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;padding:16px}
.preview-box{width:240px;height:240px;display:flex;align-items:center;justify-content:center;background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.06)}
.preview-box img{width:216px;height:216px}
.preview-box .placeholder{color:#9ca3af;font-size:13px}
.preview-box .loading{color:#9ca3af;font-size:13px;animation:pulse 1.5s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.preview-info{display:flex;gap:12px;font-size:11px;color:#9ca3af}
/* 右栏 — 设置 */
.sidebar-r{width:200px;display:flex;flex-direction:column;padding:12px;gap:12px;background:rgba(255,255,255,.5);border-left:1px solid #e5e7eb;overflow-y:auto}
@media(prefers-color-scheme:dark){.sidebar-r{background:rgba(15,15,25,.5);border-color:#1e1e30}}
.sidebar-r .label{font-size:10px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px}
.sidebar-r select,.sidebar-r input[type=range]{width:100%;margin-top:4px;padding:6px 8px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;background:#fff;outline:none}
@media(prefers-color-scheme:dark){.sidebar-r select,.sidebar-r input[type=range]{background:#1a1a2e;border-color:#2a2a40;color:#d1d5db}}
.sidebar-r select:focus,.sidebar-r input:focus{ring:2px solid rgba(37,99,235,.3)}
.sidebar-r .slider-row{font-size:11px;color:#6b7280}
.sidebar-r button{width:100%;padding:8px;border:none;border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;color:#fff;transition:all .15s}
.sidebar-r button:disabled{opacity:.35;cursor:not-allowed}
.btn-copy{background:#2563eb}.btn-copy:hover{background:#1d4ed8}
.btn-png{background:#16a34a}.btn-png:hover{background:#15803d}
.btn-svg{background:#9333ea}.btn-svg:hover{background:#7e22ce}
/* 底部输入 */
.bottom{height:88px;border-top:1px solid #e5e7eb;background:rgba(255,255,255,.8);backdrop-filter:blur(20px);padding:12px 16px;display:flex;align-items:center;gap:8px}
@media(prefers-color-scheme:dark){.bottom{background:rgba(15,15,25,.8);border-color:#1e1e30}}
.bottom input,.bottom textarea,.bottom select{flex:1;min-width:0;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;outline:none;background:#fafafa;font-family:inherit}
@media(prefers-color-scheme:dark){.bottom input,.bottom textarea,.bottom select{background:#1a1a2e;border-color:#2a2a40;color:#d1d5db}}
.bottom textarea{resize:none;height:100%}
.bottom input:focus,.bottom textarea:focus{border-color:#2563eb;box-shadow:0 0 0 2px rgba(37,99,235,.15)}
.bottom input[type=checkbox]{flex:none;width:auto}
.bottom .wifi-check{display:flex;align-items:center;gap:4px;font-size:12px;color:#6b7280;white-space:nowrap}
.error{font-size:11px;color:#ef4444;background:rgba(239,68,68,.1);padding:6px 8px;border-radius:6px}
</style>
</head>
<body>
<div class="card">
<h1>QRGen Web</h1>
<textarea id="text" placeholder="输入要编码的内容…"></textarea>
<div class="row">
<label>纠错级别</label>
<select id="level">
<option value="L">L — 7%</option>
<option value="M" selected>M — 15%</option>
<option value="Q">Q — 25%</option>
<option value="H">H — 30%</option>
</select>
<label>边距</label>
<input id="margin" type="number" value="4" min="1" max="20" style="width:64px">
<!-- 标题栏 -->
<div class="titlebar">QRGen</div>
<!-- 主体 -->
<div class="main">
<!-- 左栏 — 编码模式 -->
<div class="sidebar-l">
<div class="label">编码模式</div>
<button class="active" data-mode="text">📝 文本</button>
<button data-mode="url">🔗 URL</button>
<button data-mode="wifi">📶 WiFi</button>
<button data-mode="vcard">👤 名片</button>
<button data-mode="email">📧 Email</button>
<button data-mode="phone">📞 电话</button>
<button data-mode="sms">💬 SMS</button>
</div>
<div class="row">
<label>模块大小</label>
<input id="size" type="range" value="8" min="2" max="20" oninput="document.getElementById('sizeVal').textContent=this.value">
<span id="sizeVal" style="font-size:13px">8</span>px
<!-- 中栏 — QR 预览 -->
<div class="center">
<div class="preview-box" id="preview">
<span class="placeholder" id="previewPlaceholder">输入内容生成 QR 码</span>
<img id="previewImg" alt="QR" style="display:none">
<span class="loading" id="previewLoading" style="display:none">生成中…</span>
</div>
<div class="preview-info" id="previewInfo"></div>
</div>
<div class="preview" id="preview">
<span style="color:#999">输入内容生成 QR 码</span>
</div>
<div class="info" id="info"></div>
<div class="actions">
<button id="btnDownload" disabled>下载 PNG</button>
<button id="btnCopy" disabled>复制链接</button>
<!-- 右栏 — 设置 + 导出 -->
<div class="sidebar-r">
<div class="label">导出选项</div>
<div>
<div class="slider-row">纠错级别</div>
<select id="cfgLevel">
<option value="L">L — 7%</option>
<option value="M" selected>M — 15%</option>
<option value="Q">Q — 25%</option>
<option value="H">H — 30%</option>
</select>
</div>
<div>
<div class="slider-row">模块大小: <span id="sizeVal">8</span>px</div>
<input type="range" id="cfgSize" min="2" max="20" value="8">
</div>
<div>
<div class="slider-row">边距: <span id="marginVal">4</span></div>
<input type="range" id="cfgMargin" min="1" max="10" value="4">
</div>
<div id="errorBox"></div>
<button class="btn-copy" id="btnCopy" disabled>复制 SVG</button>
<button class="btn-png" id="btnPng" disabled>导出 PNG</button>
<button class="btn-svg" id="btnSvg" disabled>导出 SVG</button>
</div>
</div>
<!-- 底部输入区 -->
<div class="bottom" id="bottomInput"></div>
<script>
const textEl = document.getElementById('text');
const levelEl = document.getElementById('level');
const marginEl = document.getElementById('margin');
const sizeEl = document.getElementById('size');
const preview = document.getElementById('preview');
const info = document.getElementById('info');
let timer, currentUrl = '';
// ── 状态 ──
let mode = 'text';
let cfg = { level:'M', margin:4, size:8 };
let currentPngUrl = '';
function update() {
const text = textEl.value.trim();
if (!text) { preview.innerHTML='<span style="color:#999">输入内容生成 QR 码</span>'; info.textContent=''; return; }
// ── 每个模式下构建 QR 编码文本 ──
function buildQrText() {
switch (mode) {
case 'url': return docVal('url') || '';
case 'wifi': return (docVal('ssid') ? `WIFI:T:${docVal('enc','WPA')};S:${docVal('ssid')};P:${docVal('pwd')};${docVal('hidden')==='1'?'H:true;':''};` : '');
case 'vcard': return `BEGIN:VCARD\nVERSION:3.0\nFN:${docVal('name')}\nTEL:${docVal('phone')}\nEMAIL:${docVal('email')}\nORG:${docVal('org')}\nADR:${docVal('addr')}\nEND:VCARD`;
case 'email': return `mailto:${docVal('to')}?subject=${encodeURIComponent(docVal('sub'))}&body=${encodeURIComponent(docVal('body'))}`;
case 'phone': return `tel:${docVal('num')}`;
case 'sms': return `smsto:${docVal('snum')}:${docVal('msg')}`;
default: return docVal('text') || '';
}
}
function docVal(id,d){ const el=document.getElementById('f_'+id); return el ? (el.type==='checkbox' ? el.checked : el.value) : (d||''); }
clearTimeout(timer);
timer = setTimeout(async () => {
const p = new URLSearchParams({ text, level: levelEl.value, margin: marginEl.value, size: sizeEl.value });
currentUrl = `/api/qr?${p}`;
preview.innerHTML = `<img src="${currentUrl}" alt="QR">`;
document.getElementById('btnDownload').disabled = false;
document.getElementById('btnCopy').disabled = false;
info.textContent = `${p.get('size')}px/模块 · ${p.get('margin')}px边距`;
}, 300);
// ── 底部输入 — 模式表单 ──
const MODE_FORMS = {
text: '<textarea id="f_text" placeholder="输入任意文本…" class="flex:1" style="resize:none;height:100%"></textarea>',
url: '<input id="f_url" type="url" placeholder="https://example.com" style="flex:1">',
wifi: '<input id="f_ssid" placeholder="SSID" style="flex:1"><input id="f_pwd" type="password" placeholder="密码" style="flex:1"><select id="f_enc" style="flex:1"><option value="WPA">WPA/WPA2</option><option value="WEP">WEP</option><option value="nopass">无密码</option></select><label class="wifi-check"><input type="checkbox" id="f_hidden" value="1"> 隐藏</label>',
vcard: '<input id="f_name" placeholder="姓名" style="flex:1"><input id="f_phone" placeholder="电话" style="flex:1"><input id="f_email" placeholder="邮箱" style="flex:1"><input id="f_org" placeholder="公司" style="flex:1"><input id="f_addr" placeholder="地址" style="flex:1">',
email: '<input id="f_to" placeholder="收件人" style="flex:1"><input id="f_sub" placeholder="主题" style="flex:1"><input id="f_body" placeholder="正文" style="flex:2">',
phone: '<input id="f_num" type="tel" placeholder="输入电话号码" style="flex:1">',
sms: '<input id="f_snum" type="tel" placeholder="电话号码" style="flex:1"><input id="f_msg" placeholder="短信内容" style="flex:2">',
};
function renderBottom() {
document.getElementById('bottomInput').innerHTML = MODE_FORMS[mode] || MODE_FORMS.text;
bindInputs();
}
// ── 实时预览(防抖) ──
let timer;
function scheduleUpdate() {
clearTimeout(timer);
document.getElementById('previewLoading').style.display = 'block';
document.getElementById('previewImg').style.display = 'none';
document.getElementById('previewPlaceholder').style.display = 'none';
timer = setTimeout(doUpdate, 200);
}
function doUpdate() {
const text = buildQrText();
if (!text) {
document.getElementById('previewLoading').style.display = 'none';
document.getElementById('previewPlaceholder').style.display = 'block';
document.getElementById('previewInfo').textContent = '';
document.getElementById('btnCopy').disabled = true;
document.getElementById('btnPng').disabled = true;
document.getElementById('btnSvg').disabled = true;
currentPngUrl = '';
return;
}
textEl.addEventListener('input', update);
levelEl.addEventListener('change', update);
marginEl.addEventListener('input', update);
sizeEl.addEventListener('input', update);
// 预览用 PNG(size=8, 清晰不拉伸),下载用用户选择的 size
const previewUrl = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=8`;
currentPngUrl = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=${cfg.size}`;
document.getElementById('btnDownload').onclick = () => {
if (currentUrl) { const a = document.createElement('a'); a.href = currentUrl; a.download = 'qrcode.png'; a.click(); }
const img = document.getElementById('previewImg');
img.onload = () => {
document.getElementById('previewLoading').style.display = 'none';
img.style.display = 'block';
};
document.getElementById('btnCopy').onclick = async () => {
if (currentUrl) {
try { await navigator.clipboard.writeText(location.origin + currentUrl); alert('已复制'); }
catch { prompt('复制以下链接:', location.origin + currentUrl); }
}
img.onerror = () => {
document.getElementById('previewLoading').style.display = 'none';
document.getElementById('previewPlaceholder').style.display = 'block';
document.getElementById('previewPlaceholder').textContent = '编码失败';
};
img.src = previewUrl;
document.getElementById('previewInfo').textContent = `${cfg.size}px/模块 · ${cfg.margin}px边距 · ${cfg.level}`;
document.getElementById('btnCopy').disabled = false;
document.getElementById('btnPng').disabled = false;
document.getElementById('btnSvg').disabled = false;
}
// ── 事件绑定 ──
function bindInputs() {
document.querySelectorAll('#bottomInput input, #bottomInput textarea, #bottomInput select').forEach(el => {
el.addEventListener('input', scheduleUpdate);
});
// URL 模式:失焦自动补 https://
const urlEl = document.getElementById('f_url');
if (urlEl) {
urlEl.addEventListener('blur', () => {
const v = urlEl.value.trim();
if (v && !/^https?:\/\//i.test(v)) { urlEl.value = 'https://' + v; scheduleUpdate(); }
});
}
}
// ── 模式切换 ──
document.querySelectorAll('.sidebar-l button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.sidebar-l button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
mode = btn.dataset.mode;
renderBottom();
scheduleUpdate();
});
});
// ── 设置变更 ──
['cfgLevel','cfgMargin','cfgSize'].forEach(id => {
const el = document.getElementById(id);
el.addEventListener('input', () => {
if (id === 'cfgLevel') cfg.level = el.value;
if (id === 'cfgMargin') { cfg.margin = +el.value; document.getElementById('marginVal').textContent = el.value; }
if (id === 'cfgSize') { cfg.size = +el.value; document.getElementById('sizeVal').textContent = el.value; }
scheduleUpdate();
});
});
// ── 导出按钮 ──
document.getElementById('btnPng').onclick = () => {
if (currentPngUrl) { const a = document.createElement('a'); a.href = currentPngUrl; a.download = 'qrcode.png'; a.click(); }
};
document.getElementById('btnSvg').onclick = () => {
const text = buildQrText();
if (!text) return;
const url = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=1&fmt=svg`;
const a = document.createElement('a'); a.href = url; a.download = 'qrcode.svg'; a.click();
};
document.getElementById('btnCopy').onclick = async () => {
try {
const resp = await fetch(`/api/qr?text=${encodeURIComponent(buildQrText())}&level=${cfg.level}&margin=${cfg.margin}&size=1&fmt=svg`);
const svg = await resp.text();
await navigator.clipboard.writeText(svg);
} catch(e) {
const err = document.getElementById('errorBox');
err.innerHTML = `<div class="error">复制失败</div>`;
setTimeout(()=>err.innerHTML='',2000);
}
};
// ── 启动 ──
renderBottom();
</script>
</body>
</html>