Files
jiu/docs/design/label-template-editor.html
T
wangjia d67295234e refactor(client): 抽声明式 LabelTemplate 单源,价签渲染改读模型(P0)
价签版式原 100% 硬编码在四条渲染路径。引入 label_template.dart
声明式模型(纸张/打印/抬头/文字栈/QR/条码),builtinDefault() 逐值冻结
重构前常量;画布渲染器 _renderLabelBitmap 与热敏 _printFlatLabelThermal
改为纯读模型,坐标统一到画布权威。facade/web stub 加 template 可选参
(null→默认,现有调用点行为不变)。

新增 label_render_golden 零回归基准:默认模板渲染与重构前逐像素零 diff。
附评审通过的设计文档 label-template-editor.html(5 项决策)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-29 00:00:13 +08:00

206 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --warn:#B45309; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
h1{font-size:20px;margin:0 0 4px;}
h2{font-size:16px;margin:26px 0 8px;color:var(--primary-dark);border-left:4px solid var(--primary);padding-left:10px;}
h3{font-size:14px;margin:18px 0 6px;color:var(--accent);}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;max-width:1000px;margin-bottom:16px;}
p{font-size:14px;margin:6px 0;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
ol,ul{font-size:14px;margin:6px 0;padding-left:22px;}
li{margin:5px 0;}
table{width:100%;border-collapse:collapse;font-size:13px;max-width:1000px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border:1px solid var(--border);}
td{padding:9px 10px;border:1px solid #EEF1F5;vertical-align:top;}
.tag{font-size:11px;padding:2px 8px;border-radius:10px;display:inline-block;}
.tag.ok{background:#E6F3EC;color:var(--success);}
.tag.warn{background:#FFF4E5;color:var(--warn);}
.tag.no{background:var(--danger-bg);color:var(--danger);}
.tag.rec{background:#E7F0FB;color:var(--primary-dark);}
.lead{font-size:14px;background:#F0F6FF;border-left:3px solid var(--primary);padding:10px 14px;border-radius:4px;max-width:1000px;}
.flow{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#1d2430;color:#e6edf6;padding:14px 18px;border-radius:8px;max-width:1000px;overflow:auto;line-height:1.7;}
.decision{background:#FFF9EC;border:1px solid #F0DDB0;border-radius:10px;padding:14px 18px;max-width:1000px;margin-bottom:16px;}
.decision h3{color:var(--warn);margin-top:0;}
.rec-box{background:#EEF6F0;border:1px solid #BFE0CB;border-radius:8px;padding:8px 12px;margin:8px 0;font-size:13px;}
.rec-box b{color:var(--success);}
.fix{background:#FDECEC;border:1px solid #F3C6C6;border-radius:8px;padding:8px 12px;margin:8px 0;font-size:13px;}
.fix b{color:var(--danger);}
.wire{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#fbfcfe;border:1px solid var(--border);border-radius:8px;padding:14px 16px;white-space:pre;overflow:auto;max-width:1000px;color:#333;line-height:1.5;}
.fl{color:var(--muted);font-size:11.5px;}
</style>
</head>
<body>
<h1>标签模板编辑器 · 设计方案(代码实证版 v2)</h1>
<div class="sub">给「设置 → 打印模板 · 商品价签」的「编辑」按钮做可视化模板编辑器 · 2026-08-28 · 本版所有结论已逐行核对源码(不采信二手调研)· 方案评审(未实现)</div>
<div class="lead">
目标不变:店家自定义价签版式(选字段 / 调字号字体位置 / 实时预览 / 多样式命名 / 打印参数进高级)。
<b>本版把上一版两处关键判断按源码订正了</b>:① 二维码走的是<b>位图</b>不是原生指令;
② PDF / Web 两条路径用的是 <b>flexbox 流式布局、根本没有绝对坐标</b>——这直接改变了「统一渲染」的做法。
结论:真正要数据驱动的只有<b>画布(预览) + 热敏(实打)</b>这一条主线,PDF/Web 用「贴位图」收口即可,
<b>比上一版设想的工作量更小、风险更低</b>
</div>
<h2>1. 现状(逐行核对源码)</h2>
<div class="card">
<p>标签有<b>四条输出路径</b>,各自布局机制如下(已核对,非调研二手):</p>
<table>
<tr><th>路径</th><th>位置</th><th>何时走</th><th>布局机制</th><th>条码 / 二维码</th></tr>
<tr><td><b>画布</b></td><td><code>print_util_stub.dart:246</code> <code>_renderLabelBitmap</code></td><td>预览弹窗 + 热敏文字光栅(同一函数,<code>drawQr/drawBarcode=false</code> 供热敏)</td><td><b>绝对坐标</b>(逻辑 320×1604× 超采样)</td><td>条码 <code>_drawBarcode</code> 画黑条(:315)QR <code>drawImageRect</code>(:295)</td></tr>
<tr><td><b>热敏 TSPL</b></td><td>同文件 <code>_printFlatLabelThermal:351</code></td><td>检测到 DL-888B 等热敏机(真实主力)</td><td>文字=画布光栅 <code>BITMAP</code>(:394)<b>绝对坐标</b></td><td>条码=原生 <code>BARCODE</code>(:451)QR=<b>位图</b> <code>BITMAP</code>(:427)</td></tr>
<tr><td>PDF 回退</td><td>同文件 <code>printProductLabelImpl:463</code></td><td><b>只在检测不到热敏机时</b>(:491-499)</td><td><b>flexbox 流式</b><code>pw.Column/Row/Expanded</code>),<b>无绝对坐标</b></td><td>条码 <code>pw.BarcodeWidget</code> 矢量(:578)QR <code>pw.Image</code>(:593)</td></tr>
<tr><td>Web HTML</td><td><code>print_util_web.dart:21</code></td><td>Web 构建(<code>window.print()</code></td><td><b>CSS flexbox 流式</b>(:82-124)<b>无绝对坐标</b></td><td>条码 <code>toSvg</code> 矢量(:64)QR base64 img(:143)</td></tr>
</table>
<div class="fix"><b>订正 ①(我上一版说错的)</b>:热敏路径里二维码是 <code>BITMAP $qrX,$qrY,...</code>(第 427 行)——<b>位图,不是原生 QRCODE 指令</b>。它「相对清晰」的原因是<b>独立渲染</b>:把 QR PNG 按目标尺寸 130×130 单独解码 + 1:1 逐像素 128 阈值二值化(:422-436)<b>不经过文字那条 4×4 超采样降采样链路</b>。只有<b>条码</b>是真·原生 TSPL <code>BARCODE</code> 指令(:451)。</div>
<div class="fix"><b>订正 ②(影响架构)</b>PDF 和 Web <b>不是绝对坐标布局,是 flexbox 流式</b>PDF 用 <code>pw.Column/Row/Expanded</code>Web 用 CSS flex)。这意味着——如果编辑器让用户「把字段拖到某个 x/y」,这套坐标<b>天然只对得上画布/热敏,对不上 PDF/Web 的 flex 流</b>。要让 PDF/Web 认坐标,就得把它们的 flex 布局整个换掉。这正是下一节改方案的原因。</div>
<h3>1.1 画布布局其实是「自适应」的,不是死坐标</h3>
<p>关键细节(<code>_renderLabelBitmap:319-340</code>):品名字号 <code>_fitFont</code> 按宽度<b>自适应</b> clamp(12,18);文字块(品名 + 两行副字段)在剩余竖直空间里<b>自动均分留白居中</b>;副字段 <code>_labelSubFields:222</code> 把「编号+系列」拼一行、「规格+日期」拼一行,<b>空字段自动跳过、整行塌缩</b>。也就是说当前版式<b>不是一组固定像素位置</b>,而是「几个区域 + 自动排版」。这点直接决定版式模型该怎么建(见 §3)。</p>
<h3>1.2 实际渲染 / 未渲染的字段</h3>
<p>参与渲染:<code>shopName / name / code / series / spec / productionDate / qr / barcode</code><b>已在 <code>LabelData</code> 但从不渲染</b><code>batchNo / remark / shopAddress / shopPhone</code>(<code>label_data.dart:15-20</code>) → 编辑器可直接把它们做成可选字段,<b>数据层零改动</b></p>
<h3>1.3 入口与存储(已核对,利好)</h3>
<ul>
<li><b>「编辑」是空实现</b><code>device_management_screen.dart:991</code><code>_snack('模板编辑即将上线')</code>;「预览」<code>_previewLabelTemplate:1002</code> 造示例 <code>LabelData</code><code>LabelPreviewDialog</code><b>打印模板卡仅桌面渲染</b> → 编辑器只做桌面。</li>
<li><b>打印入口是扁平参数</b><code>print_util.dart:15</code> <code>printProductLabel(...)</code><code>:45</code> <code>renderLabelPreview(label)</code> 都不带模板概念 → 要加一个 <code>template</code> 参数贯穿下去(改动集中在 facade + 两个 Impl)。</li>
<li><b>后端 custom_fields 已支持增量 merge</b><code>shop.go:68-79</code> 读现有 custom_fields → 合并新键 → 保留旧键、店隔离,还有钉死契约的测试 <code>shop_test.go:31</code>。前端 <code>_savePeripherals:1031</code> 就是这么存外设的。<b>→ 模板存 custom_fields 零后端改动、且不会冲掉外设等已有键。</b></li>
</ul>
</div>
<h2>2. 渲染架构(按源码修订:画布为唯一权威 + PDF/Web 贴位图)</h2>
<div class="card">
<p>上一版我提「抽一层绘制指令 IR 给四路径各写 adapter」。读完代码后<b>这是过度设计</b>——因为 PDF/Web 是流式布局、且只是回退/次要面。更省的做法:</p>
<div class="rec-box"><b>让「画布」成为唯一的几何权威。</b> 模板 = 画布上的绝对布局;画布同时驱动<b>预览</b><b>热敏文字光栅</b>(热敏文字本就是画布栅格化的)。PDF/Web 直接<b>嵌入画布渲染出的位图</b>,不再各自摆版。四套手工对齐的布局 → 收敛成<b>一套画布渲染器 + 两个「贴位图」消费者 + 热敏既有的条码/QR 特判</b></div>
<table>
<tr><th></th><th>改法</th><th>条码/QR 清晰度</th></tr>
<tr><td>预览</td><td>画布 → PNG(现状即是)</td><td>屏幕显示,无所谓</td></tr>
<tr><td><b>热敏(主力)</b></td><td>文字=画布光栅(现状);条码=原生 <code>BARCODE</code>、QR=独立 <code>BITMAP</code><b>都保留</b>);<b>唯一改动</b>:它们的坐标从写死的 <code>const</code> 改成<b>由模板算出</b></td><td><b>不降级</b>:条码原生矢量、QR 独立 1:1 阈值,扫码可靠性照旧</td></tr>
<tr><td>PDF 回退</td><td><b>整个 <code>pw.*</code> 组件树换成单张 <code>pw.Image(画布位图)</code></b> 满页铺</td><td>回退面向办公激打/喷墨(≥300dpi),画布 1280px≈812dpi 栅格化,条码可扫</td></tr>
<tr><td>Web</td><td><b>HTML flex 换成 <code>&lt;img&gt;</code> 画布 PNG</b>40×20mm</td><td>同上,Web 打印走高 dpi,无虞</td></tr>
</table>
<p class="fl">注:条码/QR 光栅化「扫不出」的风险<b>只发生在热敏 203dpi</b>——所以热敏那条<b>坚决保留</b>原生条码 + 独立 QR,不动。PDF/Web 是高 dpi 面,贴位图完全够用。</p>
<div class="rec-box">附带红利:因为 PDF/Web 只是回退/次要面,<b>它们的模板落地可以延后</b>——先做画布+热敏(覆盖真实打印 + 预览),PDF/Web 后面「贴位图」一句话的事。<b>比上一版少写两个 adapter。</b></div>
</div>
<h2>3. 版式模型:区域 + 自动栈(贴合现有自适应布局)</h2>
<div class="card">
<p>因为 §1.1 里现状是「区域 + 自动排版」而非死坐标,模型<b>不宜做成纯自由画布</b>(否则丢掉品名自适应、竖直自动居中这些现有优点,且短名不再放大、隐藏字段不再回填空间 → 反而变差)。建议<b>区域(region) + 元素(element)</b> 混合模型:</p>
<div class="wire">LabelTemplate {
id, name // 可命名 / 重命名
paper: { width_mm:40, height_mm:20, dpi:203 }
print: { density:10, speed:2, gap_mm:2, direction:1, copies:1 } // 高级设置
regions: {
header: { visible, height_mm, bg, fields:[ text... ] } // 深蓝抬头
textStack:{ x,y,w,h, align, fields:[ text... ] } // 自动竖直均分(保留现有算法)
qr: { visible, x, y, size } // 右列二维码
barcode: { visible, x, y, w, h, symbology:code128, showText } // 左下条码
}
}
text field { binding, staticText?, fontSize(或 auto), bold, align, color }
binding ∈ shopName|shopAddress|shopPhone|name|code|series|spec|
batchNo|productionDate|remark|static</div>
<ul>
<li><b>字段开关</b>:勾选控制 field 是否进 textStack / 是否显示 QR/条码。</li>
<li><b>调样式</b>:字号(或 auto 自适应)、粗体、对齐、颜色。</li>
<li><b>调位置</b>QR / 条码 / 抬头 = <b>可移动可缩放的区域</b>(覆盖「调位置」诉求最实用的部分);textStack 内字段 = <b>调顺序 + 可选手动微调</b>,默认仍走现有自动均分。</li>
<li><b>零回归</b>:内置「默认模板」= 把现有 <code>const</code>headerH=24 / qrSize=134 / barTop=114 …)原样编码;渲染器保留自动栈算法。用字体探针 / golden 位图对照守逐像素一致。</li>
</ul>
</div>
<h2>4. 存储:shop.custom_fields(已被源码证实可行、零后端改动)</h2>
<div class="card">
<p>模板存 <code>shop.custom_fields.label_templates</code>(数组)+ <code>label_template_active</code>(默认 id)。依据:</p>
<ul>
<li>后端 <code>shop.go:68-79</code> 对 custom_fields 做<b>增量 merge</b> 并有测试钉死 → 写 <code>label_templates</code> 不碰 <code>peripherals</code> 等旧键,店隔离。<b>无需新表/迁移/接口。</b></li>
<li>前端照抄 <code>_savePeripherals:1031</code>:读 <code>shopInfoProvider</code> → 改 <code>customFields</code> map → <code>shopRepositoryProvider.updateInfo({...})</code></li>
<li>店级 → 跨设备/员工同步;打印机选择继续留本地 <code>label_printer</code>(<code>label_preview_dialog.dart:11</code>) 不动。</li>
</ul>
</div>
<h2>5. 编辑器交互(桌面专属)</h2>
<div class="card">
<p>价签卡「编辑」→ 大对话框。左预览右属性,改动 debounce ~150ms 调 <code>renderLabelPreview(sample, template)</code> 重渲染(预览路径现成)。</p>
<div class="wire">┌─ 标签模板编辑器 ──────────────────────────────────────────┐
│ 模板:[商品价签(默认)▾] [新建][复制][重命名][删除][设默认] │
├──────────────────────────┬──────────────────────────────┤
│ 实时预览(真实渲染位图) │ 字段 │
│ ┌──────────────────┐ │ ☑店名 ☑品名 ☑编号 ☑系列 │
│ │ 岩美酒行旗舰店 │ │ ☑规格 ☑生产日期 ☐批次 ☐备注 │
│ │ 茅台飞天五十三度 │▨ │ ☑二维码 ☑底部条码 │
│ │ P1001 飞天 │▨ │ ──────────────────────────── │
│ │ 53度 2024-06-01 │ │ 选中「品名」: │
│ │ ▐▌▐ ▌▐▌▌▐ ▌▐▌▐ │ │ 字号[自适应▾] □粗体 │
│ └──────────────────┘ │ 对齐[居中▾] 颜色[■] │
│ [示例▾][用真实商品▾] │ 选中「二维码」: X[178]Y[25] │
│ │ 尺寸[134] │
│ │ ──────────────────────────── │
│ │ ▸ 高级(纸张/DPI/密度/速度) │
├──────────────────────────┴──────────────────────────────┤
│ [取消] [保存] │
└──────────────────────────────────────────────────────────┘</div>
</div>
<h2>6. 两个需你定调的点(含源码约束)</h2>
<div class="decision">
<h3>决策 A:字段自由度 —— 纯自由画布会丢现有自适应</h3>
<p>你要「可调位置」,但源码现状是<b>自适应排版</b>(品名按宽度放大、文字块自动竖直居中、空字段塌缩)。三档:</p>
<ul>
<li>① 槽位式:只开关+调样式不能移位。稳,但不满足「调位置」。</li>
<li><b>② 区域+自动栈</b> <span class="tag rec">推荐</span>QR/条码/抬头做成<b>可移动缩放的区域</b>(满足「调位置」最实用部分);文字块内保留自动均分。<b>既能调位置又不丢现有优点。</b></li>
<li>③ 纯自由画布:每字段任意 x/y。最强,但<b>丢掉品名自适应/自动居中</b>40×20mm 上还易重叠溢出。</li>
</ul>
<p>推荐 <b></b>。若你更想要 ③ 的「每个字段都能拖」,我按纯坐标做,但要接受品名不再自动放大、隐藏字段不再自动回填空间。</p>
</div>
<div class="decision">
<h3>决策 B:字体与颜色 —— 热敏是单色,有物理约束</h3>
<ul>
<li><b>颜色</b>:热敏机是<b>单色介质</b>,文字二值化后全是黑(抬头深蓝也退成黑)。所以「字段颜色」<b>只对预览/PDF/Web 有视觉意义,对热敏实打无效</b>。要不要暴露颜色,取决于你在不在乎屏幕/PDF 上的彩色观感。</li>
<li><b>字体</b><code>_drawText:166</code> 写死 <code>fontFamily:'NotoSansSC'</code>,且热敏要求打包字体。支持「换字体」= 再打包字体(如宋体,<b>每款中文约 48MB</b> 增包体)+ 参数化 fontFamily。<b>MVP 建议只做字号/粗体</b>(零增包),多字体二期按需。</li>
</ul>
</div>
<h2>7. 落地路线(design-first,主线先行)</h2>
<div class="card">
<table>
<tr><th></th><th>内容</th><th>验收</th></tr>
<tr><td>P0 建模</td><td>定义 <code>LabelTemplate</code>(区域+自动栈)+ 画布渲染器读模型;内置默认模板=现 constfacade/Impl 加 <code>template</code> 参数</td><td>字体探针 / golden:默认模板逐像素零回归</td></tr>
<tr><td>P1 原型</td><td><code>design/prototype/</code> 新增编辑器桌面原型</td><td>5180 serve URL 评审,过了再写代码</td></tr>
<tr><td>P2 编辑器</td><td>编辑器 UI + custom_fields 存储 + 预览联动 + 多模板/重命名;<b>热敏</b>条码/QR 坐标改模板驱动</td><td>桌面建/存/切模板,预览实时刷新,热敏按模板出签</td></tr>
<tr><td>P3 收口(可延后)</td><td>PDF/Web 改「贴画布位图」→ 自定义模板四面一致</td><td>同模板热敏/PDF/Web 观感一致</td></tr>
</table>
<p class="tag warn" style="display:block;max-width:980px;padding:8px 12px;">按铁律:P1 原型过审前不写实现代码;前端改动原型与代码同提交。本方案即 P1 前评审。</p>
</div>
<h2>8. 待你拍板</h2>
<div class="card">
<table>
<tr><th>#</th><th>决策</th><th>推荐</th></tr>
<tr><td>1</td><td>模板存哪</td><td><b>shop.custom_fields</b>(源码证实零后端改动、店级同步)</td></tr>
<tr><td>2</td><td>渲染统一</td><td><b>画布为唯一权威 + PDF/Web 贴位图</b>;热敏保留原生条码/独立 QR,仅坐标受模板驱动</td></tr>
<tr><td>3</td><td>字段自由度</td><td><b>② 区域+自动栈</b>(可调位置又不丢自适应)</td></tr>
<tr><td>4</td><td>字体</td><td>MVP <b>只字号/粗体</b>,多字体二期(增包体)</td></tr>
<tr><td>5</td><td>颜色</td><td>热敏无效 → 建议 MVP <b>不暴露颜色</b>(或仅作预览观感),你定</td></tr>
</table>
<p>给个方向(认同/改哪条),我就做 P0 建模并出 P1 编辑器原型。</p>
</div>
<div class="sub" style="margin-top:24px;">核对源码:<code>print_util_stub.dart</code>(:158-617) / <code>print_util_web.dart</code> / <code>label_data.dart</code> / <code>print_util.dart</code> / <code>label_preview_dialog.dart</code> / <code>device_management_screen.dart</code>(:930-1035) / 后端 <code>shop.go</code>(:51-79)+<code>shop_test.go</code> · 本方案未改任何代码。</div>
</body>
</html>