diff --git a/client/lib/core/utils/label_template.dart b/client/lib/core/utils/label_template.dart new file mode 100644 index 0000000..78cda83 --- /dev/null +++ b/client/lib/core/utils/label_template.dart @@ -0,0 +1,507 @@ +import 'label_data.dart'; + +/// 价签版式声明式模型(单一真源)。 +/// +/// 设计铁律(2026-08-28 已评审通过): +/// - **画布 = 唯一几何权威**。渲染器(`_renderLabelBitmap`)是「纯绝对坐标画笔」, +/// header/textStack/qr/barcode 的坐标、字号、开关全从本模型读; +/// 预览与热敏文字光栅共用同一套画布,所见即所得。 +/// - 热敏保留原生 BARCODE + 独立位图 QR,但**坐标同样由本模型驱动**(决策②), +/// 与画布同源(不再各自硬编码)。 +/// - 编辑器负责「算坐标」(移动 QR 时联动重算左列宽度等),渲染器只读绝对值。 +/// - `builtinDefault()` 把重构前 `print_util_stub.dart` 的硬编码常量逐值冻结为绝对值, +/// 保证默认模板渲染与重构前**逐像素一致**(由 `label_render_golden_test.dart` 守闸)。 +/// +/// MVP 边界:字体只做字号 + 粗体(不换字族、不增包,决策④);颜色不暴露(热敏单色,决策⑤)。 +/// 存储:整棵树 toJson 存 `shop.custom_fields.label_templates`(List)+ `label_template_active`(id)。 + +/// 文本水平对齐(MVP:左/中/右;渲染器映射到 `ui.TextAlign`)。 +enum LabelAlign { left, center, right } + +/// 可绑定到标签字段的商品/门店数据源。 +enum LabelBinding { + shopName, + name, + code, + series, + spec, + productionDate, + batchNo, + remark, + shopAddress, + shopPhone, +} + +LabelAlign _alignFrom(String? s) => + LabelAlign.values.firstWhere((e) => e.name == s, + orElse: () => LabelAlign.center); + +LabelBinding? _bindingFrom(String? s) { + for (final e in LabelBinding.values) { + if (e.name == s) return e; + } + return null; +} + +/// 把绑定解析为其在 [d] 上的字符串值(空字段返回空串;日期截断到 10 字符)。 +String resolveBinding(LabelBinding b, LabelData d) { + switch (b) { + case LabelBinding.shopName: + return d.shopName; + case LabelBinding.name: + return d.name; + case LabelBinding.code: + return d.code; + case LabelBinding.series: + return d.series ?? ''; + case LabelBinding.spec: + return d.spec ?? ''; + case LabelBinding.productionDate: + final p = d.productionDate ?? ''; + return p.length > 10 ? p.substring(0, 10) : p; + case LabelBinding.batchNo: + return d.batchNo ?? ''; + case LabelBinding.remark: + return d.remark ?? ''; + case LabelBinding.shopAddress: + return d.shopAddress; + case LabelBinding.shopPhone: + return d.shopPhone; + } +} + +/// 纸张与打印分辨率。逻辑像素 = mm / 25.4 * dpi(四舍五入)。 +class LabelPaper { + double widthMm; + double heightMm; + int dpi; + + LabelPaper({this.widthMm = 40, this.heightMm = 20, this.dpi = 203}); + + int get logicalW => (widthMm / 25.4 * dpi).round(); // 40mm@203 → 320 + int get logicalH => (heightMm / 25.4 * dpi).round(); // 20mm@203 → 160 + + Map toJson() => + {'widthMm': widthMm, 'heightMm': heightMm, 'dpi': dpi}; + + factory LabelPaper.fromJson(Map j) => LabelPaper( + widthMm: (j['widthMm'] as num?)?.toDouble() ?? 40, + heightMm: (j['heightMm'] as num?)?.toDouble() ?? 20, + dpi: (j['dpi'] as num?)?.toInt() ?? 203, + ); + + LabelPaper copy() => LabelPaper(widthMm: widthMm, heightMm: heightMm, dpi: dpi); +} + +/// 热敏 TSPL 打印参数(高级配置区暴露)。 +class LabelPrint { + int density; + int speed; + int direction; + int copies; + double gapMm; + + LabelPrint({ + this.density = 10, + this.speed = 2, + this.direction = 1, + this.copies = 1, + this.gapMm = 2, + }); + + Map toJson() => { + 'density': density, + 'speed': speed, + 'direction': direction, + 'copies': copies, + 'gapMm': gapMm, + }; + + factory LabelPrint.fromJson(Map j) => LabelPrint( + density: (j['density'] as num?)?.toInt() ?? 10, + speed: (j['speed'] as num?)?.toInt() ?? 2, + direction: (j['direction'] as num?)?.toInt() ?? 1, + copies: (j['copies'] as num?)?.toInt() ?? 1, + gapMm: (j['gapMm'] as num?)?.toDouble() ?? 2, + ); + + LabelPrint copy() => LabelPrint( + density: density, + speed: speed, + direction: direction, + copies: copies, + gapMm: gapMm); +} + +/// 抬头栏:深蓝底反白店名(binding 固定为 shopName)。 +class LabelHeader { + bool show; + double height; // 逻辑像素 + double textX; + double textY; + double fontSize; + bool bold; + LabelAlign align; + + LabelHeader({ + this.show = true, + this.height = 24, + this.textX = 10, + this.textY = 4, + this.fontSize = 14, + this.bold = true, + this.align = LabelAlign.left, + }); + + Map toJson() => { + 'show': show, + 'height': height, + 'textX': textX, + 'textY': textY, + 'fontSize': fontSize, + 'bold': bold, + 'align': align.name, + }; + + factory LabelHeader.fromJson(Map j) => LabelHeader( + show: j['show'] as bool? ?? true, + height: (j['height'] as num?)?.toDouble() ?? 24, + textX: (j['textX'] as num?)?.toDouble() ?? 10, + textY: (j['textY'] as num?)?.toDouble() ?? 4, + fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14, + bold: j['bold'] as bool? ?? true, + align: _alignFrom(j['align'] as String?), + ); + + LabelHeader copy() => LabelHeader( + show: show, + height: height, + textX: textX, + textY: textY, + fontSize: fontSize, + bold: bold, + align: align); +} + +/// 大号自适应品名字段。fontSize == null → 在 [fontMin, fontMax] 内自适应放大。 +class LabelNameField { + bool show; + double? fontSize; // null = 自适应 + double fontMin; + double fontMax; + bool bold; + LabelAlign align; + + LabelNameField({ + this.show = true, + this.fontSize, + this.fontMin = 12, + this.fontMax = 18, + this.bold = true, + this.align = LabelAlign.center, + }); + + Map toJson() => { + 'show': show, + 'fontSize': fontSize, + 'fontMin': fontMin, + 'fontMax': fontMax, + 'bold': bold, + 'align': align.name, + }; + + factory LabelNameField.fromJson(Map j) => LabelNameField( + show: j['show'] as bool? ?? true, + fontSize: (j['fontSize'] as num?)?.toDouble(), + fontMin: (j['fontMin'] as num?)?.toDouble() ?? 12, + fontMax: (j['fontMax'] as num?)?.toDouble() ?? 18, + bold: j['bold'] as bool? ?? true, + align: _alignFrom(j['align'] as String?), + ); + + LabelNameField copy() => LabelNameField( + show: show, + fontSize: fontSize, + fontMin: fontMin, + fontMax: fontMax, + bold: bold, + align: align); +} + +/// 副字段行:一行内若干绑定按 4 空格拼接(任一为空则跳过;整行为空则该行消失)。 +/// 每行是「一个字段」的最小单元时即可实现「逐字段独立字号」。 +class LabelSubLine { + List bindings; + double fontSize; + bool bold; + LabelAlign align; + + LabelSubLine({ + required this.bindings, + this.fontSize = 14, + this.bold = false, + this.align = LabelAlign.center, + }); + + Map toJson() => { + 'bindings': bindings.map((b) => b.name).toList(), + 'fontSize': fontSize, + 'bold': bold, + 'align': align.name, + }; + + factory LabelSubLine.fromJson(Map j) => LabelSubLine( + bindings: ((j['bindings'] as List?) ?? const []) + .map((s) => _bindingFrom(s as String?)) + .whereType() + .toList(), + fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14, + bold: j['bold'] as bool? ?? false, + align: _alignFrom(j['align'] as String?), + ); + + LabelSubLine copy() => LabelSubLine( + bindings: List.of(bindings), + fontSize: fontSize, + bold: bold, + align: align); +} + +/// 左列文字区域:品名 + 若干副字段行,整体在 [top, bottom] 竖直区间内均分。 +class LabelTextStack { + double x; + double width; + double top; + double bottom; + double lineHeight; + LabelNameField name; + List subLines; + + LabelTextStack({ + this.x = 8, + this.width = 164, + this.top = 30, + this.bottom = 110, + this.lineHeight = 1.3, + required this.name, + required this.subLines, + }); + + Map toJson() => { + 'x': x, + 'width': width, + 'top': top, + 'bottom': bottom, + 'lineHeight': lineHeight, + 'name': name.toJson(), + 'subLines': subLines.map((l) => l.toJson()).toList(), + }; + + factory LabelTextStack.fromJson(Map j) => LabelTextStack( + x: (j['x'] as num?)?.toDouble() ?? 8, + width: (j['width'] as num?)?.toDouble() ?? 164, + top: (j['top'] as num?)?.toDouble() ?? 30, + bottom: (j['bottom'] as num?)?.toDouble() ?? 110, + lineHeight: (j['lineHeight'] as num?)?.toDouble() ?? 1.3, + name: j['name'] is Map + ? LabelNameField.fromJson( + (j['name'] as Map).cast()) + : LabelNameField(), + subLines: ((j['subLines'] as List?) ?? const []) + .map((m) => + LabelSubLine.fromJson((m as Map).cast())) + .toList(), + ); + + LabelTextStack copy() => LabelTextStack( + x: x, + width: width, + top: top, + bottom: bottom, + lineHeight: lineHeight, + name: name.copy(), + subLines: subLines.map((l) => l.copy()).toList(), + ); + + /// 组装非空副字段行(复刻旧 `_labelSubFields` 语义)。 + List composeSubs(LabelData d) { + final out = []; + for (final line in subLines) { + final parts = line.bindings + .map((b) => resolveBinding(b, d)) + .where((s) => s.isNotEmpty) + .toList(); + final s = parts.join(' '); + if (s.isNotEmpty) out.add(s); + } + return out; + } +} + +/// 二维码区域(正方形,绝对坐标)。 +class LabelQr { + bool show; + double x; + double y; + double size; + + LabelQr({this.show = true, this.x = 178, this.y = 25, this.size = 134}); + + Map toJson() => + {'show': show, 'x': x, 'y': y, 'size': size}; + + factory LabelQr.fromJson(Map j) => LabelQr( + show: j['show'] as bool? ?? true, + x: (j['x'] as num?)?.toDouble() ?? 178, + y: (j['y'] as num?)?.toDouble() ?? 25, + size: (j['size'] as num?)?.toDouble() ?? 134, + ); + + LabelQr copy() => LabelQr(show: show, x: x, y: y, size: size); +} + +/// 一维条码区域(Code128,内容 = 商品编号,绝对坐标)。 +class LabelBarcode { + bool show; + double x; + double y; + double width; + double height; + + LabelBarcode( + {this.show = true, + this.x = 8, + this.y = 114, + this.width = 164, + this.height = 40}); + + Map toJson() => + {'show': show, 'x': x, 'y': y, 'width': width, 'height': height}; + + factory LabelBarcode.fromJson(Map j) => LabelBarcode( + show: j['show'] as bool? ?? true, + x: (j['x'] as num?)?.toDouble() ?? 8, + y: (j['y'] as num?)?.toDouble() ?? 114, + width: (j['width'] as num?)?.toDouble() ?? 164, + height: (j['height'] as num?)?.toDouble() ?? 40, + ); + + LabelBarcode copy() => + LabelBarcode(show: show, x: x, y: y, width: width, height: height); +} + +/// 一套完整的价签版式。 +class LabelTemplate { + final String id; + String name; + LabelPaper paper; + LabelPrint print; + LabelHeader header; + LabelTextStack textStack; + LabelQr qr; + LabelBarcode barcode; + + LabelTemplate({ + required this.id, + required this.name, + required this.paper, + required this.print, + required this.header, + required this.textStack, + required this.qr, + required this.barcode, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'paper': paper.toJson(), + 'print': print.toJson(), + 'header': header.toJson(), + 'textStack': textStack.toJson(), + 'qr': qr.toJson(), + 'barcode': barcode.toJson(), + }; + + factory LabelTemplate.fromJson(Map j) { + final def = builtinDefault(); + Map? sub(String k) => + j[k] is Map ? (j[k] as Map).cast() : null; + return LabelTemplate( + id: (j['id'] as String?)?.isNotEmpty == true + ? j['id'] as String + : def.id, + name: (j['name'] as String?)?.isNotEmpty == true + ? j['name'] as String + : def.name, + paper: sub('paper') != null ? LabelPaper.fromJson(sub('paper')!) : def.paper, + print: sub('print') != null ? LabelPrint.fromJson(sub('print')!) : def.print, + header: + sub('header') != null ? LabelHeader.fromJson(sub('header')!) : def.header, + textStack: sub('textStack') != null + ? LabelTextStack.fromJson(sub('textStack')!) + : def.textStack, + qr: sub('qr') != null ? LabelQr.fromJson(sub('qr')!) : def.qr, + barcode: sub('barcode') != null + ? LabelBarcode.fromJson(sub('barcode')!) + : def.barcode, + ); + } + + LabelTemplate copyWith({String? id, String? name}) => LabelTemplate( + id: id ?? this.id, + name: name ?? this.name, + paper: paper.copy(), + print: print.copy(), + header: header.copy(), + textStack: textStack.copy(), + qr: qr.copy(), + barcode: barcode.copy(), + ); + + /// 内置默认模板:逐值冻结重构前 `print_util_stub.dart` 的画布常量。 + /// 改任一常量前先确认 `label_render_golden_test.dart` 仍零 diff。 + static LabelTemplate builtinDefault() => LabelTemplate( + id: 'builtin-default', + name: '默认版式', + paper: LabelPaper(widthMm: 40, heightMm: 20, dpi: 203), + print: LabelPrint(density: 10, speed: 2, direction: 1, copies: 1, gapMm: 2), + header: LabelHeader( + show: true, + height: 24, + textX: 10, + textY: 4, + fontSize: 14, + bold: true, + align: LabelAlign.left), + textStack: LabelTextStack( + x: 8, + width: 164, + top: 30, + bottom: 110, + lineHeight: 1.3, + name: LabelNameField( + show: true, + fontSize: null, + fontMin: 12, + fontMax: 18, + bold: true, + align: LabelAlign.center), + subLines: [ + LabelSubLine( + bindings: [LabelBinding.code, LabelBinding.series], + fontSize: 14, + bold: false, + align: LabelAlign.center), + LabelSubLine( + bindings: [LabelBinding.spec, LabelBinding.productionDate], + fontSize: 14, + bold: false, + align: LabelAlign.center), + ], + ), + qr: LabelQr(show: true, x: 178, y: 25, size: 134), + barcode: + LabelBarcode(show: true, x: 8, y: 114, width: 164, height: 40), + ); +} diff --git a/client/lib/core/utils/print_util.dart b/client/lib/core/utils/print_util.dart index 83ffc4a..659f8aa 100644 --- a/client/lib/core/utils/print_util.dart +++ b/client/lib/core/utils/print_util.dart @@ -5,6 +5,8 @@ import '../../models/stock_out.dart'; import '../errors/error_reporter.dart'; import 'label_data.dart'; export 'label_data.dart'; +import 'label_template.dart'; +export 'label_template.dart'; import 'order_print_meta.dart'; import '../theme/context_tokens.dart'; import '../../widgets/ds/ds_toast.dart'; @@ -25,6 +27,7 @@ Future printProductLabel({ String shopAddress = '', String shopPhone = '', String? printerName, + LabelTemplate? template, }) => printProductLabelImpl( qrBytes: qrBytes, @@ -39,11 +42,13 @@ Future printProductLabel({ shopAddress: shopAddress, shopPhone: shopPhone, printerName: printerName, + template: template, ); /// 渲染标签预览图(PNG 字节);Web 平台返回 null。 -Future renderLabelPreview(LabelData label) => - renderLabelPreviewImpl(label); +Future renderLabelPreview(LabelData label, + {LabelTemplate? template}) => + renderLabelPreviewImpl(label, template: template); /// 枚举当前系统所有可用打印机名;Web 平台返回空列表。 Future> listLabelPrinters() => listLabelPrintersImpl(); diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index dda5d97..6fe1703 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -25,6 +25,7 @@ import 'package:printing/printing.dart'; import '../../models/stock_in.dart'; import '../../models/stock_out.dart'; import 'label_data.dart'; +import 'label_template.dart'; import 'order_print_meta.dart'; Future _loadFont() async { @@ -158,7 +159,7 @@ bool _winRawPrint(String printerName, Uint8List data) { void _drawText( ui.Canvas canvas, String s, double x, double y, double size, double maxW, {bool bold = false, - bool center = false, + ui.TextAlign textAlign = ui.TextAlign.left, ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑) { if (s.isEmpty) return; @@ -166,7 +167,7 @@ void _drawText( fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊) fontSize: size, fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal, - textAlign: center ? ui.TextAlign.center : ui.TextAlign.left, + textAlign: textAlign, maxLines: 1, ellipsis: '', )) @@ -176,6 +177,22 @@ void _drawText( canvas.drawParagraph(p, ui.Offset(x, y)); } +/// 毫米数格式化为 TSPL 数值(整数不带小数点:40.0 → "40")。 +String _tsplMm(double v) => + v == v.roundToDouble() ? v.round().toString() : v.toString(); + +/// 版式对齐 → 画布 TextAlign。 +ui.TextAlign _uiAlign(LabelAlign a) { + switch (a) { + case LabelAlign.left: + return ui.TextAlign.left; + case LabelAlign.center: + return ui.TextAlign.center; + case LabelAlign.right: + return ui.TextAlign.right; + } +} + /// 在 canvas 上把 [data] 画成 Code128 一维条码(黑条,铺满 x,y,w,h 区域,不带人读字符)。 /// 扫码枪扫 1D 条码;条码内容 = 商品编号,与出库扫码一致。 void _drawBarcode( @@ -215,46 +232,19 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) { return size; } -/// 组装标签左列名下两行副字段(居中排布): -/// 行1 = 编号 + 度数(型号) 行2 = 规格(版本) + 生产日期 -/// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。 -/// 各字段为空则跳过(该行可只剩一个字段或整行消失);日期截断到 10 字符。四路渲染共用。 -List _labelSubFields( - String code, String? series, String? spec, String? productionDate) { - final date = (productionDate ?? '').isNotEmpty - ? (productionDate!.length > 10 - ? productionDate.substring(0, 10) - : productionDate) - : ''; - final line1 = [ - if (code.isNotEmpty) code, - if ((series ?? '').isNotEmpty) series!, - ].join(' '); - final line2 = [ - if ((spec ?? '').isNotEmpty) spec!, - if (date.isNotEmpty) date, - ].join(' '); - return [ - if (line1.isNotEmpty) line1, - if (line2.isNotEmpty) line2, - ]; -} - /// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。 /// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。 /// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。 Future _renderLabelBitmap({ - required String shop, - required String name, - required String code, - required List subs, // 左列逐行副字段:型号规格 / 日期(已过滤空) - required Uint8List qrBytes, + required LabelTemplate tpl, // 版式单源:坐标/字号/开关全从此读 + required LabelData data, // 待打印字段值 + qrBytes bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码 bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码 }) async { - const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸) + final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸) + final int h = tpl.paper.logicalH; // 20mm@203 → 160 const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐) - const sw = w * scale, sh = h * scale; + final int sw = w * scale, sh = h * scale; final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble())); @@ -271,63 +261,66 @@ Future _renderLabelBitmap({ canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()), ui.Paint()..color = cWhite); - // ── 深蓝店名头 ────────────────────────────────────────────── - const headerH = 24.0; - canvas.drawRect( - ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy); - _drawText(canvas, shop, 10, 4, 14, w - 20, bold: true, color: cCream); + // ── 深蓝店名头(binding 固定 shopName)────────────────────────── + final header = tpl.header; + if (header.show) { + canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), header.height), + ui.Paint()..color = cNavy); + _drawText(canvas, data.shopName, header.textX, header.textY, + header.fontSize, w - 2 * header.textX, + bold: header.bold, textAlign: _uiAlign(header.align), color: cCream); + } - // ── 主体两列布局(无页脚:主体直抵纸底,2026-08-27 删掉底部米色尺寸条)──────── - const bodyTop = headerH; // 24 - const double bodyBot = 160.0; // 直抵纸底 h(原到页脚顶 145),二维码/条码顺势放大 - const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间 - const double colGap = 6.0; // 左列与二维码列间距(原 10,收小) - - // 右列:二维码略放大,占主体近满高(正方形),2026-08-28 用户口径「二维码调大一点」 - const double qrPad = 1.0; - const double qrSize = (bodyBot - bodyTop) - 2 * qrPad; // 134 - const double qrTop = bodyTop + qrPad; // 25 - const double qrLeft = w - padH - qrSize; // 178 - if (drawQr) { + // ── 右列二维码(正方形,绝对坐标)───────────────────────────── + final qr = tpl.qr; + if (drawQr && qr.show) { try { - final codec = await ui.instantiateImageCodec(qrBytes); + final codec = await ui.instantiateImageCodec(data.qrBytes!); final qrImg = (await codec.getNextFrame()).image; canvas.drawImageRect( qrImg, ui.Rect.fromLTWH( 0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()), - const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize), + ui.Rect.fromLTWH(qr.x, qr.y, qr.size, qr.size), ui.Paint()); } catch (e) { debugPrint('[label] QR 解码失败: $e'); } } - // 左列几何 - const double leftX = padH; // 8 - const double leftRight = qrLeft - colGap; // 172 - const double leftW = leftRight - leftX; // 164 + // ── 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 ── + final barcode = tpl.barcode; + if (drawBarcode && barcode.show) { + _drawBarcode( + canvas, data.code, barcode.x, barcode.y, barcode.width, barcode.height); + } - // 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 - const double barH = 40.0; - const double barBottom = bodyBot - padV; // 154 - const double barTop = barBottom - barH; // 114 - if (drawBarcode) _drawBarcode(canvas, code, leftX, barTop, leftW, barH); + // ── 左列文字块:品名 + 若干副字段行,整体在 [top, bottom] 区间竖直均分 ── + final ts = tpl.textStack; + final double leftX = ts.x; + final double leftW = ts.width; + final double infoTop = ts.top; + final double infoBot = ts.bottom; + final double infoH = infoBot - infoTop; - // 左列文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体水平居中、 - // 竖直均分。2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。 - const double infoTop = bodyTop + padV; // 30 - const double infoBot = barTop - 4; // 110 - const double infoH = infoBot - infoTop; // 80 + // 逐行装配(text, size, bold, align):品名自适应/固定;副字段行按各自样式。 + final rows = <(String, double, bool, LabelAlign)>[]; + if (ts.name.show && data.name.isNotEmpty) { + final double nameSize = ts.name.fontSize ?? + _fitFont(data.name, leftW, 1000, ts.name.bold, ts.name.fontMax) + .clamp(ts.name.fontMin, ts.name.fontMax); + rows.add((data.name, nameSize, ts.name.bold, ts.name.align)); + } + for (final line in ts.subLines) { + final parts = line.bindings + .map((b) => resolveBinding(b, data)) + .where((s) => s.isNotEmpty) + .toList(); + final s = parts.join(' '); + if (s.isNotEmpty) rows.add((s, line.fontSize, line.bold, line.align)); + } - const double kSmallSize = 14.0; // 编号+度数 / 规格+日期(2026-08-28 调大一号 12→14) - final double nameSize = - _fitFont(name, leftW, 1000, true, 18.0).clamp(12.0, 18.0); - final rows = <(String, double, bool)>[ - if (name.isNotEmpty) (name, nameSize, true), // 品名粗体 - for (final s in subs) (s, kSmallSize, false), // 编号+度数 / 规格+日期 - ]; - const double kLineH = 1.3; // 行高倍数 + final double kLineH = ts.lineHeight; // 行高倍数 final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH); final int n = rows.length; final double rawGap = n > 0 ? (infoH - totalTextH) / (n + 1) : 2.0; @@ -335,7 +328,7 @@ Future _renderLabelBitmap({ double yy = infoTop + gap; for (final r in rows) { _drawText(canvas, r.$1, leftX, yy, r.$2, leftW, - bold: r.$3, color: r.$3 ? cInk : cCode, center: true); + bold: r.$3, color: r.$3 ? cInk : cCode, textAlign: _uiAlign(r.$4)); yy += r.$2 * kLineH + gap; } @@ -349,11 +342,8 @@ Future _renderLabelBitmap({ /// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。 /// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。 Future _printFlatLabelThermal({ - required String shop, - required String name, - required String code, - required List subs, // 度数 / 规格 / 日期(已过滤空) - required Uint8List qrBytes, + required LabelTemplate tpl, // 版式单源:QR/条码坐标 + TSPL 参数全从此读 + required LabelData data, String? printerName, }) async { if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false; @@ -361,19 +351,18 @@ Future _printFlatLabelThermal({ debugPrint('[label] thermal printer = $printer'); if (printer == null) return false; - const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸) - const wBytes = (w + 7) >> 3; // 40 bytes/row + final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸) + final int h = tpl.paper.logicalH; // 20mm@203 → 160 + final int wBytes = (w + 7) >> 3; // 40 bytes/row + final code = data.code; // ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)───────── // 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化: // 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。 // 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。 final img = await _renderLabelBitmap( - shop: shop, - name: name, - code: code, - subs: subs, - qrBytes: qrBytes, + tpl: tpl, + data: data, drawBarcode: false, drawQr: false, ); @@ -383,10 +372,13 @@ Future _printFlatLabelThermal({ const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致 const int subTotal = ss * ss; // 16 子像素/墨点 + // TSPL 头部参数由 template 驱动(纸张/间隙/方向/密度/速度)。 + final p = tpl.print; final buf = BytesBuilder(); buf.add(ascii.encode( - 'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n' - 'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n', + 'SIZE ${_tsplMm(tpl.paper.widthMm)} mm,${_tsplMm(tpl.paper.heightMm)} mm\r\n' + 'GAP ${_tsplMm(p.gapMm)} mm,0 mm\r\nDIRECTION ${p.direction}\r\n' + 'REFERENCE 0,0\r\nDENSITY ${p.density}\r\nSPEED ${p.speed}\r\nCLS\r\n', )); // 整幅文字位图(mode 0 覆写整版):深蓝抬头→黑底反白,其余白底黑字, @@ -413,37 +405,44 @@ Future _printFlatLabelThermal({ } buf.add(ascii.encode('\r\n')); - // ── 右列二维码(原生 BITMAP,避免光栅降采样糊码):占主体整高、右对齐 ── - const qrW = 130, qrH = 130; // 2026-08-28 放大,对齐画布 QR(134) - const qrX = 320 - 8 - qrW; // 182 - const bodyTop = 24, bodyBot = 160; // 无页脚:主体直抵纸底 - const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 27 - const qrWBytes = (qrW + 7) >> 3; // 13 - final qrCodec = await ui.instantiateImageCodec(qrBytes, - targetWidth: qrW, targetHeight: qrH); - final qrImg = (await qrCodec.getNextFrame()).image; - final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba); - final qrRgba = qrBd!.buffer.asUint8List(); - buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,')); - for (int yy = 0; yy < qrH; yy++) { - final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); - for (int xx = 0; xx < qrW; xx++) { - final i = (yy * qrW + xx) * 4; - final a = qrRgba[i + 3]; - final lum = a < 128 - ? 255.0 - : 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2]; - if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); + // ── 右列二维码(原生 BITMAP,避免光栅降采样糊码),坐标由 template 驱动(与画布同源)── + if (tpl.qr.show && data.qrBytes != null) { + final int qrW = tpl.qr.size.round(); + final int qrH = qrW; + final int qrX = tpl.qr.x.round(); + final int qrY = tpl.qr.y.round(); + final int qrWBytes = (qrW + 7) >> 3; + final qrCodec = await ui.instantiateImageCodec(data.qrBytes!, + targetWidth: qrW, targetHeight: qrH); + final qrImg = (await qrCodec.getNextFrame()).image; + final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba); + final qrRgba = qrBd!.buffer.asUint8List(); + buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,')); + for (int yy = 0; yy < qrH; yy++) { + final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); + for (int xx = 0; xx < qrW; xx++) { + final i = (yy * qrW + xx) * 4; + final a = qrRgba[i + 3]; + final lum = a < 128 + ? 255.0 + : 0.299 * qrRgba[i] + + 0.587 * qrRgba[i + 1] + + 0.114 * qrRgba[i + 2]; + if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); + } + buf.add(row); } - buf.add(row); + buf.add(ascii.encode('\r\n')); } - buf.add(ascii.encode('\r\n')); - // ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),narrow 自适应铺满居中 ── - const leftX = 8, leftRight = qrX - 6, leftW = leftRight - leftX; // 8..188, 180 - const kBarH = 34; - const kBarY = bodyBot - 8 - kBarH; // 118 - if (code.isNotEmpty) { + // ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),坐标由 template 驱动 ── + // narrow 模块宽自适应铺满区域并水平居中。 + if (tpl.barcode.show && code.isNotEmpty) { + final int leftX = tpl.barcode.x.round(); + final int leftW = tpl.barcode.width.round(); + final int leftRight = leftX + leftW; + final int kBarH = tpl.barcode.height.round(); + final int kBarY = tpl.barcode.y.round(); final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验) final narrow = (leftW / estModules).floor().clamp(2, 4); final barW = estModules * narrow; @@ -452,7 +451,7 @@ Future _printFlatLabelThermal({ 'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n')); } - buf.add(ascii.encode('PRINT 1,1\r\n')); + buf.add(ascii.encode('PRINT 1,${p.copies}\r\n')); final tspl = buf.toBytes(); debugPrint('[label] raster BITMAP+QR+BARCODE tspl ${tspl.length}B -> $printer'); @@ -473,8 +472,23 @@ Future printProductLabelImpl({ String shopAddress = '', String shopPhone = '', String? printerName, + LabelTemplate? template, // 版式单源;null → 内置默认(行为同重构前) }) async { try { + final tpl = template ?? LabelTemplate.builtinDefault(); + final data = LabelData( + qrBytes: qrBytes, + name: name, + code: code, + spec: spec, + series: series, + batchNo: batchNo, + productionDate: productionDate, + remark: remark, + shopName: shopName, + shopAddress: shopAddress, + shopPhone: shopPhone, + ); final font = await _loadFont(); final doc = pw.Document(); final qrImage = pw.MemoryImage(qrBytes); @@ -484,17 +498,12 @@ Future printProductLabelImpl({ const labelH = 20.0 * PdfPageFormat.mm; const headerH = labelH * 0.20; - // 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致) - final subs = _labelSubFields(code, series, spec, productionDate); + // 副字段行由 template 装配(口径与预览、TSPL 一致) + final subs = tpl.textStack.composeSubs(data); // 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印) if (await _printFlatLabelThermal( - shop: shopName, - name: name, - code: code, - subs: subs, - qrBytes: qrBytes, - printerName: printerName)) { + tpl: tpl, data: data, printerName: printerName)) { return; } @@ -617,15 +626,12 @@ Future printProductLabelImpl({ } /// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。 -/// Web 平台或 qrBytes 为空时抛异常。 -Future renderLabelPreviewImpl(LabelData label) async { +/// [template] 为空时用内置默认版式(行为同重构前)。Web 平台或 qrBytes 为空时抛异常。 +Future renderLabelPreviewImpl(LabelData label, + {LabelTemplate? template}) async { final img = await _renderLabelBitmap( - shop: label.shopName, - name: label.name, - code: label.code, - subs: _labelSubFields( - label.code, label.series, label.spec, label.productionDate), - qrBytes: label.qrBytes!, + tpl: template ?? LabelTemplate.builtinDefault(), + data: label, ); final bd = await img.toByteData(format: ui.ImageByteFormat.png); return bd!.buffer.asUint8List(); diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index 2b005f2..2518e12 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -7,6 +7,7 @@ import '../../models/stock_in.dart'; import '../../models/stock_out.dart'; import '../errors/error_reporter.dart'; import 'label_data.dart'; +import 'label_template.dart'; import 'order_print_meta.dart'; void _openPrintWindow(String html) { @@ -31,30 +32,26 @@ Future printProductLabelImpl({ String shopAddress = '', String shopPhone = '', String? printerName, + LabelTemplate? template, }) async { final base64Img = base64Encode(qrBytes); - final specVal = (spec ?? '').isNotEmpty ? spec! : ''; - final seriesVal = (series ?? '').isNotEmpty ? series! : ''; - final dateVal = (productionDate ?? '').isNotEmpty - ? (productionDate!.length > 10 - ? productionDate.substring(0, 10) - : productionDate) - : ''; - // 左列名下两行副字段(居中):行1=编号+度数、行2=规格+日期(口径与预览、PDF、TSPL 一致) - // 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。 - final line1 = [ - if (code.isNotEmpty) code, - if (seriesVal.isNotEmpty) seriesVal, - ].join(' '); - final line2 = [ - if (specVal.isNotEmpty) specVal, - if (dateVal.isNotEmpty) dateVal, - ].join(' '); - final subs = [ - if (line1.isNotEmpty) line1, - if (line2.isNotEmpty) line2, - ]; + // 副字段行由 template 装配(口径与预览、PDF、TSPL 一致;null → 内置默认版式)。 + final tpl = template ?? LabelTemplate.builtinDefault(); + final data = LabelData( + qrBytes: qrBytes, + name: name, + code: code, + spec: spec, + series: series, + batchNo: batchNo, + productionDate: productionDate, + remark: remark, + shopName: shopName, + shopAddress: shopAddress, + shopPhone: shopPhone, + ); + final subs = tpl.textStack.composeSubs(data); final subLines = subs.map((s) => '
$s
').join(); // 商品名过长时缩小字号 final nameLen = name.runes.length; @@ -299,7 +296,9 @@ Future printStockInOrderImpl( // ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)───────────────────────── -Future renderLabelPreviewImpl(LabelData label) async => null; +Future renderLabelPreviewImpl(LabelData label, + {LabelTemplate? template}) async => + null; Future> listLabelPrintersImpl() async => []; diff --git a/client/test/golden/goldens/label_render_default.png b/client/test/golden/goldens/label_render_default.png new file mode 100644 index 0000000..9a276ee Binary files /dev/null and b/client/test/golden/goldens/label_render_default.png differ diff --git a/client/test/golden/label_render_golden_test.dart b/client/test/golden/label_render_golden_test.dart new file mode 100644 index 0000000..fd33c3e --- /dev/null +++ b/client/test/golden/label_render_golden_test.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/utils/print_util.dart'; + +import '../support/golden_harness.dart'; + +/// 标签价签默认版式「零回归」基准(P0 守法)。 +/// +/// 目的:`LabelTemplate` 抽单源 + 画布渲染器改读模型(`_renderLabelBitmap`)后, +/// 内置默认模板(`builtinDefault()`)渲染出的价签必须与重构前**逐像素一致**。 +/// +/// 用法: +/// 基准(必须在动渲染器之前,于当前 main 上生成一次): +/// flutter test --update-goldens test/golden/label_render_golden_test.dart +/// 回归(重构后跑,须零 diff): +/// flutter test test/golden/label_render_golden_test.dart +/// +/// 说明:`renderLabelPreview` 走 dart:ui 直画(`Picture.toImage`),不经 widget +/// 管线;在测试 zone 里直接 await 会因无 frame 驱动光栅而挂死,故渲染 + golden +/// 比对整体放进 `tester.runAsync()`。字体复用 `ensureGoldenFonts()` 的 NotoSansSC, +/// 与真实渲染同源。QR 用固定棋盘图占位,保证确定性(真实 QR 数据不影响版式几何)。 + +const _goldenUri = 'goldens/label_render_default.png'; + +/// 生成一张确定的 QR 占位图(10×10 棋盘 → 240×240 PNG),供版式渲染填充二维码位。 +Future _fixedQrPng() async { + const cells = 10; + const cell = 24.0; // 240×240 + final rec = ui.PictureRecorder(); + final canvas = ui.Canvas(rec); + final paint = ui.Paint(); + for (var y = 0; y < cells; y++) { + for (var x = 0; x < cells; x++) { + paint.color = (x + y) % 2 == 0 + ? const ui.Color(0xFF000000) + : const ui.Color(0xFFFFFFFF); + canvas.drawRect(ui.Rect.fromLTWH(x * cell, y * cell, cell, cell), paint); + } + } + final img = await rec + .endRecording() + .toImage((cells * cell).round(), (cells * cell).round()); + final bd = await img.toByteData(format: ui.ImageByteFormat.png); + return bd!.buffer.asUint8List(); +} + +void main() { + testWidgets('价签默认版式 · 零回归基准', (tester) async { + await ensureGoldenFonts(); + + Uint8List? png; + bool comparePassed = false; + + await tester.runAsync(() async { + final label = LabelData( + qrBytes: await _fixedQrPng(), + name: '贵州茅台酒', + code: 'P1001', + series: '飞天53度', + spec: '500ml', + productionDate: '2024-05-18', + shopName: '鼎晟酒行', + ); + + png = await renderLabelPreview(label); + if (png == null) return; + + final uri = Uri.parse(_goldenUri); + if (autoUpdateGoldenFiles) { + await goldenFileComparator.update(uri, png!); + comparePassed = true; + } else { + comparePassed = await goldenFileComparator.compare(png!, uri); + } + }); + + expect(png, isNotNull, reason: 'renderLabelPreview 在测试(VM/桌面)下应返回 PNG'); + expect(comparePassed, isTrue, + reason: '默认模板渲染与基准不一致:零回归被破坏(先在重构前 main 上 --update-goldens 生成基准)'); + }); +} diff --git a/docs/design/label-template-editor.html b/docs/design/label-template-editor.html new file mode 100644 index 0000000..c09e412 --- /dev/null +++ b/docs/design/label-template-editor.html @@ -0,0 +1,205 @@ + + + + + +标签模板编辑器 设计方案 — 酒库管理系统 + + + +

