自制条形码批量生成工具(纯HTML,巴枪扫码专用)

工具地址:本文末尾附完整源码,复制另存为 .html 即可本地使用,无需安装任何软件。


功能介绍

核心功能

  • 批量输入:在左侧文本框里每行粘贴一个单号,实时自动生成条码,无需点按钮
  • FORMAT:固定使用 CODE128 格式,兼容快递单号(数字+字母+特殊字符全支持)
  • 尺寸调节:两个滑块分别控制条宽和高度,条宽越大巴枪越容易读取

勾选与导出

这是这个工具最实用的功能,专门为「扫码出问题时需要重发」的场景设计:

  1. 用巴枪扫完一批条码,发现有 20 条扫描失败
  2. 在页面上勾选这 20 条(点击卡片即可选中)
  3. 点「生成图片」→ 弹出预览窗口
  4. 点「复制图片」→ 图片直接进剪贴板,粘贴发微信/钉钉给同事
  5. 或在图片上右键 → 复制图片

仅保留已选

勾选有问题的条码后,点「仅保留已选」,输入框里的内容会自动更新,只剩下你勾选的那几条单号。可以直接复制出去重新查单号状态。操作完还可以点「↩ 复原」回到完整列表。

其他细节

  • 全选 / 取消选择
  • 生成的图片宽度自适应条码宽度,没有多余的右侧空白
  • 图片为 2× 分辨率,清晰不模糊
  • 生成几百条也不卡,渲染分帧进行

使用方法

  1. 下载本文末尾的 HTML 源码,保存为 条形码生成器.html
  2. 双击用浏览器打开(推荐 Chrome / Edge)
  3. 把单号粘贴进左侧文本框,一行一个
  4. 将屏幕对准巴枪扫描,或勾选后生成图片发给别人

效果截图

左侧输入区 + 右侧条码列表,勾选后顶部出现蓝色操作栏

┌─────────────────┬──────────────────────────────────────┐
│  输入内容        │  [已选 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="每行一个条码内容&#10;&#10;SF1234567890&#10;YT9876543210&#10;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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>

posted @ 2026-05-03 10:31  奶油小仙男  阅读(6)  评论(0)    收藏  举报