feat(client): 价签默认模板品名默认展示 + 二维码放大留小边框

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

原型与代码同提交(design-first 双边一致)
This commit is contained in:
wangjia
2026-08-31 12:07:22 +08:00
parent ced71bd8a1
commit 7dceefc55e
8 changed files with 312 additions and 54 deletions
+10 -4
View File
@@ -54,7 +54,8 @@ Future<ui.Image> renderLabelImage({
lay.headerFontSize, w - 2 * lay.headerTextX,
bold: lay.headerBold,
textAlign: _uiAlign(lay.headerAlign),
color: cCream);
color: cCream,
centerInH: lay.headerHeight); // 店名在深蓝带内上下居中(textY 作微调)
}
// ── 右列二维码(正方形,绝对坐标)─────────────────────────────
@@ -127,12 +128,15 @@ Future<Uint8List?> renderLabelPngBytes({
return bd?.buffer.asUint8List();
}
/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
/// [centerInH] > 0 时:在 [y, y+centerInH] 区间内竖直居中(用紧排行高 1.0 量高后
/// 上下留白均分),供抬头店名在深蓝带内上下居中;[y] 作为居中基础上的手动微调量。
void _drawText(
ui.Canvas canvas, String s, double x, double y, double size, double maxW,
{bool bold = false,
ui.TextAlign textAlign = ui.TextAlign.left,
ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑)
ui.Color color = const ui.Color(0xFF000000), // ds-ignore: 纸面固定色(默认黑)
double centerInH = 0})
{
if (s.isEmpty) return;
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
@@ -142,11 +146,13 @@ void _drawText(
textAlign: textAlign,
maxLines: 1,
ellipsis: '',
height: centerInH > 0 ? 1.0 : null, // 居中时用紧排行高,量出的高≈字号,居中更准
))
..pushStyle(ui.TextStyle(color: color)) // ds-ignore: 打印画布纸面固定色
..addText(s);
final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
canvas.drawParagraph(p, ui.Offset(x, y));
final double dy = centerInH > 0 ? y + (centerInH - p.height) / 2 : y;
canvas.drawParagraph(p, ui.Offset(x, dy));
}
/// 版式对齐 → 画布 TextAlign。
+35 -4
View File
@@ -147,7 +147,7 @@ class LabelHeader {
this.show = true,
this.height = 24,
this.textX = 10,
this.textY = 4,
this.textY = 0, // 0=渲染器按 height 竖直居中;正负值作微调
this.fontSize = 14,
this.bold = true,
this.align = LabelAlign.left,
@@ -167,7 +167,7 @@ class 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,
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?),
@@ -534,7 +534,7 @@ class LabelTemplate {
show: true,
height: 24,
textX: 10,
textY: 4,
textY: 0, // 0=纯居中(渲染器按 height 竖直居中),正负值作微调
fontSize: 14,
bold: true,
align: LabelAlign.left),
@@ -564,12 +564,41 @@ class LabelTemplate {
align: LabelAlign.center),
],
),
qr: LabelQr(show: true, x: 178, y: 25, size: 134),
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'];
@@ -577,6 +606,8 @@ List<LabelTemplate> readLabelTemplates(Map<String, dynamic> cf) {
final list = raw
.whereType<Map>()
.map((m) => LabelTemplate.fromJson(m.cast<String, dynamic>()))
.map(_migrateLegacyDefaults)
.map(_forceBuiltinDefaultFromCode)
.toList();
if (list.isNotEmpty) return list;
}
@@ -41,8 +41,6 @@ class LabelTemplateEditorScreen extends ConsumerStatefulWidget {
const List<List<LabelBinding>> _lineGroups = [
[LabelBinding.code, LabelBinding.series], // 行 0
[LabelBinding.spec, LabelBinding.productionDate], // 行 1
[LabelBinding.batchNo], // 行 2
[LabelBinding.remark], // 行 3
];
enum _FKind { name, header, sub, qr, barcode }
@@ -64,8 +62,6 @@ const List<_Fd> _fields = [
_Fd('spec', '版本 / 规格', _FKind.sub, line: 1, binding: LabelBinding.spec),
_Fd('date', '生产日期', _FKind.sub,
line: 1, binding: LabelBinding.productionDate),
_Fd('batch', '批次号', _FKind.sub, line: 2, binding: LabelBinding.batchNo),
_Fd('remark', '备注', _FKind.sub, line: 3, binding: LabelBinding.remark),
_Fd('qr', '二维码', _FKind.qr),
_Fd('barcode', '条形码', _FKind.barcode),
];
@@ -93,9 +89,15 @@ class _LabelTemplateEditorScreenState
final Map<String, TextEditingController> _ctrls = {};
final TextEditingController _sizeCtrl = TextEditingController();
/// 系统自带的默认模板 id(受保护基准版式)。
static const String _builtinDefaultId = 'builtin-default';
LabelTemplate get _t =>
_templates.firstWhere((t) => t.id == _editingId, orElse: () => _templates.first);
/// 默认模板只读:字段/字号/位置/打印参数一律不可改,要改必须先「复制」。
bool get _isLocked => _t.id == _builtinDefaultId;
@override
void initState() {
super.initState();
@@ -179,21 +181,49 @@ class _LabelTemplateEditorScreenState
}
/// 预览/打印共用的示例数据(QR 已在 bootstrap 生成)。
/// 预览示例的默认内容(仅用于左侧预览;真实出签永远取商品真实数据)。
String _sampleDefault(String key) {
switch (key) {
case 'name':
return '贵州茅台酒';
case 'code':
return 'P1001';
case 'series':
return '飞天53度';
case 'spec':
return '500ml';
case 'date':
return '2024-05-18';
case 'shop':
return ref.read(shopInfoProvider).valueOrNull?.name ?? '鼎晟酒行';
}
return '';
}
/// 字段内容编辑框的控制器(复用 [_ctrls] 池,`sample/` 命名空间,随店级预览而非模板)。
TextEditingController _contentCtrl(_Fd fd) => _ctrls.putIfAbsent(
'sample/${fd.key}',
() => TextEditingController(text: _sampleDefault(fd.key)));
/// 当前预览示例取值:编辑框非空用编辑框,否则回默认。
String _sampleOf(String key) {
final v = _ctrls['sample/$key']?.text.trim() ?? '';
return v.isNotEmpty ? v : _sampleDefault(key);
}
LabelData? _sampleLabel() {
final qr = _qrBytes;
if (qr == null) return null;
final shopName =
ref.read(shopInfoProvider).valueOrNull?.name ?? '鼎晟酒行';
return LabelData(
qrBytes: qr,
name: '贵州茅台酒',
code: 'P1001',
series: '飞天53度',
spec: '500ml',
productionDate: '2024-05-18',
name: _sampleOf('name'),
code: _sampleOf('code'),
series: _sampleOf('series'),
spec: _sampleOf('spec'),
productionDate: _sampleOf('date'),
batchNo: '20240518-01',
remark: '整箱现货',
shopName: shopName,
shopName: _sampleOf('shop'),
);
}
@@ -282,6 +312,13 @@ class _LabelTemplateEditorScreenState
_t.textStack.name.fontSize = v ? null : _t.textStack.name.fontMax;
});
/// 步进器 +1/-1:夹在 [6,48],回写模型与输入框(同原型 stepSize)。
void _stepSize(_Fd fd, int d) {
final n = (_sizeOf(fd).round() + d).clamp(6, 48);
_setSize(fd, n.toDouble());
_sizeCtrl.text = n.toString();
}
bool _boldOf(_Fd fd) => switch (fd.kind) {
_FKind.name => _t.textStack.name.bold,
_FKind.header => _t.header.bold,
@@ -531,7 +568,9 @@ class _LabelTemplateEditorScreenState
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
const Spacer(),
DsButton('重置', icon: LucideIcons.refreshCw, onPressed: _reset),
DsButton('重置',
icon: LucideIcons.refreshCw,
onPressed: _isLocked ? null : _reset),
const SizedBox(width: 10),
DsButton('打印', icon: LucideIcons.printer, onPressed: _print),
const SizedBox(width: 10),
@@ -681,6 +720,10 @@ class _LabelTemplateEditorScreenState
],
onChanged: _switchEditing,
),
if (_isLocked) ...[
const SizedBox(height: 10),
_lockNote(t),
],
const SizedBox(height: 10),
Wrap(
spacing: 8,
@@ -695,7 +738,7 @@ class _LabelTemplateEditorScreenState
DsButton('重命名',
icon: LucideIcons.pencil,
small: true,
onPressed: _renameTemplate),
onPressed: _isLocked ? null : _renameTemplate),
DsButton('设默认',
icon: LucideIcons.check,
small: true,
@@ -704,7 +747,7 @@ class _LabelTemplateEditorScreenState
icon: LucideIcons.x,
small: true,
variant: DsBtnVariant.danger,
onPressed: _deleteTemplate),
onPressed: _isLocked ? null : _deleteTemplate),
],
),
const SizedBox(height: 12),
@@ -736,6 +779,19 @@ class _LabelTemplateEditorScreenState
);
}
/// 默认模板只读提示条(点「复制」是可编辑逃生口)。
Widget _lockNote(AppTokens t) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: t.warnBg,
borderRadius: BorderRadius.circular(AppDims.rMd),
border: Border.all(color: t.warn),
),
child: Text('系统默认模板不可修改,点「复制」生成可编辑副本',
style: TextStyle(
fontSize: AppDims.fsXs, color: t.warn, height: 1.5)),
);
Widget _fieldSection(AppTokens t) => _sectionCard(
t,
'显示字段',
@@ -752,22 +808,25 @@ class _LabelTemplateEditorScreenState
Widget _fieldRow(AppTokens t, _Fd fd) {
final on = _onOf(fd);
final open = _openField == fd.key;
final open = !_isLocked && _openField == fd.key;
final graphic = fd.kind == _FKind.qr || fd.kind == _FKind.barcode;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
borderRadius: BorderRadius.circular(AppDims.rMd),
onTap: () => setState(() {
if (open) {
_openField = null;
} else {
_openField = fd.key;
_sizeCtrl.text =
graphic || _isAuto(fd) ? '' : _sizeOf(fd).round().toString();
}
}),
onTap: _isLocked
? null
: () => setState(() {
if (open) {
_openField = null;
} else {
_openField = fd.key;
_sizeCtrl.text = graphic || _isAuto(fd)
? ''
: _sizeOf(fd).round().toString();
}
}),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9),
decoration: BoxDecoration(
@@ -776,7 +835,9 @@ class _LabelTemplateEditorScreenState
),
child: Row(
children: [
DsSwitch(value: on, onChanged: (v) => _setOn(fd, v)),
DsSwitch(
value: on,
onChanged: _isLocked ? null : (v) => _setOn(fd, v)),
const SizedBox(width: 10),
Expanded(
child: Text(fd.label,
@@ -822,6 +883,27 @@ class _LabelTemplateEditorScreenState
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 内容(仅改左侧预览示例,真实打印永远取商品真实数据)
Row(
children: [
SizedBox(
width: 44,
child: Text('内容',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted))),
Expanded(
child: DsInput(
controller: _contentCtrl(fd),
onChanged: (_) => _scheduleRender(),
),
),
],
),
const SizedBox(height: 4),
Text('仅改预览示例,真实打印按商品数据填充',
style: TextStyle(
fontSize: AppDims.fsXs, color: t.muted, height: 1.6)),
const SizedBox(height: 12),
// 字号(品名可切自动)
Row(
children: [
@@ -841,6 +923,7 @@ class _LabelTemplateEditorScreenState
final n = int.tryParse(v.trim());
if (n != null && n > 0) _setSize(fd, n.toDouble());
},
suffix: _sizeStepper(fd, t, auto),
),
),
if (isName) ...[
@@ -889,6 +972,26 @@ class _LabelTemplateEditorScreenState
);
}
/// 字号输入框右侧内嵌的上下箭头步进器(点击 +1/-1,同原型 .lt-steparr)。
Widget _sizeStepper(_Fd fd, AppTokens t, bool auto) {
Widget arrow(IconData ic, int d) => InkWell(
onTap: auto ? null : () => _stepSize(fd, d),
child: SizedBox(
height: 15,
width: 18,
child: Icon(ic, size: 13, color: auto ? t.faint : t.muted),
),
);
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
arrow(LucideIcons.chevronUp, 1),
arrow(LucideIcons.chevronDown, -1),
],
);
}
// ── 高级 · 区域位置 ───────────────────────────────────────────────────
Widget _advancedSection(AppTokens t) {
return Container(
@@ -978,8 +1081,10 @@ class _LabelTemplateEditorScreenState
DsSeg(
items: const ['正向', '反向'],
index: _t.print.direction == 0 ? 1 : 0,
onChanged: (i) =>
_mutate(() => _t.print.direction = i == 0 ? 1 : 0),
onChanged: (i) {
if (_isLocked) return;
_mutate(() => _t.print.direction = i == 0 ? 1 : 0);
},
),
],
),
@@ -1016,6 +1121,7 @@ class _LabelTemplateEditorScreenState
label,
input: DsInput(
controller: c,
enabled: !_isLocked,
keyboardType: TextInputType.numberWithOptions(decimal: decimal),
onChanged: (v) {
final n = double.tryParse(v.trim());