标签模板编辑器 · 设计方案(代码实证版 v2)

+
给「设置 → 打印模板 · 商品价签」的「编辑」按钮做可视化模板编辑器 · 2026-08-28 · 本版所有结论已逐行核对源码(不采信二手调研)· 方案评审(未实现)
+ +
+ 目标不变:店家自定义价签版式(选字段 / 调字号字体位置 / 实时预览 / 多样式命名 / 打印参数进高级)。 + 本版把上一版两处关键判断按源码订正了:① 二维码走的是位图不是原生指令; + ② PDF / Web 两条路径用的是 flexbox 流式布局、根本没有绝对坐标——这直接改变了「统一渲染」的做法。 + 结论:真正要数据驱动的只有画布(预览) + 热敏(实打)这一条主线,PDF/Web 用「贴位图」收口即可, + 比上一版设想的工作量更小、风险更低。 +
+ +

1. 现状(逐行核对源码)

+
+

标签有四条输出路径,各自布局机制如下(已核对,非调研二手):

+ + + + + + +
路径位置何时走布局机制条码 / 二维码
画布print_util_stub.dart:246 _renderLabelBitmap预览弹窗 + 热敏文字光栅(同一函数,drawQr/drawBarcode=false 供热敏)绝对坐标(逻辑 320×160,4× 超采样)条码 _drawBarcode 画黑条(:315);QR drawImageRect(:295)
热敏 TSPL同文件 _printFlatLabelThermal:351检测到 DL-888B 等热敏机(真实主力)文字=画布光栅 BITMAP(:394);绝对坐标条码=原生 BARCODE(:451);QR=位图 BITMAP(:427)
PDF 回退同文件 printProductLabelImpl:463只在检测不到热敏机时(:491-499)flexbox 流式pw.Column/Row/Expanded),无绝对坐标条码 pw.BarcodeWidget 矢量(:578);QR pw.Image(:593)
Web HTMLprint_util_web.dart:21Web 构建(window.print()CSS flexbox 流式(:82-124),无绝对坐标条码 toSvg 矢量(:64);QR base64 img(:143)
+ +
订正 ①(我上一版说错的):热敏路径里二维码是 BITMAP $qrX,$qrY,...(第 427 行)——位图,不是原生 QRCODE 指令。它「相对清晰」的原因是独立渲染:把 QR PNG 按目标尺寸 130×130 单独解码 + 1:1 逐像素 128 阈值二值化(:422-436),不经过文字那条 4×4 超采样降采样链路。只有条码是真·原生 TSPL BARCODE 指令(:451)。
+ +
订正 ②(影响架构):PDF 和 Web 不是绝对坐标布局,是 flexbox 流式(PDF 用 pw.Column/Row/Expanded,Web 用 CSS flex)。这意味着——如果编辑器让用户「把字段拖到某个 x/y」,这套坐标天然只对得上画布/热敏,对不上 PDF/Web 的 flex 流。要让 PDF/Web 认坐标,就得把它们的 flex 布局整个换掉。这正是下一节改方案的原因。
+ +

1.1 画布布局其实是「自适应」的,不是死坐标

+

关键细节(_renderLabelBitmap:319-340):品名字号 _fitFont 按宽度自适应 clamp(12,18);文字块(品名 + 两行副字段)在剩余竖直空间里自动均分留白居中;副字段 _labelSubFields:222 把「编号+系列」拼一行、「规格+日期」拼一行,空字段自动跳过、整行塌缩。也就是说当前版式不是一组固定像素位置,而是「几个区域 + 自动排版」。这点直接决定版式模型该怎么建(见 §3)。

+ +

1.2 实际渲染 / 未渲染的字段

+

参与渲染:shopName / name / code / series / spec / productionDate / qr / barcode已在 LabelData 但从不渲染batchNo / remark / shopAddress / shopPhone(label_data.dart:15-20) → 编辑器可直接把它们做成可选字段,数据层零改动

+ +

1.3 入口与存储(已核对,利好)

+
    +
  • 「编辑」是空实现device_management_screen.dart:991_snack('模板编辑即将上线');「预览」_previewLabelTemplate:1002 造示例 LabelDataLabelPreviewDialog打印模板卡仅桌面渲染 → 编辑器只做桌面。
  • +
  • 打印入口是扁平参数print_util.dart:15 printProductLabel(...):45 renderLabelPreview(label) 都不带模板概念 → 要加一个 template 参数贯穿下去(改动集中在 facade + 两个 Impl)。
  • +
  • 后端 custom_fields 已支持增量 mergeshop.go:68-79 读现有 custom_fields → 合并新键 → 保留旧键、店隔离,还有钉死契约的测试 shop_test.go:31。前端 _savePeripherals:1031 就是这么存外设的。→ 模板存 custom_fields 零后端改动、且不会冲掉外设等已有键。
  • +
+
+ +

2. 渲染架构(按源码修订:画布为唯一权威 + PDF/Web 贴位图)

+
+

上一版我提「抽一层绘制指令 IR 给四路径各写 adapter」。读完代码后这是过度设计——因为 PDF/Web 是流式布局、且只是回退/次要面。更省的做法:

+
让「画布」成为唯一的几何权威。 模板 = 画布上的绝对布局;画布同时驱动预览热敏文字光栅(热敏文字本就是画布栅格化的)。PDF/Web 直接嵌入画布渲染出的位图,不再各自摆版。四套手工对齐的布局 → 收敛成一套画布渲染器 + 两个「贴位图」消费者 + 热敏既有的条码/QR 特判
+ + + + + + +
改法条码/QR 清晰度
预览画布 → PNG(现状即是)屏幕显示,无所谓
热敏(主力)文字=画布光栅(现状);条码=原生 BARCODE、QR=独立 BITMAP都保留);唯一改动:它们的坐标从写死的 const 改成由模板算出不降级:条码原生矢量、QR 独立 1:1 阈值,扫码可靠性照旧
PDF 回退整个 pw.* 组件树换成单张 pw.Image(画布位图) 满页铺回退面向办公激打/喷墨(≥300dpi),画布 1280px≈812dpi 栅格化,条码可扫
WebHTML flex 换成 <img> 画布 PNG(40×20mm)同上,Web 打印走高 dpi,无虞
+

