自制条形码批量生成工具(纯HTML,巴枪扫码专用)
工具地址:本文末尾附完整源码,复制另存为
.html即可本地使用,无需安装任何软件。
功能介绍
核心功能
- 批量输入:在左侧文本框里每行粘贴一个单号,实时自动生成条码,无需点按钮
- FORMAT:固定使用
CODE128格式,兼容快递单号(数字+字母+特殊字符全支持) - 尺寸调节:两个滑块分别控制条宽和高度,条宽越大巴枪越容易读取
勾选与导出
这是这个工具最实用的功能,专门为「扫码出问题时需要重发」的场景设计:
- 用巴枪扫完一批条码,发现有 20 条扫描失败
- 在页面上勾选这 20 条(点击卡片即可选中)
- 点「生成图片」→ 弹出预览窗口
- 点「复制图片」→ 图片直接进剪贴板,粘贴发微信/钉钉给同事
- 或在图片上右键 → 复制图片
仅保留已选
勾选有问题的条码后,点「仅保留已选」,输入框里的内容会自动更新,只剩下你勾选的那几条单号。可以直接复制出去重新查单号状态。操作完还可以点「↩ 复原」回到完整列表。
其他细节
- 全选 / 取消选择
- 生成的图片宽度自适应条码宽度,没有多余的右侧空白
- 图片为 2× 分辨率,清晰不模糊
- 生成几百条也不卡,渲染分帧进行
使用方法
- 下载本文末尾的 HTML 源码,保存为
条形码生成器.html - 双击用浏览器打开(推荐 Chrome / Edge)
- 把单号粘贴进左侧文本框,一行一个
- 将屏幕对准巴枪扫描,或勾选后生成图片发给别人
效果截图
左侧输入区 + 右侧条码列表,勾选后顶部出现蓝色操作栏
┌─────────────────┬──────────────────────────────────────┐
│ 输入内容 │ [已选 3 个] 生成图片 仅保留已选 取消 │
│ ├──────────────────────────────────────┤
│ SF1234567890 │ ☑ #01 ████ ██ █ ███ ███ ██████ │
│ YT9876543210 │ SF1234567890 │
│ ZTO2024050100 │ ☑ #02 ██ █████ ██ █ ████ ██ ██ │
│ │ YT9876543210 │
│ 共 3 个条码 │ ☑ #03 ██████ █ ██ ████ ████ ██ │
│ │ ZTO2024050100 │
│ 条宽 ══●══ 3 │ │
│ 高度 ══●══ 90 │ │
│ │ │
│ [清空内容] │ │
└─────────────────┴──────────────────────────────────────┘
可配置参数
打开 HTML 文件,最顶部有三个变量可以直接改:
const DEFAULT_BAR_WIDTH = 3; // 条宽(推荐 1–4,越大越易扫)
const DEFAULT_BAR_HEIGHT = 90; // 条码高度,单位 px
const BARCODE_SIDE_MARGIN = 8; // 图片左右留白,单位 px
源码
完整源码如下,复制全部内容另存为
.html文件即可使用。依赖库 JsBarcode 通过 CDN 加载(需联网第一次加载,之后浏览器会缓存)。
→ 完整 HTML 源码附在下方代码块中
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>条形码生成器</title>
<script>
/* ══════════════════════════════════════════
可调参数 — 直接修改这里的数字即可
══════════════════════════════════════════ */
const DEFAULT_BAR_WIDTH = 3; // 条宽(推荐 1–4,越大越易扫)
const DEFAULT_BAR_HEIGHT = 90; // 条码高度,单位 px
const BARCODE_SIDE_MARGIN = 8; // 图片左右留白,单位 px
</script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--accent: #D95F02;
--bg: #F0EFEA;
--panel: #FFFFFF;
--dark: #1A1A18;
--muted: #90908A;
--border: #DDDDD8;
--font: 'Courier New', Courier, monospace;
--check: #1A6AFF;
}
body { font-family: var(--font); background: var(--bg); color: var(--dark); min-height: 100vh; }
/* ── Header ── */
header {
background: var(--dark); color: #fff;
padding: 14px 24px;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 100;
border-bottom: 2px solid var(--accent);
}
header h1 { font-size: 14px; font-weight: normal; letter-spacing: 0.18em; text-transform: uppercase; }
.header-tip { font-size: 11px; color: #888; }
.header-blog-link {
font-size: 11px; color: #C8884A; text-decoration: none;
letter-spacing: 0.04em; opacity: 0.85; transition: opacity 0.15s;
}
.header-blog-link:hover { opacity: 1; text-decoration: underline; }
/* ── Layout ── */
.layout { display: grid; grid-template-columns: 280px 1fr; min-height: calc(100vh - 50px); }
.sidebar {
background: var(--panel); border-right: 1px solid var(--border);
padding: 18px 16px;
position: sticky; top: 50px; height: calc(100vh - 50px);
overflow-y: auto;
display: flex; flex-direction: column; gap: 18px;
}
.section-label {
font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
color: var(--muted); margin-bottom: 7px;
}
/* ── Textarea fills remaining height ── */
.textarea-wrap { display: flex; flex-direction: column; flex: 1; min-height: 0; }
textarea {
flex: 1; width: 100%; min-height: 120px;
border: 1px solid var(--border); padding: 10px;
font-family: var(--font); font-size: 13px; line-height: 1.65;
resize: none;
background: #FAFAF7; color: var(--dark);
outline: none; transition: border-color 0.15s;
}
textarea:focus { border-color: var(--accent); }
.stat-row { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-top: 5px; }
.stat-row b { color: var(--dark); }
#err-label { color: #C0604A; display: none; }
/* ── Sliders ── */
.slider-group { display: flex; flex-direction: column; gap: 14px; }
.slider-row { display: flex; flex-direction: column; gap: 4px; }
.slider-label { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); }
.slider-label strong { color: var(--dark); font-weight: bold; }
input[type=range] { width: 100%; height: 4px; accent-color: var(--accent); cursor: pointer; }
.sep { flex: 1; }
/* ── Buttons ── */
.btn {
display: block; width: 100%; padding: 10px; border: none;
font-family: var(--font); font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase;
cursor: pointer; transition: all 0.15s;
}
.btn:active { transform: scale(0.98); }
.btn-clear { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-clear:hover { border-color: #999; color: var(--dark); }
/* ── Main ── */
.main { padding: 20px 24px; position: relative; }
/* ── Selection toolbar ── */
.sel-toolbar {
display: none;
position: sticky; top: 50px; z-index: 90;
background: var(--check); color: #fff;
padding: 9px 14px;
margin: -20px -24px 20px;
align-items: center; justify-content: space-between; gap: 10px;
}
.sel-toolbar.visible { display: flex; }
.sel-info { font-size: 13px; white-space: nowrap; }
.sel-actions { display: flex; gap: 7px; align-items: center; flex-wrap: wrap; }
.sel-btn {
padding: 6px 13px; border: none;
font-family: var(--font); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.sel-btn:active { transform: scale(0.97); }
.sel-btn-img { background: #fff; color: var(--check); font-weight: bold; }
.sel-btn-img:hover { background: #E8F0FF; }
.sel-btn-filter { background: rgba(255,255,255,0.18); color: #fff; border: 1px solid rgba(255,255,255,0.4); }
.sel-btn-filter:hover { background: rgba(255,255,255,0.28); }
.sel-btn-all { background: transparent; color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.3); }
.sel-btn-all:hover { background: rgba(255,255,255,0.1); }
.sel-btn-desel { background: transparent; color: rgba(255,255,255,0.55); border: 1px solid rgba(255,255,255,0.2); font-size: 10px; }
.sel-btn-desel:hover { background: rgba(255,255,255,0.08); }
/* ── Undo bar ── */
.undo-bar {
display: none; align-items: center; justify-content: space-between;
background: #FFF8EC; border: 1px solid #F5D9A0;
padding: 8px 12px; margin-bottom: 10px;
font-size: 12px; color: #7A5800;
}
.undo-bar.visible { display: flex; }
.undo-btn {
padding: 5px 14px; border: 1px solid #D4AA50;
background: transparent; color: #7A5800;
font-family: var(--font); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer; transition: all 0.15s;
}
.undo-btn:hover { background: #FFF0CC; }
/* ── Barcode list ── */
#barcode-grid { display: flex; flex-direction: column; gap: 10px; }
.barcode-row {
display: flex; align-items: stretch;
border: 1px solid var(--border); background: #fff;
transition: border-color 0.12s, box-shadow 0.12s;
cursor: pointer; user-select: none;
}
.barcode-row:hover { border-color: #BBB; }
.barcode-row.selected { border-color: var(--check); box-shadow: 0 0 0 1px var(--check); }
.check-col {
width: 46px; min-width: 46px;
display: flex; align-items: center; justify-content: center;
border-right: 1px solid var(--border); background: #FAFAF7;
transition: background 0.12s; flex-shrink: 0;
}
.barcode-row.selected .check-col { background: #EEF3FF; border-right-color: #C5D4FF; }
.check-box {
width: 19px; height: 19px;
border: 1.5px solid #CCCCC8; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: all 0.12s; background: #fff;
}
.barcode-row:hover .check-box { border-color: #999; }
.barcode-row.selected .check-box { background: var(--check); border-color: var(--check); }
.check-mark { width: 11px; height: 11px; opacity: 0; transition: opacity 0.12s; }
.barcode-row.selected .check-mark { opacity: 1; }
.barcode-content {
flex: 1; padding: 12px 18px 10px;
display: flex; flex-direction: column; align-items: flex-start; min-width: 0;
}
.barcode-content svg { max-width: 100%; height: auto; display: block; }
.card-seq { font-size: 10px; color: #C5C4BD; letter-spacing: 0.1em; margin-bottom: 2px; }
.barcode-error-content { flex: 1; padding: 14px 18px; background: #FFF8F6; }
.error-label { font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; color: #C0604A; margin-bottom: 3px; }
.error-text { font-size: 13px; color: var(--dark); word-break: break-all; }
.error-hint { font-size: 11px; color: #C0604A; margin-top: 3px; }
/* ── Empty state ── */
.empty-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 320px; color: var(--muted); gap: 8px; text-align: center;
}
.empty-icon { font-size: 40px; color: #CCC; margin-bottom: 8px; line-height: 1; }
/* ── Image modal ── */
#img-modal {
display: none; position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.72);
align-items: flex-start; justify-content: center;
padding: 20px; overflow-y: auto;
}
#img-modal.show { display: flex; }
.modal-box {
background: #fff; width: 100%; max-width: 700px;
display: flex; flex-direction: column; margin: auto;
}
.modal-topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: #fff; z-index: 1;
}
.modal-title { font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
.modal-actions { display: flex; gap: 8px; align-items: center; }
.modal-copy-btn {
padding: 7px 18px; border: none;
background: var(--check); color: #fff;
font-family: var(--font); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer; transition: all 0.15s;
}
.modal-copy-btn:hover { background: #0E55D9; }
.modal-copy-btn:active { transform: scale(0.97); }
.modal-copy-btn.copied { background: #1A8C4A; }
.modal-close-btn {
padding: 7px 14px; border: 1px solid var(--border);
background: transparent; color: var(--muted);
font-family: var(--font); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer; transition: all 0.15s;
}
.modal-close-btn:hover { border-color: #999; color: var(--dark); }
.modal-hint {
font-size: 11px; color: var(--muted);
padding: 8px 16px; border-bottom: 1px solid var(--border); background: #FAFAF7;
}
.modal-img-area { padding: 16px; background: #E8E8E4; display: flex; justify-content: center; }
.modal-img-area img { display: block; max-width: 100%; height: auto; background: #fff; }
/* ── Toast ── */
#toast {
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
background: #1A1A18; color: #fff;
padding: 10px 22px; font-family: var(--font); font-size: 12px; letter-spacing: 0.1em;
opacity: 0; transition: opacity 0.25s; pointer-events: none; z-index: 999; white-space: nowrap;
}
#toast.show { opacity: 1; }
/* ── Loading ── */
#loading {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 300;
align-items: center; justify-content: center; flex-direction: column; gap: 14px;
color: #fff; font-family: var(--font); font-size: 13px; letter-spacing: 0.1em;
}
#loading.show { display: flex; }
.spinner {
width: 28px; height: 28px;
border: 2.5px solid rgba(255,255,255,0.25); border-top-color: #fff;
border-radius: 50%; animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media print {
header, .sidebar, .sel-toolbar { display: none !important; }
.layout { display: block; }
.main { padding: 8mm; }
body { background: white; }
.check-col { display: none !important; }
.barcode-row { border: 0.5px solid #CCC; box-shadow: none !important; margin-bottom: 5mm; page-break-inside: avoid; }
.barcode-content { padding: 10px 14px 8px; }
.card-seq { display: none; }
}
</style>
</head>
<body>
<header>
<h1>条形码生成器</h1>
<a class="header-blog-link" href="https://www.cnblogs.com/ppmdz/p/19968371" target="_blank" rel="noopener">
博客园 →
</a>
</header>
<div class="layout">
<!-- ── Sidebar ── -->
<aside class="sidebar">
<div class="textarea-wrap">
<div class="section-label">输入内容</div>
<textarea id="input"
placeholder="每行一个条码内容 SF1234567890 YT9876543210 ZTO20240501001"
spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
></textarea>
<div class="stat-row">
<span>共 <b id="line-count">0</b> 个条码</span>
<span id="err-label"><b id="err-num">0</b> 个错误</span>
</div>
</div>
<div>
<div class="section-label">尺寸调节</div>
<div class="slider-group">
<div class="slider-row">
<div class="slider-label">条宽(越大越易扫)<strong id="w-val"></strong></div>
<input type="range" id="bar-width" min="1" max="4" step="0.5">
</div>
<div class="slider-row">
<div class="slider-label">高度<strong id="h-val"></strong></div>
<input type="range" id="bar-height" min="60" max="220" step="10">
</div>
</div>
</div>
<div class="sep"></div>
<button class="btn btn-clear" onclick="clearAll()">清空内容</button>
</aside>
<!-- ── Main ── -->
<main class="main">
<div class="sel-toolbar" id="sel-toolbar">
<span class="sel-info">已选 <b id="sel-count">0</b> 个</span>
<div class="sel-actions">
<button class="sel-btn sel-btn-img" onclick="buildImage()">生成图片</button>
<button class="sel-btn sel-btn-filter" onclick="filterToSelected()">仅保留已选</button>
<button class="sel-btn sel-btn-all" onclick="selectAll()">全选</button>
<button class="sel-btn sel-btn-desel" onclick="clearSelection()">取消</button>
</div>
</div>
<div class="undo-bar" id="undo-bar">
<span>已过滤,输入框内容已更新</span>
<button class="undo-btn" onclick="undoFilter()">↩ 复原</button>
</div>
<div id="output">
<div class="empty-state">
<div class="empty-icon">|||| ||| |||||</div>
<p>在左侧输入条码内容</p>
<small style="font-size:11px">每行一个,自动生成</small>
</div>
</div>
</main>
</div>
<!-- Image modal -->
<div id="img-modal" onclick="closeModalOutside(event)">
<div class="modal-box" id="modal-box">
<div class="modal-topbar">
<span class="modal-title">已选条形码图片</span>
<div class="modal-actions">
<button class="modal-copy-btn" id="copy-btn" onclick="copyToClipboard()">复制图片</button>
<button class="modal-close-btn" onclick="closeModal()">关闭</button>
</div>
</div>
<div class="modal-hint" id="modal-hint">点击「复制图片」可直接粘贴发送,或在图片上右击 → 复制图片</div>
<div class="modal-img-area">
<img id="modal-img" src="" alt="条形码图片">
</div>
</div>
</div>
<!-- Loading -->
<div id="loading"><div class="spinner"></div><span id="loading-msg">正在生成图片…</span></div>
<!-- Toast -->
<div id="toast"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsbarcode/3.11.6/JsBarcode.all.min.js"></script>
<script>
/* ── DOM refs ── */
const inputEl = document.getElementById('input');
const outputEl = document.getElementById('output');
const barWidthEl = document.getElementById('bar-width');
const barHeightEl = document.getElementById('bar-height');
const lineCountEl = document.getElementById('line-count');
const errLabel = document.getElementById('err-label');
const errNumEl = document.getElementById('err-num');
const wValEl = document.getElementById('w-val');
const hValEl = document.getElementById('h-val');
const selToolbar = document.getElementById('sel-toolbar');
const selCountEl = document.getElementById('sel-count');
const loadingEl = document.getElementById('loading');
const loadingMsg = document.getElementById('loading-msg');
/* ── Init sliders ── */
barWidthEl.value = DEFAULT_BAR_WIDTH;
barHeightEl.value = DEFAULT_BAR_HEIGHT;
wValEl.textContent = DEFAULT_BAR_WIDTH;
hValEl.textContent = DEFAULT_BAR_HEIGHT + ' px';
/* ── State ── */
let debounceTimer;
const selected = new Set();
let lastImageBlob = null;
let undoSnapshot = null;
let cachedLines = []; // parsed lines, always up-to-date after generate()
let genToken = 0; // incremented to cancel in-flight renders
const RENDER_CHUNK = 40; // barcodes per animation frame during list render
const IMG_PARALLEL = 80; // SVGs serialized in parallel during image build
/* ── Helpers ── */
function debounce(fn, ms) { clearTimeout(debounceTimer); debounceTimer = setTimeout(fn, ms); }
function getLines() { return inputEl.value.split('\n').map(l => l.trim()).filter(l => l.length > 0); }
function escHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
function rAF() { return new Promise(r => requestAnimationFrame(r)); }
function getBarcodeOpts() {
return {
format: 'CODE128',
width: parseFloat(barWidthEl.value),
height: parseInt(barHeightEl.value),
displayValue: true,
fontSize: 13,
font: 'Courier New, monospace',
textAlign: 'center',
textPosition: 'bottom',
textMargin: 5,
margin: 10,
background: '#FFFFFF',
lineColor: '#000000',
};
}
function renderBarcode(svgEl, line) {
JsBarcode(svgEl, line, getBarcodeOpts());
}
/* ══════════════════════════════════════════════════
generate()
- Builds DOM in one innerHTML pass (fast)
- Renders JsBarcode in chunks of RENDER_CHUNK per
animation frame so the UI never freezes
══════════════════════════════════════════════════ */
async function generate() {
selected.clear();
updateSelToolbar();
cachedLines = getLines();
lineCountEl.textContent = cachedLines.length;
if (cachedLines.length === 0) {
outputEl.innerHTML = `<div class="empty-state">
<div class="empty-icon">|||| ||| |||||</div>
<p>在左侧输入条码内容</p>
<small style="font-size:11px">每行一个,自动生成</small>
</div>`;
errLabel.style.display = 'none';
return;
}
// 1. Build all DOM at once (string concat; far faster than DOM API for large lists)
let html = '<div id="barcode-grid">';
for (let i = 0; i < cachedLines.length; i++) {
html += `<div class="barcode-row" id="row-${i}" onclick="toggleSelect(${i})">
<div class="check-col"><div class="check-box">
<svg class="check-mark" viewBox="0 0 11 11" fill="none">
<polyline points="1.5,5.5 4.2,8.2 9,2" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div></div>
<div class="barcode-content" id="content-${i}">
<div class="card-seq">#${String(i + 1).padStart(2, '0')}</div>
<svg id="bc-${i}"></svg>
</div>
</div>`;
}
html += '</div>';
outputEl.innerHTML = html; // single paint
// 2. Render barcodes in chunks — keeps UI interactive
const myToken = ++genToken;
let errCount = 0;
for (let start = 0; start < cachedLines.length; start += RENDER_CHUNK) {
if (myToken !== genToken) return; // a newer generate() started; abort
await rAF(); // yield to browser between chunks
const end = Math.min(start + RENDER_CHUNK, cachedLines.length);
for (let i = start; i < end; i++) {
const svgEl = document.getElementById(`bc-${i}`);
if (!svgEl) continue;
try {
renderBarcode(svgEl, cachedLines[i]);
} catch (e) {
errCount++;
const el = document.getElementById(`content-${i}`);
if (el) {
el.className = 'barcode-error-content';
el.innerHTML = `<div class="error-label">无法生成</div>
<div class="error-text">${escHtml(cachedLines[i])}</div>
<div class="error-hint">内容包含不支持的字符</div>`;
}
}
}
}
errLabel.style.display = errCount > 0 ? 'inline' : 'none';
errNumEl.textContent = errCount;
}
/* ══════════════════════════════════════════════════
reRenderBarcodes()
Called by slider changes — skips DOM rebuild,
only re-runs JsBarcode on existing SVG elements.
══════════════════════════════════════════════════ */
async function reRenderBarcodes() {
if (cachedLines.length === 0) return;
const myToken = ++genToken;
for (let start = 0; start < cachedLines.length; start += RENDER_CHUNK) {
if (myToken !== genToken) return;
await rAF();
const end = Math.min(start + RENDER_CHUNK, cachedLines.length);
for (let i = start; i < end; i++) {
const svgEl = document.getElementById(`bc-${i}`);
// skip error cards (no width attr set by JsBarcode)
if (!svgEl || !svgEl.hasAttribute('width')) continue;
try { renderBarcode(svgEl, cachedLines[i]); } catch(e) {}
}
}
}
/* ── Selection ── */
function toggleSelect(i) {
const row = document.getElementById(`row-${i}`);
if (!row) return;
if (selected.has(i)) { selected.delete(i); row.classList.remove('selected'); }
else { selected.add(i); row.classList.add('selected'); }
updateSelToolbar();
}
function selectAll() {
// use cachedLines.length — avoids re-parsing
for (let i = 0; i < cachedLines.length; i++) {
selected.add(i);
const row = document.getElementById(`row-${i}`);
if (row) row.classList.add('selected');
}
updateSelToolbar();
}
function clearSelection() {
selected.forEach(i => {
const r = document.getElementById(`row-${i}`);
if (r) r.classList.remove('selected');
});
selected.clear();
updateSelToolbar();
}
function updateSelToolbar() {
selCountEl.textContent = selected.size;
selToolbar.classList.toggle('visible', selected.size > 0);
}
/* ── Filter to selected ── */
function filterToSelected() {
if (selected.size === 0) return;
undoSnapshot = inputEl.value;
const kept = Array.from(selected).sort((a, b) => a - b).map(i => cachedLines[i]);
inputEl.value = kept.join('\n');
document.getElementById('undo-bar').classList.add('visible');
showToast(`输入框已更新,保留 ${kept.length} 条`);
generate();
}
function undoFilter() {
if (undoSnapshot === null) return;
inputEl.value = undoSnapshot;
undoSnapshot = null;
document.getElementById('undo-bar').classList.remove('visible');
showToast('已复原');
generate();
}
/* ══════════════════════════════════════════════════
buildImage()
- Collects SVG dimensions up-front
- Fires ALL svgToImage() conversions in parallel
via Promise.all (huge speedup for large sets)
- Draws to canvas in a single synchronous pass
══════════════════════════════════════════════════ */
function svgToImage(svgEl) {
// Serialize once, reuse
const svgData = new XMLSerializer().serializeToString(svgEl);
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = () => { URL.revokeObjectURL(url); reject(); };
img.src = url;
});
}
async function buildImage() {
if (selected.size === 0) return;
const sortedIdx = Array.from(selected).sort((a, b) => a - b);
loadingEl.classList.add('show');
setLoadingMsg(`正在处理 ${sortedIdx.length} 个条码…`);
const M = BARCODE_SIDE_MARGIN;
const GAP = 6;
const CARD_PAD_V = 12;
const SCALE = 2;
try {
// ── Phase 1: collect SVG metadata (synchronous, instant) ──
const meta = sortedIdx.map(i => {
const svgEl = document.getElementById(`bc-${i}`);
if (!svgEl || !svgEl.getAttribute('width'))
return { i, error: cachedLines[i] };
return {
i,
svgEl,
natW: parseInt(svgEl.getAttribute('width')),
natH: parseInt(svgEl.getAttribute('height')),
};
});
// ── Phase 2: ALL SVG→Image conversions in parallel ──
setLoadingMsg(`正在并行加载 ${sortedIdx.length} 张条码图像…`);
// Process in parallel batches of IMG_PARALLEL to avoid browser limits
const items = [];
for (let start = 0; start < meta.length; start += IMG_PARALLEL) {
const batch = meta.slice(start, start + IMG_PARALLEL);
const results = await Promise.all(batch.map(m => {
if (m.error !== undefined) return Promise.resolve(m);
return svgToImage(m.svgEl)
.then(img => ({ ...m, img }))
.catch(() => ({ i: m.i, error: cachedLines[m.i] }));
}));
items.push(...results);
const done = Math.min(start + IMG_PARALLEL, meta.length);
setLoadingMsg(`已加载 ${done} / ${meta.length}…`);
}
// ── Phase 3: compute canvas size ──
const maxW = items.reduce((m, it) => it.natW ? Math.max(m, it.natW) : m, 0);
const canvasW = maxW + M * 2;
const totalH = items.reduce((s, it) =>
s + (it.natH ? it.natH + CARD_PAD_V * 2 : 40), 0
) + GAP * (items.length - 1);
// ── Phase 4: draw (synchronous, fast) ──
setLoadingMsg('正在合成图片…');
await rAF(); // let loading msg update render before heavy canvas work
const canvas = document.createElement('canvas');
canvas.width = canvasW * SCALE;
canvas.height = totalH * SCALE;
const ctx = canvas.getContext('2d');
ctx.scale(SCALE, SCALE);
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvasW, totalH);
let y = 0;
items.forEach((it, idx) => {
const cardH = it.natH ? it.natH + CARD_PAD_V * 2 : 40;
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, y, canvasW, cardH);
ctx.strokeStyle = '#DDDDD8';
ctx.lineWidth = 0.5;
ctx.strokeRect(0.25, y + 0.25, canvasW - 0.5, cardH - 0.5);
if (it.img) {
const x = M + (maxW - it.natW) / 2;
ctx.drawImage(it.img, x, y + CARD_PAD_V, it.natW, it.natH);
} else {
ctx.fillStyle = '#C0604A';
ctx.font = '11px Courier New';
ctx.fillText('无法生成: ' + (it.error || ''), M, y + 24);
}
y += cardH + (idx < items.length - 1 ? GAP : 0);
});
// ── Phase 5: export to blob ──
canvas.toBlob(blob => {
lastImageBlob = blob;
const url = URL.createObjectURL(blob);
document.getElementById('modal-img').src = url;
const canClipboard = !!(navigator.clipboard && window.ClipboardItem);
document.getElementById('modal-hint').textContent = canClipboard
? '点击「复制图片」可直接粘贴到微信/钉钉等,或在图片上右击 → 复制图片'
: '此浏览器不支持一键复制,请在图片上右击 → 复制图片';
loadingEl.classList.remove('show');
document.getElementById('img-modal').classList.add('show');
}, 'image/png');
} catch(e) {
loadingEl.classList.remove('show');
alert('图片生成失败:' + e.message);
}
}
function setLoadingMsg(msg) {
if (loadingMsg) loadingMsg.textContent = msg;
}
/* ── Clipboard ── */
async function copyToClipboard() {
if (!lastImageBlob) return;
const btn = document.getElementById('copy-btn');
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': lastImageBlob })]);
btn.textContent = '✓ 已复制';
btn.classList.add('copied');
showToast('图片已复制,可直接粘贴发送');
setTimeout(() => { btn.textContent = '复制图片'; btn.classList.remove('copied'); }, 2500);
} catch(e) {
showToast('复制失败,请右击图片手动复制');
}
}
function closeModal() {
document.getElementById('img-modal').classList.remove('show');
const img = document.getElementById('modal-img');
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = '';
lastImageBlob = null;
const btn = document.getElementById('copy-btn');
btn.textContent = '复制图片';
btn.classList.remove('copied');
}
function closeModalOutside(e) {
if (e.target === document.getElementById('img-modal')) closeModal();
}
/* ── Toast ── */
let toastTimer;
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2400);
}
/* ── Misc ── */
function clearAll() { inputEl.value = ''; generate(); inputEl.focus(); }
inputEl.addEventListener('input', () => {
document.getElementById('undo-bar').classList.remove('visible');
undoSnapshot = null;
debounce(generate, 320);
});
// Sliders: only re-render SVGs, skip full DOM rebuild
barWidthEl.addEventListener('input', () => {
wValEl.textContent = barWidthEl.value;
debounce(reRenderBarcodes, 180);
});
barHeightEl.addEventListener('input', () => {
hValEl.textContent = barHeightEl.value + ' px';
debounce(reRenderBarcodes, 180);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
generate();
</script>
</body>
</html>

浙公网安备 33010602011771号