Files
jiu/client/lib/core/utils/label_template.dart
T
wangjia 7b3a0ecdeb feat(client): 标签模板编辑器 P2 · 编辑器屏+存储+打印联动(原型同提交)
设备管理「价签·编辑」进入独立编辑器屏 /devices/label-template:
- 左实时预览走真实 renderLabelPreview 渲染路径(示例数据, debounce 重渲)
- 右属性面板:多模板管理(编辑对象/打印默认分离)、显示字段手风琴、
  高级区域位置、打印参数
- 存 shop.custom_fields.label_templates + label_template_active(零后端改)
- Option A:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐,
  各自独立开关;仅品名支持自动字号;字体不做(热敏单色)
- 打印联动:LabelPreviewDialog 改 ConsumerStatefulWidget, 未显式传模板时
  自动取本店 active 模板→所有打印入口(商品详情/出入库列表/编辑抽屉)一致生效

守护:
- 默认输出逐像素零变化由 label_render_golden_test 守(改内置默认常量才触发)
- 存储读写由 label_template_storage_test 单测守(纯内存, 不触真实库)
- 编辑器挂载由 label_template_editor_smoke_test 守
- 本屏不入自动 fidelity/golden 闸(左预览 Picture.toImage 需 runAsync,
  pumpAndSettle 骨架驱动不了), 原因+替代守法记入 CONTRACT 块4

design-first:原型 label-template-editor.html 去字体选择器、加 Option A 配对
镜像, 与真实屏同提交。真机热敏打印坐标/条码可扫须用户在打印机验收。

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