注:条码/QR 光栅化「扫不出」的风险只发生在热敏 203dpi——所以热敏那条坚决保留原生条码 + 独立 QR,不动。PDF/Web 是高 dpi 面,贴位图完全够用。

+
附带红利:因为 PDF/Web 只是回退/次要面,它们的模板落地可以延后——先做画布+热敏(覆盖真实打印 + 预览),PDF/Web 后面「贴位图」一句话的事。比上一版少写两个 adapter。
+
+ +

3. 版式模型:区域 + 自动栈(贴合现有自适应布局)

+
+

因为 §1.1 里现状是「区域 + 自动排版」而非死坐标,模型不宜做成纯自由画布(否则丢掉品名自适应、竖直自动居中这些现有优点,且短名不再放大、隐藏字段不再回填空间 → 反而变差)。建议区域(region) + 元素(element) 混合模型:

+
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
+
    +
  • 字段开关:勾选控制 field 是否进 textStack / 是否显示 QR/条码。
  • +
  • 调样式:字号(或 auto 自适应)、粗体、对齐、颜色。
  • +
  • 调位置:QR / 条码 / 抬头 = 可移动可缩放的区域(覆盖「调位置」诉求最实用的部分);textStack 内字段 = 调顺序 + 可选手动微调,默认仍走现有自动均分。
  • +
  • 零回归:内置「默认模板」= 把现有 const(headerH=24 / qrSize=134 / barTop=114 …)原样编码;渲染器保留自动栈算法。用字体探针 / golden 位图对照守逐像素一致。
  • +
