Files
jiu/client/lib/core/utils/label_template.dart
T
wangjia 7dceefc55e feat(client): 价签默认模板品名默认展示 + 二维码放大留小边框
- 默认模板改为代码只读基准:readLabelTemplates 加载时强制用
  builtinDefault() 覆盖 builtin-default 项,忽略存储里的旧脏几何
  (旧默认品名被关/旧 QR 坐标等一律自愈回代码基准)
- 二维码放大 x198/y44/尺寸96 → x192/y32/尺寸120,四周留 ~1mm 小边框
- 原型 label-template-editor.html 高级 QR 输入同步;golden 重生
- 新增存储测试钉死「内置默认永远回代码基准」

原型与代码同提交(design-first 双边一致)
2026-08-31 12:07:22 +08:00

694 lines
22 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 = 0, // 0=渲染器按 height 竖直居中;正负值作微调
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() ?? 0,
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: 0, // 0=纯居中(渲染器按 height 竖直居中),正负值作微调
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: 192, y: 32, size: 120), // 放大占满右列,四周留 ~8px(≈1mm) 小边框;左距条码/文字(x≤172) 20px
barcode:
LabelBarcode(show: true, x: 8, y: 114, width: 164, height: 40),
);
}
/// 历史内置默认的 QR 坐标签名(x/y/size)。加载老店铺模板时,凡 QR 恰好等于某个历史默认,
/// 自动升级为当前 [LabelTemplate.builtinDefault] 的居中坐标——老模板无需手动「重置」即可拿到
/// 二维码居中+四周留白的新版式。用户手动挪过的 QR 不匹配任一签名,保持不动。
const List<List<double>> _legacyDefaultQrXYS = [
[178, 25, 134], // 最初默认(贴头顶、无留白)
[190, 34, 116], // 中间一版(缩小但仍偏上)
];
/// 就地把匹配历史默认签名的 QR 升级到当前默认坐标;返回同一实例便于链式 map。
LabelTemplate _migrateLegacyDefaults(LabelTemplate t) {
final q = t.qr;
for (final s in _legacyDefaultQrXYS) {
if (q.x == s[0] && q.y == s[1] && q.size == s[2]) {
final def = LabelTemplate.builtinDefault().qr;
q.x = def.x;
q.y = def.y;
q.size = def.size;
break;
}
}
return t;
}
/// 内置默认模板是「代码真相 + 只读锁」:无论存储里存了什么旧版式(旧 QR、品名被关等),
/// 加载时一律用当前 [LabelTemplate.builtinDefault] 覆盖,保证默认永远是最新基准。
/// 用户的定制一律走「复制」出来的副本(另一 id),不受影响。
LabelTemplate _forceBuiltinDefaultFromCode(LabelTemplate t) =>
t.id == 'builtin-default' ? LabelTemplate.builtinDefault() : t;
/// 从 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>()))
.map(_migrateLegacyDefaults)
.map(_forceBuiltinDefaultFromCode)
.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,
});
}