663 lines
20 KiB
Dart
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.
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<String, dynamic> toJson() =>
{'widthMm': widthMm, 'heightMm': heightMm, 'dpi': dpi};
factory LabelPaper.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'density': density,
'speed': speed,
'direction': direction,
'copies': copies,
'gapMm': gapMm,
};
factory LabelPrint.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'show': show,
'height': height,
'textX': textX,
'textY': textY,
'fontSize': fontSize,
'bold': bold,
'align': align.name,
};
factory LabelHeader.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'show': show,
'fontSize': fontSize,
'fontMin': fontMin,
'fontMax': fontMax,
'bold': bold,
'align': align.name,
};
factory LabelNameField.fromJson(Map<String, dynamic> 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<LabelBinding> bindings;
double fontSize;
bool bold;
LabelAlign align;
LabelSubLine({
required this.bindings,
this.fontSize = 14,
this.bold = false,
this.align = LabelAlign.center,
});
Map<String, dynamic> toJson() => {
'bindings': bindings.map((b) => b.name).toList(),
'fontSize': fontSize,
'bold': bold,
'align': align.name,
};
factory LabelSubLine.fromJson(Map<String, dynamic> j) => LabelSubLine(
bindings: ((j['bindings'] as List?) ?? const [])
.map((s) => _bindingFrom(s as String?))
.whereType<LabelBinding>()
.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<LabelSubLine> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>())
: LabelNameField(),
subLines: ((j['subLines'] as List?) ?? const [])
.map((m) =>
LabelSubLine.fromJson((m as Map).cast<String, dynamic>()))
.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<String> composeSubs(LabelData d) {
final out = <String>[];
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<String, dynamic> toJson() =>
{'show': show, 'x': x, 'y': y, 'size': size};
factory LabelQr.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() =>
{'show': show, 'x': x, 'y': y, 'width': width, 'height': height};
factory LabelBarcode.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> j) {
final def = builtinDefault();
Map<String, dynamic>? sub(String k) =>
j[k] is Map ? (j[k] as Map).cast<String, dynamic>() : 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(),
);
/// 把声明式模型解析成一套**绝对几何**(渲染器只读它,不再各自读原始字段)。
///
/// 1 期布局口径 = 栅格流/自动回流(2026-08 用户拍板,取代绝对坐标):
/// 存的绝对坐标是「全字段显示」的基准版式;某结构区域隐藏时,其让出的空间
/// 由相邻区域回收填充,纸面不留空。三处结构性回流:
/// ① 条码隐藏 → 文字区向下吃掉条码纵向空间(textBottom 延到条码原下沿);
/// ② 二维码隐藏 → 文字区向右吃掉整列(textWidth 延到二维码原右沿);
/// ③ 抬头隐藏 → 正文/二维码/条码整体上移一个抬头高,顶部不留空带。
/// 副字段(编号/系列/规格/日期/批次/备注)的增删本就由文字区竖直均分自适应,无需此处处理。
///
/// **零回归铁律**:全字段显示时,本方法逐值返回存的绝对坐标(三条回流均为 no-op、
/// 安全地板默认值之上不触发),故默认模板渲染与重构前逐像素一致
/// (由 `label_render_golden_test.dart` 守闸)。改这里先确认 golden 仍零 diff。
LabelLayout computeLayout() {
double tTop = textStack.top;
double tBot = textStack.bottom;
final double tX = textStack.x;
double tW = textStack.width;
double qY = qr.y;
double bY = barcode.y;
// ① 条码隐藏:文字区向下延到条码原下沿,吃掉纵向空白
if (!barcode.show) tBot = barcode.y + barcode.height;
// ② 二维码隐藏:文字区向右延到二维码原右沿,吃掉右列空白
if (!qr.show) tW = (qr.x + qr.size) - textStack.x;
// ③ 抬头隐藏:正文/二维码/条码整体上移一个抬头高
if (!header.show) {
final double dy = header.height;
tTop -= dy;
qY -= dy;
bY -= dy;
}
// 打印安全地板(203dpi 热敏可扫下限):默认值均在地板之上 → 不触发、不改默认像素
final double qSize = qr.size < kMinQrSize ? kMinQrSize : qr.size;
final double barH = barcode.height < kMinBarcodeH ? kMinBarcodeH : barcode.height;
return LabelLayout(
headerShow: header.show,
headerHeight: header.height,
headerTextX: header.textX,
headerTextY: header.textY,
headerFontSize: header.fontSize,
headerBold: header.bold,
headerAlign: header.align,
qrShow: qr.show,
qrX: qr.x,
qrY: qY,
qrSize: qSize,
barcodeShow: barcode.show,
barX: barcode.x,
barY: bY,
barW: barcode.width,
barH: barH,
textX: tX,
textWidth: tW,
textTop: tTop,
textBottom: tBot,
textLineHeight: textStack.lineHeight,
name: textStack.name,
subLines: textStack.subLines,
);
}
/// 内置默认模板:逐值冻结重构前 `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),
);
}
/// 从 shop.custom_fields 读取店级模板列表;为空/损坏时回退单个 [LabelTemplate.builtinDefault]。
List<LabelTemplate> readLabelTemplates(Map<String, dynamic> cf) {
final raw = cf['label_templates'];
if (raw is List && raw.isNotEmpty) {
final list = raw
.whereType<Map>()
.map((m) => LabelTemplate.fromJson(m.cast<String, dynamic>()))
.toList();
if (list.isNotEmpty) return list;
}
return [LabelTemplate.builtinDefault()];
}
/// 当前出签实际使用的「打印默认」模板:由 custom_fields.label_template_activeid)指定,
/// 缺失/匹配不到时取列表首个;列表空时 builtinDefault。渲染/打印路径统一取此。
LabelTemplate activeLabelTemplate(Map<String, dynamic> cf) {
final list = readLabelTemplates(cf);
final id = cf['label_template_active'];
if (id is String && id.isNotEmpty) {
for (final t in list) {
if (t.id == id) return t;
}
}
return list.first;
}
/// 二维码最小边长(逻辑像素@203dpi ≈ 10mm):低于此扫码不可靠,computeLayout 兜底钳制。
const double kMinQrSize = 80;
/// 一维条码最小高度(逻辑像素@203dpi ≈ 2.5mm):低于此扫码枪难识别,computeLayout 兜底钳制。
const double kMinBarcodeH = 20;
/// `computeLayout()` 的产物:一套解析后的**绝对几何**,渲染器(画布 + 热敏 TSPL)只读它。
///
/// 全字段显示时逐值等于模型存的绝对坐标(栅格流回流均为 no-op),保证默认渲染零回归。
/// 文字栈的品名/副字段沿用原对象(竖直均分 + 品名自适应逻辑在渲染器内,不变)。
class LabelLayout {
final bool headerShow;
final double headerHeight;
final double headerTextX;
final double headerTextY;
final double headerFontSize;
final bool headerBold;
final LabelAlign headerAlign;
final bool qrShow;
final double qrX;
final double qrY;
final double qrSize;
final bool barcodeShow;
final double barX;
final double barY;
final double barW;
final double barH;
final double textX;
final double textWidth;
final double textTop;
final double textBottom;
final double textLineHeight;
final LabelNameField name;
final List<LabelSubLine> subLines;
const LabelLayout({
required this.headerShow,
required this.headerHeight,
required this.headerTextX,
required this.headerTextY,
required this.headerFontSize,
required this.headerBold,
required this.headerAlign,
required this.qrShow,
required this.qrX,
required this.qrY,
required this.qrSize,
required this.barcodeShow,
required this.barX,
required this.barY,
required this.barW,
required this.barH,
required this.textX,
required this.textWidth,
required this.textTop,
required this.textBottom,
required this.textLineHeight,
required this.name,
required this.subLines,
});
}