+
+ +

4. 存储:shop.custom_fields(已被源码证实可行、零后端改动)

+
+

模板存 shop.custom_fields.label_templates(数组)+ label_template_active(默认 id)。依据:

+
    +
  • 后端 shop.go:68-79 对 custom_fields 做增量 merge 并有测试钉死 → 写 label_templates 不碰 peripherals 等旧键,店隔离。无需新表/迁移/接口。
  • +
  • 前端照抄 _savePeripherals:1031:读 shopInfoProvider → 改 customFields map → shopRepositoryProvider.updateInfo({...})
  • +
  • 店级 → 跨设备/员工同步;打印机选择继续留本地 label_printer(label_preview_dialog.dart:11) 不动。
  • +
+
+ +

5. 编辑器交互(桌面专属)

+
+

价签卡「编辑」→ 大对话框。左预览右属性,改动 debounce ~150ms 调 renderLabelPreview(sample, template) 重渲染(预览路径现成)。

+
┌─ 标签模板编辑器 ──────────────────────────────────────────┐ +│ 模板:[商品价签(默认)▾] [新建][复制][重命名][删除][设默认] │ +├──────────────────────────┬──────────────────────────────┤ +│ 实时预览(真实渲染位图) │ 字段 │ +│ ┌──────────────────┐ │ ☑店名 ☑品名 ☑编号 ☑系列 │ +│ │ 岩美酒行旗舰店 │ │ ☑规格 ☑生产日期 ☐批次 ☐备注 │ +│ │ 茅台飞天五十三度 │▨ │ ☑二维码 ☑底部条码 │ +│ │ P1001 飞天 │▨ │ ──────────────────────────── │ +│ │ 53度 2024-06-01 │ │ 选中「品名」: │ +│ │ ▐▌▐ ▌▐▌▌▐ ▌▐▌▐ │ │ 字号[自适应▾] □粗体 │ +│ └──────────────────┘ │ 对齐[居中▾] 颜色[■] │ +│ [示例▾][用真实商品▾] │ 选中「二维码」: X[178]Y[25] │ +│ │ 尺寸[134] │ +│ │ ──────────────────────────── │ +│ │ ▸ 高级(纸张/DPI/密度/速度) │ +├──────────────────────────┴──────────────────────────────┤ +│ [取消] [保存] │ +└──────────────────────────────────────────────────────────┘
+
+ +

