7dceefc55e
- 默认模板改为代码只读基准:readLabelTemplates 加载时强制用 builtinDefault() 覆盖 builtin-default 项,忽略存储里的旧脏几何 (旧默认品名被关/旧 QR 坐标等一律自愈回代码基准) - 二维码放大 x198/y44/尺寸96 → x192/y32/尺寸120,四周留 ~1mm 小边框 - 原型 label-template-editor.html 高级 QR 输入同步;golden 重生 - 新增存储测试钉死「内置默认永远回代码基准」 原型与代码同提交(design-first 双边一致)
694 lines
22 KiB
Dart
694 lines
22 KiB
Dart
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_active(id)指定,
|
||
/// 缺失/匹配不到时取列表首个;列表空时 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,
|
||
});
|
||
}
|