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