6. 两个需你定调的点(含源码约束)

+
+

决策 A:字段自由度 —— 纯自由画布会丢现有自适应

+

你要「可调位置」,但源码现状是自适应排版(品名按宽度放大、文字块自动竖直居中、空字段塌缩)。三档:

+
    +
  • ① 槽位式:只开关+调样式不能移位。稳,但不满足「调位置」。
  • +
  • ② 区域+自动栈 推荐:QR/条码/抬头做成可移动缩放的区域(满足「调位置」最实用部分);文字块内保留自动均分。既能调位置又不丢现有优点。
  • +
  • ③ 纯自由画布:每字段任意 x/y。最强,但丢掉品名自适应/自动居中,40×20mm 上还易重叠溢出。
  • +
+

推荐 。若你更想要 ③ 的「每个字段都能拖」,我按纯坐标做,但要接受品名不再自动放大、隐藏字段不再自动回填空间。

+
+ +
+

决策 B:字体与颜色 —— 热敏是单色,有物理约束

+
    +
  • 颜色:热敏机是单色介质,文字二值化后全是黑(抬头深蓝也退成黑)。所以「字段颜色」只对预览/PDF/Web 有视觉意义,对热敏实打无效。要不要暴露颜色,取决于你在不在乎屏幕/PDF 上的彩色观感。
  • +
  • 字体_drawText:166 写死 fontFamily:'NotoSansSC',且热敏要求打包字体。支持「换字体」= 再打包字体(如宋体,每款中文约 4–8MB 增包体)+ 参数化 fontFamily。MVP 建议只做字号/粗体(零增包),多字体二期按需。
  • +
+
+ +

7. 落地路线(design-first,主线先行)

+
+ + + + + + +
内容验收
P0 建模定义 LabelTemplate(区域+自动栈)+ 画布渲染器读模型;内置默认模板=现 const;facade/Impl 加 template 参数字体探针 / golden:默认模板逐像素零回归
P1 原型design/prototype/ 新增编辑器桌面原型5180 serve URL 评审,过了再写代码
P2 编辑器编辑器 UI + custom_fields 存储 + 预览联动 + 多模板/重命名;热敏条码/QR 坐标改模板驱动桌面建/存/切模板,预览实时刷新,热敏按模板出签
P3 收口(可延后)PDF/Web 改「贴画布位图」→ 自定义模板四面一致同模板热敏/PDF/Web 观感一致
+

按铁律:P1 原型过审前不写实现代码;前端改动原型与代码同提交。本方案即 P1 前评审。

+
+ +

8. 待你拍板

+
+ + + + + + + +
#决策推荐
1模板存哪shop.custom_fields(源码证实零后端改动、店级同步)
2渲染统一画布为唯一权威 + PDF/Web 贴位图;热敏保留原生条码/独立 QR,仅坐标受模板驱动
3字段自由度② 区域+自动栈(可调位置又不丢自适应)
4字体MVP 只字号/粗体,多字体二期(增包体)
5颜色热敏无效 → 建议 MVP 不暴露颜色(或仅作预览观感),你定
+

给个方向(认同/改哪条),我就做 P0 建模并出 P1 编辑器原型。

+
+ +
核对源码:print_util_stub.dart(:158-617) / print_util_web.dart / label_data.dart / print_util.dart / label_preview_dialog.dart / device_management_screen.dart(:930-1035) / 后端 shop.go(:51-79)+shop_test.go · 本方案未改任何代码。
+ + diff --git a/docs/index.html b/docs/index.html index c00769c..15f9724 100644 --- a/docs/index.html +++ b/docs/index.html @@ -46,6 +46,7 @@
  • 扫码出库(扫码枪扫二维码建出库单)设计HTML — 扫码枪=HID键盘,解析已印二维码URL取public_id→新增鉴权接口映射本店product_id→装配明细行;含数量口径待决
  • 扫码出库 · 实现计划(出库表单扫码+1)HTML — 落到 stock_out_form_screen:扫码框承接URL→取?code=→本地整仓索引命中→明细+1;方案A纯前端(≤1000SKU)/B加后端精确查;design-first先改原型
  • 库存筛选规格MD
  • +
  • 标签模板编辑器(价签版式自定义)设计HTML — 现状四路径硬编码→抽声明式 LabelTemplate 单源;编辑器桌面专属左预览右属性;存 shop.custom_fields;含 5 项待拍板决策(2026-08-28 方案评审)
  • 📚 知识库 · 调研