Compare commits

..

10 Commits

Author SHA1 Message Date
wangjia fcc254caff chore: release client-v1.1.17
Deploy Client / build-client-web (push) Successful in 53s
Deploy Client / build-windows (push) Successful in 2m9s
Deploy Client / build-macos (push) Failing after 1m58s
Deploy Client / build-android (push) Has been skipped
Deploy Client / build-ios (push) Has been skipped
Deploy Client / release-deploy-client (push) Has been skipped
2026-08-31 12:08:49 +08:00
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
wangjia ced71bd8a1 refactor(client): P3 PDF/Web 价签收口为「贴画布位图」+ 抽出单源渲染器
将标签「画布位图」渲染逻辑(renderLabelImage / renderLabelPngBytes + 文字/条码/
字号自适应辅助)抽到平台无关的 label_bitmap.dart,四条路径共用同一套几何:
桌面预览、桌面热敏 TSPL 光栅、桌面 PDF 回退、Web 打印。

- print_util_stub.dart:PDF 回退(printProductLabelImpl)非热敏时不再手搭 pw.* 组件
  树(忽略模板坐标),改贴整幅 renderLabelImage 位图铺满 40×20mm 页;删除仅供旧
  PDF 树使用的 _navy/_cream/_footnote/_ink 常量与 barcode 包导入。
- print_util_web.dart:HTML flex 布局换成单张 <img> 画布 PNG;renderLabelPreviewImpl
  由返回 null 改为走共享渲染器(Web 预览与桌面同源)。
- 效果:同一模板在热敏/PDF/Web 观感 100% 一致。条码/二维码随位图光栅化,扫码风险
  只在 ≥300dpi 办公机路径(热敏 203dpi 仍走原生 TSPL BARCODE/BITMAP,不受影响)。

零回归:label_render_golden 逐像素通过(抽出保持字节等价);flutter analyze 0
error/warning、362 测试全绿、check_ds_code --strict 0 违规。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-30 14:43:34 +08:00
wangjia 195b4d22e7 feat(client): 标签模板编辑器头部加「打印」按钮(重置与保存之间)
打印当前预览:复用出签弹窗 LabelPreviewDialog(选打印机/份数),传入正在
编辑的模板 _t 与示例数据。预览/打印共用 _sampleLabel() 抽出示例 LabelData。

L1 图标单源:新登记 i-printer(Lucide printer)到原型 icons.js + 官网
icons.njk 两端(check-l1-sync 同集要求),原型头部 use #i-printer。

design-first:原型 label-template-editor.html 同步加「打印」按钮 + printTpl()
占位,与真实屏同提交。smoke test 增头部「打印」按钮断言。

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-30 09:56:23 +08:00
wangjia 27c5f94329 feat(design): 价签模板编辑器原型(P1),已过审
新增 design/prototype/screens/label-template-editor.html:左实时预览价签 +
右属性面板(模板下拉/显示字段手风琴含每行字体·字号·加粗·对齐/高级区域位置
数字精调/打印参数)。1 期决策:栅格流布局不做拖拽、字段样式内嵌每行、字体仅
系统+内置思源黑体、编辑对象与打印默认对象分离(打印默认徽标+底部提示)。
index.html 登记 kit 卡,devices.html 价签卡编辑入口指向本屏。

check-ds 12 道全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-29 23:19:04 +08:00
wangjia eedcd9a385 refactor(client): 价签渲染器改栅格流布局(computeLayout),隐藏字段邻居回收空间
标签版式从绝对坐标改为「栅格流/自动回流」:LabelTemplate.computeLayout()
解析存储坐标为流式绝对几何(LabelLayout),三条回流规则——条码隐藏文字区向下
延伸、二维码隐藏文字区向右延伸、抬头隐藏整体上移;打印安全地板钳制
(kMinQrSize=80/kMinBarcodeH=20)。画布位图与热敏 TSPL 两条渲染路径改为只读
computeLayout(),不再直读 tpl.header/qr/barcode/textStack。

全字段显示时回流均为 no-op,默认模板逐像素等于重构前基准——
label_render_golden_test 零 diff 守护。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-29 23:18:55 +08:00
wangjia d67295234e 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
2026-08-29 00:00:13 +08:00
wangjia df242dd703 chore: release client-v1.1.16
Deploy Client / build-client-web (push) Successful in 1m0s
Deploy Client / build-macos (push) Successful in 2m58s
Deploy Client / build-android (push) Successful in 1m18s
Deploy Client / build-ios (push) Successful in 3m22s
Deploy Client / build-windows (push) Successful in 2m8s
Deploy Client / release-deploy-client (push) Successful in 3m5s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 20:02:17 +08:00
wangjia 6610d629ce fix(client): 修复 Windows 标签打印中文空白(NotoSansSC family 改用静态字体)
可变字体 NotoSansSC.ttf 的 fvar 轴在 Windows dart:ui(Skia) 下解析失败致标签 canvas 文字全空白(macOS CoreText 正常);family 重指静态 NotoSansSC-Regular.ttf。可变版文件保留(golden_harness/tools 仍按路径加载)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 19:32:33 +08:00
23 changed files with 3209 additions and 478 deletions
+14
View File
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.17] - 2026-08-31
### 新功能
- 全新「商品价签模板编辑器」:在「设置 → 打印模板 · 商品价签」点「编辑」进入,可自定义价签版式——勾选要显示的字段(品名 / 店名 / 编号 / 型号 / 规格 / 生产日期 / 二维码 / 条形码),逐字段调字号、粗体、对齐,调整二维码 / 条码 / 抬头位置,左侧实时预览;支持保存多个命名模板、复制、重命名、设为默认并一键打印;纸张、密度、打印方向等参数收进「高级」。系统默认模板为只读基准,点「复制」即可生成可编辑副本。
### 改进
- 默认价签模板优化:品名默认展示,二维码放大到占满右侧整列、四周只留约 1mm 小边框,扫码更清晰。
- 电脑 / 网页版打印价签改为与热敏标签机一致的整图版式,三端观感统一。
## [1.1.16] - 2026-08-28
### 修复
- 修复 Windows 桌面版打印商品价签时中文(店名、品名、编号等文字)整片不显示、只剩条码与二维码的问题;现在价签中文在 Windows 上正常清晰显示。
## [1.1.15] - 2026-08-28
### 改进
+6
View File
@@ -20,6 +20,7 @@ import '../../screens/settings/settings_screen.dart';
import '../../screens/settings/users_screen.dart';
import '../../screens/about/about_screen.dart';
import '../../screens/devices/device_management_screen.dart';
import '../../screens/devices/label_template_editor_screen.dart';
import '../../screens/me/me_screen.dart';
import '../../screens/license/license_screen.dart';
import '../auth/auth_state.dart';
@@ -183,6 +184,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/devices',
pageBuilder: (_, __) =>
_noTransition(const DeviceManagementScreen())),
// 标签价签模板编辑器(照原型 label-template-editor.html
GoRoute(
path: '/devices/label-template',
pageBuilder: (_, __) =>
_noTransition(const LabelTemplateEditorScreen())),
]),
// 7 系统设置(面板:门店信息/用户管理/编号规则/偏好设置;
// 授权管理 2026-07-10 提升为独立一级屏,见 branch 10 `/license`
+207
View File
@@ -0,0 +1,207 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:barcode/barcode.dart' as bc;
import 'package:flutter/foundation.dart' show debugPrint;
import 'label_data.dart';
import 'label_template.dart';
/// 标签「画布位图」单源渲染器(平台无关,只依赖 dart:ui + package:barcode)。
///
/// 四条打印/预览路径共用同一套几何:
/// - 桌面预览(`renderLabelPreviewImpl`);
/// - 桌面热敏 TSPL 光栅(`_printFlatLabelThermal`barcode/QR 关闭改走原生命令);
/// - 桌面 PDF 回退(`printProductLabelImpl` 贴整幅位图);
/// - Web 打印(`print_util_web.dart` 贴整幅位图 `<img>`)。
///
/// 版式坐标/字号/开关全从 [tpl].computeLayout() 读,确保「预览即实际输出」。
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
Future<ui.Image> renderLabelImage({
required LabelTemplate tpl, // 版式单源:坐标/字号/开关全从此读
required LabelData data, // 待打印字段值 + qrBytes
bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
}) async {
final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸)
final int h = tpl.paper.logicalH; // 20mm@203 → 160
const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
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()));
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
// 纸面固定配色(对齐设计原型 devices.html 标签块)——预览为彩色设计稿,
// 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 抬头店名字色
const cInk = ui.Color(0xFF111111); // ds-ignore 正文
const cCode = ui.Color(0xFF333333); // ds-ignore 编号 / 三字段
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = cWhite);
// 解析栅格流版式(隐藏区域空间由邻居回收;全字段显示时逐值=模型存的绝对坐标 → 零回归)。
final lay = tpl.computeLayout();
// ── 深蓝店名头(binding 固定 shopName)──────────────────────────
if (lay.headerShow) {
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), lay.headerHeight),
ui.Paint()..color = cNavy);
_drawText(canvas, data.shopName, lay.headerTextX, lay.headerTextY,
lay.headerFontSize, w - 2 * lay.headerTextX,
bold: lay.headerBold,
textAlign: _uiAlign(lay.headerAlign),
color: cCream,
centerInH: lay.headerHeight); // 店名在深蓝带内上下居中(textY 作微调)
}
// ── 右列二维码(正方形,绝对坐标)─────────────────────────────
if (drawQr && lay.qrShow) {
try {
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()),
ui.Rect.fromLTWH(lay.qrX, lay.qrY, lay.qrSize, lay.qrSize),
ui.Paint());
} catch (e) {
debugPrint('[label] QR 解码失败: $e');
}
}
// ── 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 ──
if (drawBarcode && lay.barcodeShow) {
_drawBarcode(canvas, data.code, lay.barX, lay.barY, lay.barW, lay.barH);
}
// ── 左列文字块:品名 + 若干副字段行,整体在 [top, bottom] 区间竖直均分 ──
final double leftX = lay.textX;
final double leftW = lay.textWidth;
final double infoTop = lay.textTop;
final double infoBot = lay.textBottom;
final double infoH = infoBot - infoTop;
// 逐行装配(text, size, bold, align):品名自适应/固定;副字段行按各自样式。
final rows = <(String, double, bool, LabelAlign)>[];
if (lay.name.show && data.name.isNotEmpty) {
final double nameSize = lay.name.fontSize ??
_fitFont(data.name, leftW, 1000, lay.name.bold, lay.name.fontMax)
.clamp(lay.name.fontMin, lay.name.fontMax);
rows.add((data.name, nameSize, lay.name.bold, lay.name.align));
}
for (final line in lay.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));
}
final double kLineH = lay.textLineHeight; // 行高倍数
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;
final double gap = rawGap < 1.0 ? 1.0 : rawGap;
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, textAlign: _uiAlign(r.$4));
yy += r.$2 * kLineH + gap;
}
return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640
}
/// 渲染标签为 PNG 字节(预览弹窗 / Web 打印共用)。QR 未就绪等失败返回 null。
Future<Uint8List?> renderLabelPngBytes({
required LabelTemplate tpl,
required LabelData data,
}) async {
final img = await renderLabelImage(tpl: tpl, data: data);
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
return bd?.buffer.asUint8List();
}
/// 在 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: 纸面固定色(默认黑)
double centerInH = 0})
{
if (s.isEmpty) return;
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
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));
final double dy = centerInH > 0 ? y + (centerInH - p.height) / 2 : y;
canvas.drawParagraph(p, ui.Offset(x, dy));
}
/// 版式对齐 → 画布 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(
ui.Canvas canvas, String data, double x, double y, double w, double h) {
if (data.isEmpty) return;
final paint = ui.Paint()
..color = const ui.Color(0xFF000000) // ds-ignore: 打印画布纸面固定色
..style = ui.PaintingStyle.fill;
for (final e in bc.Barcode.code128().make(data, width: w, height: h)) {
if (e is bc.BarcodeBar && e.black) {
canvas.drawRect(
ui.Rect.fromLTWH(x + e.left, y + e.top, e.width, e.height), paint);
}
}
}
/// 测量单行文本在指定字号下的宽度
double _measureWidth(String s, double size, bool bold) {
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontFamily: 'NotoSansSC',
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
maxLines: 1,
))
..addText(s);
final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
return p.maxIntrinsicWidth;
}
/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
if (s.isEmpty) return 0;
final w100 = _measureWidth(s, 100, bold);
final byW = w100 > 0 ? maxW / w100 * 100 : cap;
var size = byW < maxH ? byW : maxH;
if (size > cap) size = cap;
return size;
}
+693
View File
@@ -0,0 +1,693 @@
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,
});
}
+7 -2
View File
@@ -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();
+91 -356
View File
@@ -3,7 +3,6 @@ import 'dart:convert' show ascii;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ffi';
import 'package:barcode/barcode.dart' as bc;
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
show
@@ -24,7 +23,9 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import 'label_bitmap.dart';
import 'label_data.dart';
import 'label_template.dart';
import 'order_print_meta.dart';
Future<pw.Font> _loadFont() async {
@@ -45,12 +46,6 @@ String _fileTs() {
return '${n.year}${p(n.month)}${p(n.day)}_${p(n.hour)}${p(n.minute)}${p(n.second)}';
}
// ── 设计色彩 token ──────────────────────────────────────────────────────────
const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip
const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 抬头反白店名
const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 编号 / 三字段小字
const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
// 得力 DL-888B 等热敏机走系统打印(CUPS 通用驱动)会多走纸/空白,必须发原生 TSPL。
// 直接画位图而非走 PDF 光栅化:避免二维码渲染丢失、SIZE 用整数避免多走纸。
@@ -154,193 +149,9 @@ bool _winRawPrint(String printerName, Uint8List data) {
}
}
/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
void _drawText(
ui.Canvas canvas, String s, double x, double y, double size, double maxW,
{bool bold = false,
bool center = false,
ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑)
{
if (s.isEmpty) return;
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
textAlign: center ? ui.TextAlign.center : ui.TextAlign.left,
maxLines: 1,
ellipsis: '',
))
..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));
}
/// 在 canvas 上把 [data] 画成 Code128 一维条码(黑条,铺满 x,y,w,h 区域,不带人读字符)。
/// 扫码枪扫 1D 条码;条码内容 = 商品编号,与出库扫码一致。
void _drawBarcode(
ui.Canvas canvas, String data, double x, double y, double w, double h) {
if (data.isEmpty) return;
final paint = ui.Paint()
..color = const ui.Color(0xFF000000) // ds-ignore: 打印画布纸面固定色
..style = ui.PaintingStyle.fill;
for (final e in bc.Barcode.code128().make(data, width: w, height: h)) {
if (e is bc.BarcodeBar && e.black) {
canvas.drawRect(
ui.Rect.fromLTWH(x + e.left, y + e.top, e.width, e.height), paint);
}
}
}
/// 测量单行文本在指定字号下的宽度
double _measureWidth(String s, double size, bool bold) {
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontFamily: 'NotoSansSC',
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
maxLines: 1,
))
..addText(s);
final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
return p.maxIntrinsicWidth;
}
/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
if (s.isEmpty) return 0;
final w100 = _measureWidth(s, 100, bold);
final byW = w100 > 0 ? maxW / w100 * 100 : cap;
var size = byW < maxH ? byW : maxH;
if (size > cap) size = 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,
bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
}) async {
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
const sw = w * scale, sh = h * scale;
final recorder = ui.PictureRecorder();
final canvas =
ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
// 纸面固定配色(对齐设计原型 devices.html 标签块)——预览为彩色设计稿,
// 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 抬头店名字色
const cInk = ui.Color(0xFF111111); // ds-ignore 正文
const cCode = ui.Color(0xFF333333); // ds-ignore 编号 / 三字段
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);
// ── 主体两列布局(无页脚:主体直抵纸底,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) {
try {
final codec = await ui.instantiateImageCodec(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.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,内容=商品编号),供扫码枪扫码出库
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);
// 左列文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体水平居中、
// 竖直均分。2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
const double infoTop = bodyTop + padV; // 30
const double infoBot = barTop - 4; // 110
const double infoH = infoBot - infoTop; // 80
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 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;
final double gap = rawGap < 1.0 ? 1.0 : rawGap;
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);
yy += r.$2 * kLineH + gap;
}
return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640
}
/// 毫米数格式化为 TSPL 数值(整数不带小数点:40.0 → "40")。
String _tsplMm(double v) =>
v == v.roundToDouble() ? v.round().toString() : v.toString();
/// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与
/// 预览画布 100% 一致——彻底消除内置点阵字(TSS16/24)与预览 TrueType 度量不一致
@@ -349,11 +160,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,32 +169,37 @@ 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,
final img = await renderLabelImage(
tpl: tpl,
data: data,
drawBarcode: false,
drawQr: false,
);
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
final iw = img.width; // 12804× 超采样)
const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
const int ss = 4; // 超采样倍率,须与 renderLabelImage 的 scale 一致
const int subTotal = ss * ss; // 16 子像素/墨点
// 解析栅格流版式(与画布同源):QR/条码坐标随隐藏字段回流,全字段显示时=模型绝对坐标。
final lay = tpl.computeLayout();
// 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 +226,44 @@ Future<bool> _printFlatLabelThermal({
}
buf.add(ascii.encode('\r\n'));
// ── 右列二维码(原生 BITMAP,避免光栅降采样糊码):占主体整高、右对齐 ──
const qrW = 130, qrH = 130; // 2026-08-28 放大,对齐画布 QR134
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 (lay.qrShow && data.qrBytes != null) {
final int qrW = lay.qrSize.round();
final int qrH = qrW;
final int qrX = lay.qrX.round();
final int qrY = lay.qrY.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 (lay.barcodeShow && code.isNotEmpty) {
final int leftX = lay.barX.round();
final int leftW = lay.barW.round();
final int leftRight = leftX + leftW;
final int kBarH = lay.barH.round();
final int kBarY = lay.barY.round();
final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验)
final narrow = (leftW / estModules).floor().clamp(2, 4);
final barW = estModules * narrow;
@@ -452,7 +272,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,131 +293,49 @@ Future<void> printProductLabelImpl({
String shopAddress = '',
String shopPhone = '',
String? printerName,
LabelTemplate? template, // 版式单源;null → 内置默认(行为同重构前)
}) async {
try {
final font = await _loadFont();
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 doc = pw.Document();
final qrImage = pw.MemoryImage(qrBytes);
// Label: 40 × 20 mm(匹配实际标签纸尺寸)
const labelW = 40.0 * PdfPageFormat.mm;
const labelH = 20.0 * PdfPageFormat.mm;
const headerH = labelH * 0.20;
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致)
final subs = _labelSubFields(code, series, spec, productionDate);
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
code: code,
subs: subs,
qrBytes: qrBytes,
printerName: printerName)) {
tpl: tpl, data: data, printerName: printerName)) {
return;
}
// 商品名字号:随字数自适应(与预览一致,短名更大)
final double nameFs =
name.runes.length > 10 ? 6.5 : (name.runes.length > 7 ? 7.5 : 9.0);
// ── P3:非热敏(PDF / 办公打印机)整幅贴画布位图 ───────────────────────────
// 收口为「贴画布位图」:用与预览、热敏光栅同一套 _renderLabelBitmap(版式坐标 /
// 字号 / 开关单源),把整张标签渲染成一张位图铺满 40×20mm 页——令 PDF 观感与
// 预览、热敏 100% 一致;条码 / 二维码随位图光栅化(≥300dpi 办公机可扫,热敏
// 203dpi 路径不受影响,仍走上面原生 TSPL BARCODE / BITMAP)。
final labelImg = await renderLabelImage(tpl: tpl, data: data);
final labelPngBd =
await labelImg.toByteData(format: ui.ImageByteFormat.png);
final labelImage = pw.MemoryImage(labelPngBd!.buffer.asUint8List());
doc.addPage(pw.Page(
pageFormat: const PdfPageFormat(labelW, labelH),
margin: pw.EdgeInsets.zero,
build: (_) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// ── Header:深蓝店名头 ────────────────────────────────────────────────
pw.Container(
height: headerH,
color: _navy,
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
alignment: pw.Alignment.centerLeft,
child: pw.Text(shopName,
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font,
fontSize: 6,
fontWeight: pw.FontWeight.bold,
color: _cream,
letterSpacing: 0.5,
)),
),
// ── Body:左列(文字+条码) / 右列(二维码占整高) ─────────────────────────
pw.Expanded(
child: pw.Container(
color: PdfColors.white,
padding: const pw.EdgeInsets.fromLTRB(4, 3, 4, 3),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 左列
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体居中。
// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text(name,
textAlign: pw.TextAlign.center,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font,
fontSize: nameFs,
fontWeight: pw.FontWeight.bold,
color: _ink,
letterSpacing: 0.3)),
for (final s in subs) ...[
pw.SizedBox(height: 2),
pw.Text(s,
textAlign: pw.TextAlign.center,
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font,
fontSize: 5,
color: _footnote)),
],
],
),
),
// 左列底部整幅一维条码(Code128,内容=商品编号),供扫码枪
if (code.isNotEmpty) ...[
pw.SizedBox(height: 3),
pw.SizedBox(
height: 13,
child: pw.BarcodeWidget(
barcode: pw.Barcode.code128(),
data: code,
drawText: false,
color: _ink,
),
),
],
],
),
),
pw.SizedBox(width: 4),
// 右列:二维码占主体整高(正方形)
pw.AspectRatio(
aspectRatio: 1,
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
),
],
),
),
),
],
),
build: (_) => pw.Image(labelImage, fit: pw.BoxFit.fill),
));
// 预生成 PDF 字节,避免 macOS NSPrintPanel 打开后再调 doc.save() 导致主线程卡住。
@@ -617,15 +355,12 @@ Future<void> printProductLabelImpl({
}
/// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。
/// Web 平台或 qrBytes 为空时抛异常。
Future<Uint8List> renderLabelPreviewImpl(LabelData label) 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!,
/// [template] 为空时用内置默认版式(行为同重构前)。Web 平台或 qrBytes 为空时抛异常。
Future<Uint8List> renderLabelPreviewImpl(LabelData label,
{LabelTemplate? template}) async {
final img = await renderLabelImage(
tpl: template ?? LabelTemplate.builtinDefault(),
data: label,
);
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
return bd!.buffer.asUint8List();
+35 -107
View File
@@ -1,12 +1,13 @@
import 'dart:convert';
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:barcode/barcode.dart' as bc;
import 'package:web/web.dart' as web;
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
import 'label_bitmap.dart';
import 'label_data.dart';
import 'label_template.dart';
import 'order_print_meta.dart';
void _openPrintWindow(String html) {
@@ -31,126 +32,50 @@ Future<void> printProductLabelImpl({
String shopAddress = '',
String shopPhone = '',
String? printerName,
LabelTemplate? template,
}) async {
final base64Img = base64Encode(qrBytes);
// 版式单源(null → 内置默认版式,口径同桌面预览/PDF/TSPL)。
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 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,
];
final subLines = subs.map((s) => '<div class="sub">$s</div>').join();
// 商品名过长时缩小字号
final nameLen = name.runes.length;
final nameFontPt = nameLen > 10 ? 7.0 : (nameLen > 7 ? 8.0 : 9.0);
// 一维条码(Code128,内容=商品编号):矢量 SVG 内联,扫码枪扫码出库。
final barcodeSvg = code.isNotEmpty
? bc.Barcode.code128()
.toSvg(code, width: 200, height: 46, drawText: false)
: '';
// P3:Web 打印收口为「贴画布位图」——与桌面预览/热敏/PDF 共用同一套 renderLabelImage
// 把整张标签渲染成 PNG(版式坐标/字号/开关单源),铺满 40×20mm 页;令四端观感
// 100% 一致(条码/二维码随位图光栅化,浏览器→办公打印机 ≥300dpi 可扫)。
final png = await renderLabelPngBytes(tpl: tpl, data: data);
if (png == null) {
throw Exception('标签渲染失败:二维码未就绪,请稍后重试');
}
final base64Img = base64Encode(png);
final html = '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
<style>
/* 标签尺寸:40mm × 20mm,横向(对齐设计原型 devices.html 标签块*/
/* 标签尺寸:40mm × 20mm,横向(整幅贴位图,对齐 renderLabelImage 输出*/
@page { size: 40mm 20mm; margin: 0; }
* { box-sizing: border-box; margin: 0; padding: 0;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
.label {
width: 40mm; height: 20mm;
display: flex; flex-direction: column;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #111;
}
/* ── Header:深蓝店名头 3.2mm ── */
.hdr {
background: #1f2a3a; color: #f4ecd8;
height: 3.2mm; flex-shrink: 0;
display: flex; align-items: center;
padding: 0 1.2mm; overflow: hidden;
}
.hdr-name {
font-size: 6pt; font-weight: 700; letter-spacing: 0.05em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── 主体:左列(文字+条码) / 右列(二维码占整高) ── */
.middle {
flex: 1; min-height: 0; display: flex; gap: 0.8mm;
padding: 0.7mm 0.9mm; overflow: hidden;
}
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.info {
flex: 1; min-height: 0; display: flex; flex-direction: column;
justify-content: center; align-items: center; gap: 0.6mm; overflow: hidden;
}
.pname {
font-weight: 700; text-align: center; line-height: 1.1; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sub {
font-size: 5pt; color: #333; line-height: 1.15; text-align: center;
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bc { flex-shrink: 0; height: 4.6mm; }
.bc svg { width: 100%; height: 100%; display: block; }
/* 右:二维码占主体整高(正方形)*/
.qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; }
.qr img { width: 100%; height: 100%; object-fit: contain; display: block; }
html, body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
img { width: 40mm; height: 20mm; display: block; object-fit: fill; }
</style>
</head>
<body>
<div class="label">
<div class="hdr">
<span class="hdr-name">$shopName</span>
</div>
<div class="middle">
<div class="left">
<div class="info">
<div class="pname" style="font-size:${nameFontPt}pt">$name</div>
$subLines
</div>
<div class="bc">$barcodeSvg</div>
</div>
<div class="qr">
<img src="data:image/png;base64,$base64Img" />
</div>
</div>
</div>
<img src="data:image/png;base64,$base64Img" />
<script>
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(function() { window.print(); });
} else {
window.onload = function() { window.print(); };
}
window.onload = function() { window.print(); };
</script>
</body>
</html>''';
@@ -299,7 +224,10 @@ Future<void> printStockInOrderImpl(
// ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)─────────────────────────
Future<Uint8List?> renderLabelPreviewImpl(LabelData label) async => null;
Future<Uint8List?> renderLabelPreviewImpl(LabelData label,
{LabelTemplate? template}) async =>
renderLabelPngBytes(
tpl: template ?? LabelTemplate.builtinDefault(), data: label);
Future<List<String>> listLabelPrintersImpl() async => [];
@@ -7,12 +7,14 @@
// 打印模板窄屏不渲染(拍板:移动端无打印)。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart' show LabelData;
import '../../core/utils/print_util.dart'
show LabelData, activeLabelTemplate;
import '../../widgets/label_preview_dialog.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
@@ -989,7 +991,8 @@ class _DeviceManagementScreenState
onPressed: onPreview ?? () => _snack('模板预览即将上线')),
const SizedBox(width: 8),
DsButton('编辑',
small: true, onPressed: () => _snack('模板编辑即将上线')),
small: true,
onPressed: () => context.push('/devices/label-template')),
]),
],
),
@@ -1023,7 +1026,10 @@ class _DeviceManagementScreenState
if (!mounted) return;
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
builder: (_) => LabelPreviewDialog(
labels: [label],
template: shop != null ? activeLabelTemplate(shop.customFields) : null,
),
);
}
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -1,11 +1,13 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/errors/error_reporter.dart';
import '../core/theme/context_tokens.dart';
import '../core/responsive/responsive.dart';
import '../core/utils/print_util.dart';
import '../providers/shop_provider.dart';
import 'ds/ds_atoms.dart';
const _kPrinterKey = 'label_printer';
@@ -14,23 +16,37 @@ const _kPrinterKey = 'label_printer';
///
/// 单张模式(labels.length == 1):隐藏缩略图条,直接显示大图。
/// 多张模式:左侧竖排缩略图(带勾选),右侧大图主视图。
class LabelPreviewDialog extends StatefulWidget {
class LabelPreviewDialog extends ConsumerStatefulWidget {
final List<LabelData> labels;
/// 多张懒加载:按 productId 拉取 QR PNG 字节。单张场景可不传(qrBytes 已在 LabelData 中)。
final Future<Uint8List> Function(int productId)? qrFetcher;
/// 出签使用的版式模板;显式传入优先,否则自动取本店 active 模板
/// [activeLabelTemplate],缺失回退 [LabelTemplate.builtinDefault]),
/// 使店家自定义的价签版式在所有打印入口一致生效。
final LabelTemplate? template;
const LabelPreviewDialog({
super.key,
required this.labels,
this.qrFetcher,
this.template,
});
@override
State<LabelPreviewDialog> createState() => _LabelPreviewDialogState();
ConsumerState<LabelPreviewDialog> createState() =>
_LabelPreviewDialogState();
}
class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
class _LabelPreviewDialogState extends ConsumerState<LabelPreviewDialog> {
/// 有效模板:显式 template 优先,否则读本店 active 模板(异步未就绪时回退默认)。
LabelTemplate? get _effectiveTemplate {
if (widget.template != null) return widget.template;
final shop = ref.read(shopInfoProvider).valueOrNull;
return shop != null ? activeLabelTemplate(shop.customFields) : null;
}
int _selectedIndex = 0;
late List<Uint8List?> _previews;
late List<bool> _include;
@@ -80,7 +96,7 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
label.qrBytes = await widget.qrFetcher!(label.productId!);
}
if (label.qrBytes == null) continue;
final png = await renderLabelPreview(label);
final png = await renderLabelPreview(label, template: _effectiveTemplate);
if (mounted && png != null) {
setState(() => _previews[i] = png);
}
@@ -132,6 +148,7 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
shopAddress: label.shopAddress,
shopPhone: label.shopPhone,
printerName: _printer,
template: _effectiveTemplate,
);
done++;
if (mounted) {
+5 -2
View File
@@ -69,14 +69,17 @@ flutter:
assets:
- assets/fonts/NotoSansSC-Regular.ttf
- assets/fonts/NotoSansSC.ttf
- assets/fonts/NotoSansSC.ttf # 可变字体:golden_harness / tools 按路径加载,勿从 assets 移除
- assets/config/app_info.json
- assets/brand/logo_mark.png
fonts:
# family 指向静态 Regular(非可变字体):可变版 NotoSansSC.ttf 的 fvar 轴在 Windows
# dart:ui(Skia) 下解析失败 → 标签 canvas 文字整片空白(macOS CoreText 能取默认实例故正常)。
# 该 family 仅 print_util 标签 canvas 用,静态 Regular 三端一致可渲染,粗体走合成加粗。
- family: NotoSansSC
fonts:
- asset: assets/fonts/NotoSansSC.ttf
- asset: assets/fonts/NotoSansSC-Regular.ttf
# 等宽真相源(原型 --font-mono 首选 JetBrains MonoOFL 可再分发):
# 打包进 app → 原型截图 / golden / 三端真机等宽渲染完全同源。
- family: JetBrainsMono
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,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/models/shop.dart';
import 'package:jiu_client/providers/shop_provider.dart';
import 'package:jiu_client/screens/devices/label_template_editor_screen.dart';
/// 冒烟测试:编辑器屏在真 provider 下能构建/挂载(本屏无法走 golden——左预览用
/// `Picture.toImage` 直画光栅化,pumpAndSettle 骨架驱动不了,见 CONTRACT 块4)。
/// 这里只验证脚手架/面板正常渲染(预览停在静态占位,不阻塞 settle)。
const _shop = ShopInfo(
id: 1,
code: 'DSJH-001',
name: '鼎晟酒行',
address: '浙江省杭州市',
phone: '0571-88886666',
managerName: '王经理',
);
void main() {
testWidgets('标签模板编辑器 · 挂载渲染面板', (tester) async {
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(ProviderScope(
overrides: [shopInfoProvider.overrideWith((ref) => _shop)],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildTheme('a'),
home: const Scaffold(body: LabelTemplateEditorScreen()),
),
));
await tester.pumpAndSettle();
expect(find.text('标签模板编辑器'), findsOneWidget);
expect(find.text('显示字段'), findsOneWidget);
expect(find.text('打印参数'), findsOneWidget);
// 头部「打印」按钮(打印当前预览)
expect(find.text('打印'), findsOneWidget);
expect(find.text('模板'), findsWidgets);
// 字段行齐全(品名 + 二维码等)
expect(find.text('品名'), findsOneWidget);
expect(find.text('二维码'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,115 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/utils/label_template.dart';
/// 锁死模板存储读写(走 shop.custom_fields,零后端改动)与 JSON 往返,
/// 不触真实库——纯内存对象校验。
void main() {
group('LabelTemplate 存储 helpers', () {
test('custom_fields 无模板时回退内置默认', () {
final list = readLabelTemplates(<String, dynamic>{});
expect(list, hasLength(1));
expect(list.first.id, 'builtin-default');
});
test('activeLabelTemplate 按 id 命中,缺失回退第一项', () {
final a = LabelTemplate.builtinDefault();
final b = a.copyWith(id: 'tpl-2', name: '大字号');
final cf = <String, dynamic>{
'label_templates': [a.toJson(), b.toJson()],
'label_template_active': 'tpl-2',
};
expect(activeLabelTemplate(cf).id, 'tpl-2');
cf['label_template_active'] = 'nope';
expect(activeLabelTemplate(cf).id, 'builtin-default');
});
test('toJson → fromJson 往返保真(几何/开关/样式)', () {
final src = LabelTemplate.builtinDefault();
src.textStack.subLines[0].fontSize = 16;
src.textStack.subLines[0].bold = true;
src.qr.size = 120;
src.print.direction = 0;
final back = LabelTemplate.fromJson(src.toJson());
expect(back.textStack.subLines[0].fontSize, 16);
expect(back.textStack.subLines[0].bold, true);
expect(back.qr.size, 120);
expect(back.print.direction, 0);
});
test('存 → 读回列表保持顺序与命名', () {
final a = LabelTemplate.builtinDefault();
final b = a.copyWith(id: 'tpl-2', name: '双语抬头');
final cf = <String, dynamic>{
'label_templates': [a.toJson(), b.toJson()],
};
final list = readLabelTemplates(cf);
expect(list.map((t) => t.id), ['builtin-default', 'tpl-2']);
expect(list[1].name, '双语抬头');
});
test('老店铺旧默认 QR 加载时自动升级为居中坐标', () {
final def = LabelTemplate.builtinDefault();
// 构造两个历史默认签名的模板(贴头顶版 + 中间偏上版)
final legacyA = def.copyWith(id: 'tpl-legacy-a', name: '旧模板A');
legacyA.qr
..x = 178
..y = 25
..size = 134;
final legacyB = def.copyWith(id: 'tpl-legacy-b', name: '旧模板B');
legacyB.qr
..x = 190
..y = 34
..size = 116;
final cf = <String, dynamic>{
'label_templates': [legacyA.toJson(), legacyB.toJson()],
};
final list = readLabelTemplates(cf);
for (final t in list) {
expect(t.qr.x, def.qr.x, reason: '${t.name} QR.x 应升级到新默认');
expect(t.qr.y, def.qr.y, reason: '${t.name} QR.y 应升级到新默认');
expect(t.qr.size, def.qr.size, reason: '${t.name} QR.size 应升级到新默认');
}
});
test('内置默认模板永远回代码基准:存储里被改脏也覆盖回 builtinDefault', () {
final def = LabelTemplate.builtinDefault();
// 模拟老库存的 builtin-default:品名被关、QR 乱改、字号乱改
final stale = def.copyWith();
stale.textStack.name.show = false;
stale.qr
..x = 10
..y = 10
..size = 200;
stale.textStack.subLines[0].fontSize = 99;
final cf = <String, dynamic>{
'label_templates': [stale.toJson()],
};
final t =
readLabelTemplates(cf).firstWhere((x) => x.id == 'builtin-default');
expect(t.textStack.name.show, isTrue, reason: '品名默认必须展示');
expect(t.qr.x, def.qr.x);
expect(t.qr.y, def.qr.y);
expect(t.qr.size, def.qr.size);
expect(t.textStack.subLines[0].fontSize, def.textStack.subLines[0].fontSize);
});
test('用户自定义过的 QR 不匹配旧签名,加载时保持不动', () {
final custom = LabelTemplate.builtinDefault().copyWith(
id: 'tpl-custom',
name: '自定义位置',
);
custom.qr
..x = 210
..y = 60
..size = 80; // 与任一历史默认都不同
final cf = <String, dynamic>{
'label_templates': [custom.toJson()],
};
final t = readLabelTemplates(cf).first;
expect(t.qr.x, 210);
expect(t.qr.y, 60);
expect(t.qr.size, 80);
});
});
}
+1
View File
@@ -107,6 +107,7 @@ AppTokens 目前**仅颜色**。原型还驱动:
| 出库列表(桌/移) | `stock-out-list.html` | `stock_out_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill**2026-07-14 六项优化**:详情抽屉明细同增生产日期+批次号两列(抽屉 660)、状态筛选改多选 chip 且默认「草稿+待审核」、新建单默认填充默认仓库(原型 stock-out-list.html 同步);fidelity a 3.228% / b 2.217% / c 3.538%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden |
| 财务(桌/移) | `finance.html` | `finance_screen.dart` | 同步 | ✅ **Phase2 重建**:时间范围 chips(真实过滤)+KPI 4 卡(stock summary 环比)+收支趋势柱状图(DsBarChart, /finance/trend)+应收应付汇总(/finance/summary)+流水表+往来抽屉+登记收支(补往来单位);**2026-07-14 六项优化**:流水表筛选新增「往来单位」下拉、时间范围统一改用通用自定义时间组件(原型 finance.html/m-finance.html 同步);fidelity a 4.036% / b 3.131% / c 4.340%(均 ≤ 阈值 6%),移动 golden 自比全绿;**2026-07-14 追加**:应收/应付汇总卡头部新增「往来单位」搜索——本地即时过滤(`/finance/summary` 全量非分页,搜索口径=显示口径),桌面 `DsSearchBox``_secHead` trailing)/移动 应收·应付 分段头 `MSearchRow`(流水分段沿用既有 chip→sheet 单选,不重复),原型 finance.html `.sum-search` / m-finance.html 应收应付分段 `.m-search` 同步,finance 相关 6 个整屏 golden + m_finance 3 个 golden 已按新布局 `--update-goldens`**2026-07-14 验收反馈调整**:桌面「往来单位」搜索由卡标题行 `DsSearchBox` 挪进汇总表表头「往来单位」列头右侧,改为下拉单选 `ComboSearchField`(同 `_flowTable` 往来单位筛选控件,选中精确匹配一行/× 清除恢复全量,候选=当前汇总行往来单位);原型 `.sec-head .sum-search` 移除,改 `.th-party-wrap` 内嵌 `.combo` + `openSearchMenu` 弹层;移动端未动。desktop `finance` golden 3 主题已 `--update-goldens`3.11% 差异,目检确认新版式),`finance_mobile`/`m_finance` 无变化、原样通过;**2026-07-14 验收反馈2**(实机截图 3 处细节):①下拉候选由「仅当前汇总行往来单位」改为「全量往来单位」(`allPartnersProvider`,与 `_flowTable` toolbar 同源),选中一个汇总没有余额行的单位时走既有「没有匹配的往来单位」空态;②占位符叠词修复——`ComboSearchField.hint``'搜索往来单位'``'往来单位'`(内部会自动拼「搜索」前缀),弹层内搜索框恢复正常显示「搜索往来单位…」,收起态触发器文案对齐 `_flowTable` 写法同为「往来单位」;③表头行下拉垂直位置——`header` 外包 `SizedBox(height: 34)` 显式钉住 Row 高度消除潜在字体行高偏差,不改表头行 pad 11/14 既有行高口径。原型 finance.html 同步:`ALL_PARTNERS``SUMMARY` 全部单位 + 1 个无余额行的「鼎丰超市」演示空态)替换 `sumPartyId`/`SUMMARY` 候选、`#sumPartyInput` 收起态 placeholder 改「往来单位」;`.th-party-wrap{align-items:center}` + 浏览器 `th` 默认 `vertical-align:middle` 本就居中,未改 CSS。desktop `finance` golden 3 主题已 `--update-goldens`(0.04% 差异,目检确认新候选/占位文案,表头对齐无回归),`finance_mobile`/`m_finance` 无变化、原样通过 | ✅ fidelity | ✅ fidelity |
| 设备管理(桌/移) | `devices.html` | `device_management_screen.dart` | 同步 | ✅ **Phase2 重建**:会话表(DsTable)+外设卡网格(custom_fields.peripherals 本地存档)+打印模板;fidelity 2.43.0%≤8% | ✅ fidelity | ✅ fidelity |
| 标签模板编辑器(桌/移) | `label-template-editor.html` | `label_template_editor_screen.dart``/devices/label-template`,设备管理「价签·编辑」进入) | 同步 | ✅ **2026-08 P2 落地**:左实时预览(走真实 `renderLabelPreview` 渲染路径,示例数据)+ 右属性面板(模板管理[编辑对象/打印默认分离]·显示字段手风琴·高级区域位置·打印参数);存 `shop.custom_fields.label_templates`+`label_template_active`(零后端改)。**Option A**:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐,各自独立开关;仅品名支持自动字号;字体不做(热敏单色)。默认输出逐像素零变化由 `label_render_golden_test.dart` 守(改内置默认常量才触发)。⚠️ **本屏不入自动 fidelity/golden 闸**:左预览用 `Picture.toImage` 直画光栅化(同 label_render,需 `runAsync`),`pumpAndSettle` 的 widget-golden 骨架无法驱动→整屏 golden 会把预览拍成占位。原型=代码一致由 serve 目检 + 本行保证;存储读写由 `label_template_storage_test.dart` 单测守;真机打印坐标/条码可扫须用户在热敏机验收 | ⚠️ 见左(不入闸) | ⚠️ 见左(不入闸) |
| 系统设置(桌/移) | `settings.html` | `settings_screen.dart` | 同步 | ✅ **Phase2 重建**;假「系统参数」已删;fidelity 1.72.3%≤8%**2026-07-10 授权管理面板迁出**(提升为独立一级屏 `license.html`),subnav 剩 门店信息/用户管理/偏好——**真实端已同步完成**(`settings_screen.dart` 子导航同步摘除授权管理项,golden 已按新态重生成,见授权管理两行) | ✅ fidelity | ✅ fidelity |
| 用户管理(桌/移) | `users.html` | `users_screen.dart``/settings/users` | 同步 | ✅ **Phase2 新独立页**KPI 4 卡+搜索/角色筛选+rcard 弹窗,角色四级拉平;fidelity 1.32.1%≤8% | ✅ fidelity | ✅ fidelity |
| 关于我们(桌/移) | `about.html` | `about_screen.dart` | 同步 | ✅ **Phase2 重建**Hero/产品信息/更新日志 timeline(/public/release 同源)fidelity 2.22.7%≤8%;授权信息卡 2026-07-04 迁至 设置→授权管理 | ✅ fidelity | ✅ fidelity |
+1
View File
@@ -146,6 +146,7 @@
<a class="kit" href="screens/inventory-check.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic61"/></svg></div><div class="kt"><b>库存盘点</b><span>实盘录入 · 差异盈亏</span></div></a>
<a class="kit" href="screens/product-detail.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic62"/></svg></div><div class="kt"><b>商品详情</b><span>库存分布 · 流水 · 价格历史</span></div></a>
<a class="kit" href="screens/devices.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic63"/></svg></div><div class="kt"><b>设备管理</b><span>打印机/扫码枪 · 连接状态</span></div></a>
<a class="kit" href="screens/label-template-editor.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic12"/></svg></div><div class="kt"><b>标签模板编辑器</b><span>实时预览画布 / 属性面板</span></div></a>
<a class="kit" href="screens/notifications.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-bell"/></svg></div><div class="kt"><b>通知中心</b><span>列表 · 类型筛选 / 未读</span></div></a>
<a class="kit" href="screens/notification.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-bell"/></svg></div><div class="kt"><b>通知详情</b><span>正文 · 关联单据 / 操作</span></div></a>
<a class="kit" href="screens/about.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic24"/></svg></div><div class="kt"><b>关于我们</b><span>版本 · 更新日志 · 授权</span></div></a>
+1 -1
View File
@@ -115,7 +115,7 @@
<div class="tpl-desc">40×20mm · 店名 / 品名 / 编号 / 度数规格 / 生产日期 / 二维码 + 底部整幅一维条码</div>
<div class="tpl-acts">
<div class="btn ghost sm" onclick="openLabelPreview()"><svg viewBox="0 0 24 24"><use href="#i-eye"/></svg>预览</div>
<div class="btn ghost sm" onclick="toast('编辑标签模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>编辑</div>
<div class="btn ghost sm" onclick="location.href='label-template-editor.html'"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>编辑</div>
</div>
</div>
</div>
+2 -2
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,478 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="a">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>标签模板编辑器 · 岩美酒库</title>
<link rel="stylesheet" href="../tokens.css">
<link rel="stylesheet" href="../atoms.css">
<style>
body{margin:0; height:100vh; overflow:hidden; font-family:var(--font); color:var(--text); background:var(--bg); transition:background-color .28s;}
.app{height:100vh; display:flex; flex-direction:column;}
.main{padding:22px 26px; overflow:hidden; display:flex; flex-direction:column;}
.head{display:flex; align-items:flex-end; gap:14px; margin-bottom:18px; flex:none;}
.head h1{margin:0; font-size:var(--fs-h1); font-weight:700; color:var(--title); letter-spacing:.3px;}
.head .sub{font-size:var(--fs-sm); color:var(--muted); margin-bottom:2px;}
.head .actions{margin-left:auto; display:flex; gap:10px;}
/* ── 双栏:左预览 · 右属性 ── */
.lt-wrap{flex:1; display:grid; grid-template-columns:minmax(0,1fr) 400px; gap:22px; min-height:0;}
/* 左 · 实时预览舞台 */
.lt-stage{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--sh-1); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; padding:24px; min-height:0;}
.lt-stitle{align-self:flex-start; font-size:var(--fs-sm); font-weight:600; color:var(--muted); letter-spacing:.4px;}
/* 舞台预留 100% 尺寸(320×160 @1.5=480×240);缩放靠 label 自身 transform */
.lt-frame{width:480px; height:240px; display:flex; align-items:center; justify-content:center;}
/* 价签纸面 320×160(40×20mm@203dpi)flex 栅格流,内容自动填满整纸,删字段自动回流补位 */
.lt-label{width:320px; height:160px; background:#fff; border-radius:var(--r-sm); box-shadow:var(--sh-2); overflow:hidden; transform:scale(1.5); transform-origin:center; transition:transform .18s;
display:flex; flex-direction:column; box-sizing:border-box;}
/* 行①店名 bandflex:none,关掉后整体上填) */
.lt-hd{flex:none; padding:4px 12px; text-align:center;
background:#1f2a3a; /* ds-allow 价签抬头深蓝(物理印刷色,非主题) */ }
.lt-shop{font-size:14px; font-weight:700; letter-spacing:.5px;
color:#fff; /* ds-allow 抬头白字 */ }
/* 行②主体(flex:1 撑满抬头以下全部高):左列(文字+条码) + 右列(整高二维码) */
.lt-main{flex:1; min-height:0; display:flex; align-items:stretch; gap:10px; padding:8px 12px;}
/* 左列:文字栏在上、条码在下,条码与文字同宽 */
.lt-left{flex:1; min-width:0; display:flex; flex-direction:column; gap:6px;}
.lt-txt{flex:1; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; text-align:center;}
.lt-name{font-size:18px; font-weight:700; line-height:1.15;
color:#1a1a1a; /* ds-allow 价签品名墨色 */ }
.lt-sub{font-size:14px; line-height:1.15;
color:#3a3a3a; /* ds-allow 价签副字段灰 */ }
/* 二维码:正方形,撑满整个主体高度(抬头以下一整列,横跨文字+条码) */
.lt-qr{flex:none; align-self:stretch; aspect-ratio:1; border-radius:var(--r-sm);
background:repeating-conic-gradient(#000 0 25%, #fff 0 50%) 0 0/12px 12px; /* ds-allow 二维码占位棋盘 */ }
/* 条码 bandflex:none,宽度=左列宽=文字宽;关掉后左列上填) */
.lt-foot{flex:none;}
.lt-bars{width:100%; height:30px;
background:repeating-linear-gradient(90deg, #000 0 2px, #fff 2px 5px, #000 5px 6px, #fff 6px 10px); /* ds-allow 一维条码占位 */ }
.lt-off{display:none !important;}
.lt-cap{font-size:var(--fs-xs); color:var(--muted); letter-spacing:.4px;}
.lt-tools{display:flex; align-items:center; gap:12px;}
.lt-tools .lbl{font-size:var(--fs-xs); color:var(--muted);}
/* 右 · 属性面板 */
.lt-panel{overflow-y:auto; padding-right:4px; display:flex; flex-direction:column; gap:14px;}
.lt-sec{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:16px 16px 18px;}
.lt-sectitle{font-size:var(--fs-sm); font-weight:700; color:var(--title); margin:0 0 12px; display:flex; align-items:center; gap:8px;}
.lt-sectitle svg{width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:2; color:var(--primary);}
.lt-mgr{display:flex; flex-wrap:wrap; gap:8px; margin-top:10px;}
/* 字段清单(每行内嵌样式,手风琴展开一行) */
.lt-field{border-radius:var(--r-md);}
.lt-field + .lt-field{margin-top:2px;}
.lt-frow{display:flex; align-items:center; gap:10px; padding:9px 8px; border-radius:var(--r-md); cursor:pointer; transition:background .15s;}
.lt-frow:hover{background:var(--row-hover);}
.lt-field.open>.lt-frow{background:var(--accent-soft);}
.lt-fname{flex:1; font-size:var(--fs-body); color:var(--text);}
.lt-fmeta{font-size:var(--fs-xs); color:var(--muted);}
.lt-frow .switch{flex:none;}
.lt-frow .chev{width:16px; height:16px; flex:none; stroke:currentColor; fill:none; stroke-width:2; color:var(--muted); transition:transform .18s;}
.lt-field.open>.lt-frow .chev{transform:rotate(180deg);}
.lt-field.disabled .lt-fname{color:var(--muted); text-decoration:line-through;}
/* 折叠体:默认收起,展开显字段样式 */
.lt-fbody{display:none; padding:6px 8px 14px;}
.lt-field.open>.lt-fbody{display:block;}
.lt-srow{display:flex; align-items:center; gap:12px; margin-bottom:12px;}
.lt-srow:last-child{margin-bottom:0;}
.lt-srow .k{width:44px; flex:none; font-size:var(--fs-sm); color:var(--muted);}
.lt-srow .input{width:80px;}
/* 字号步进器:输入框右侧内嵌上下箭头,点击 +1/-1 */
.lt-stepper{position:relative; width:80px; flex:none;}
.lt-stepper .input{width:100%; box-sizing:border-box; padding-right:22px;}
.lt-steparr{position:absolute; right:1px; top:1px; bottom:1px; width:20px;
display:flex; flex-direction:column; border-left:1px solid var(--border);}
.lt-steparr button{flex:1; display:flex; align-items:center; justify-content:center;
border:0; background:transparent; cursor:pointer; color:var(--muted); padding:0;}
.lt-steparr button:hover{background:var(--row-hover); color:var(--text);}
.lt-steparr button:last-child{border-top:1px solid var(--border);}
.lt-steparr button:disabled{opacity:.4; cursor:default; background:transparent; color:var(--muted);}
.lt-steparr svg{width:11px; height:11px; stroke:currentColor; fill:none; stroke-width:2.5;}
.lt-gnote{margin:0; font-size:var(--fs-xs); color:var(--muted); line-height:1.6;}
/* 字体下拉(自包含浮层,每行独立) */
.lt-fontsel{position:relative; flex:1;}
.lt-fontsel .ctrl{width:100%; box-sizing:border-box;}
.lt-fontname{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.lt-fontpop{position:absolute; left:0; top:calc(100% + 4px); width:100%; box-sizing:border-box; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-md); box-shadow:var(--sh-3); padding:4px; display:none; z-index:var(--z-pop);}
.lt-fontpop.show{display:block;}
.lt-fontitem{display:flex; align-items:center; padding:8px 10px; border-radius:var(--r-sm); font-size:var(--fs-body); color:var(--text); cursor:pointer;}
.lt-fontitem:hover{background:var(--row-hover);}
.lt-fontitem.on{color:var(--primary); font-weight:600;}
/* 「打印默认」徽标 + 提示 */
.lt-tag{margin-left:auto; font-size:var(--fs-xs); font-weight:600; color:var(--primary); background:var(--accent-soft); border-radius:var(--r-pill); padding:1px 8px;}
.lt-tplhint{display:flex; align-items:center; gap:6px; margin-top:12px; padding-top:12px; border-top:1px solid var(--border); font-size:var(--fs-xs); color:var(--muted);}
.lt-tplhint svg{width:14px; height:14px; flex:none; stroke:currentColor; fill:none; stroke-width:2; color:var(--primary);}
.lt-tplhint b{color:var(--primary); font-weight:600;}
/* 默认模板只读锁:提示条 + 受锁区域整体禁用(点「复制」为可编辑逃生口) */
.lt-locknote{margin-top:10px; padding:10px 12px; background:var(--warn-bg); border:1px solid var(--warn); border-radius:var(--r-md); font-size:var(--fs-xs); color:var(--warn); line-height:1.5;}
.lt-lockoff{transition:opacity .15s;}
.dis{opacity:.45; pointer-events:none;}
.lt-panel.locked #fieldList, .lt-panel.locked .lt-adv, .lt-panel.locked #printSec{opacity:.5; pointer-events:none;}
/* 高级折叠 */
.lt-adv{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden;}
.lt-adv>summary{list-style:none; cursor:pointer; padding:14px 16px; font-size:var(--fs-sm); font-weight:700; color:var(--title); display:flex; align-items:center; gap:8px;}
.lt-adv>summary::-webkit-details-marker{display:none;}
.lt-adv>summary .chev{margin-left:auto; width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:2; color:var(--muted); transition:transform .18s;}
.lt-adv[open]>summary .chev{transform:rotate(180deg);}
.lt-adv .body{padding:0 16px 16px;}
.lt-grid2{display:grid; grid-template-columns:1fr 1fr; gap:10px 14px;}
.ds-fab{position:fixed; right:22px; bottom:22px; width:44px; height:44px; border-radius:var(--r-pill); background:var(--surface); color:var(--muted); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; box-shadow:var(--sh-2); z-index:var(--z-fab); opacity:.6; transition:opacity .18s,color .18s,border-color .18s,transform .18s,box-shadow .18s; text-decoration:none;}
.ds-fab:hover{opacity:1; color:var(--primary); border-color:var(--primary); box-shadow:var(--sh-3); transform:translateY(-2px);}
.ds-fab svg{width:20px; height:20px; stroke-width:2; fill:none; stroke:currentColor;}
</style>
</head>
<body>
<div class="app">
<div class="top" data-shell-top data-active="devices"></div>
<div class="body">
<div class="side" id="side"></div>
<div class="main">
<div class="head">
<div class="btn ghost sm" onclick="location.href='devices.html'"><svg viewBox="0 0 24 24"><use href="#i-arrow-left"/></svg>返回</div>
<h1>标签模板编辑器</h1><span class="sub">左侧实时预览 · 右侧属性面板</span>
<div class="actions">
<div class="btn ghost lt-lockoff" onclick="resetTpl()"><svg viewBox="0 0 24 24"><use href="#i-refresh"/></svg>重置</div>
<div class="btn ghost" onclick="printTpl()"><svg viewBox="0 0 24 24"><use href="#i-printer"/></svg>打印</div>
<div class="btn primary" onclick="toast('模板已保存 ✓')"><svg viewBox="0 0 24 24"><use href="#i-check"/></svg>保存</div>
</div>
</div>
<div class="lt-wrap">
<!-- 左 · 实时预览 -->
<div class="lt-stage">
<div class="lt-stitle">实时预览</div>
<div class="lt-frame">
<div class="lt-label" id="label">
<div class="lt-hd" data-f="shop"><span class="lt-shop">鼎晟酒行</span></div>
<div class="lt-main">
<div class="lt-left">
<div class="lt-txt">
<div class="lt-name" data-f="name">贵州茅台酒</div>
<div class="lt-sub"><span data-f="code">P1001</span>&nbsp;&nbsp;&nbsp;&nbsp;<span data-f="series">飞天53度</span></div>
<div class="lt-sub"><span data-f="spec">500ml</span>&nbsp;&nbsp;&nbsp;&nbsp;<span data-f="date">2024-05-18</span></div>
</div>
<div class="lt-foot" data-f="barcode"><div class="lt-bars"></div></div>
</div>
<div class="lt-qr" data-f="qr"></div>
</div>
</div>
</div>
<div class="lt-tools">
<span class="lbl">缩放</span>
<div class="seg" id="zoom">
<button onclick="setZoom(1.125,this)">75%</button>
<button class="on" onclick="setZoom(1.5,this)">100%</button>
<button onclick="setZoom(1.875,this)">125%</button>
</div>
</div>
<div class="lt-cap">40 × 20 mm · 203 dpi · 示例数据</div>
</div>
<!-- 右 · 属性面板 -->
<div class="lt-panel">
<!-- 模板管理 -->
<div class="lt-sec">
<div class="lt-sectitle"><svg viewBox="0 0 24 24"><use href="#i-ic12"/></svg>模板</div>
<div class="field">
<div class="lt-fontsel">
<div class="ctrl" onclick="toggleTpl(event)"><span class="lt-fontname" id="tplName">默认版式</span><svg width="14" height="14" viewBox="0 0 24 24" stroke-width="2"><use href="#i-chevron-down"/></svg></div>
<div class="lt-fontpop" id="tplPop"></div>
</div>
</div>
<div class="lt-mgr">
<div class="btn ghost sm" onclick="toast('新建模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-plus"/></svg>新建</div>
<div class="btn ghost sm" onclick="toast('复制模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-ic27"/></svg>复制</div>
<div class="btn ghost sm lt-lockoff" onclick="openRename()"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>重命名</div>
<div class="btn ghost sm" onclick="setDefaultTpl()"><svg viewBox="0 0 24 24"><use href="#i-check"/></svg>设默认</div>
<div class="btn danger sm lt-lockoff" onclick="toast('删除模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-close"/></svg>删除</div>
</div>
<div class="lt-locknote" id="lockNote">系统默认模板不可修改,点「复制」生成可编辑副本</div>
<div class="lt-tplhint"><svg viewBox="0 0 24 24"><use href="#i-check"/></svg>出签打印使用:<b id="tplActive">默认版式</b></div>
</div>
<!-- 显示字段(样式内嵌每行,选中展开折叠) -->
<div class="lt-sec">
<div class="lt-sectitle"><svg viewBox="0 0 24 24"><use href="#i-eye"/></svg>显示字段<span class="lt-fmeta" style="margin-left:auto;font-weight:400">点字段名展开样式</span></div>
<div id="fieldList"></div>
</div>
<!-- 高级 · 区域位置 -->
<details class="lt-adv">
<summary><svg viewBox="0 0 24 24" style="width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2"><use href="#i-ic12"/></svg>高级 · 区域位置<svg class="chev" viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></summary>
<div class="body">
<div class="lt-fmeta" style="margin-bottom:10px">二维码</div>
<div class="lt-grid2">
<div class="field"><label>X</label><input class="input" value="192"></div>
<div class="field"><label>Y</label><input class="input" value="32"></div>
<div class="field"><label>尺寸</label><input class="input" value="120"></div>
</div>
<div class="lt-fmeta" style="margin:14px 0 10px">条形码</div>
<div class="lt-grid2">
<div class="field"><label>X</label><input class="input" value="8"></div>
<div class="field"><label>Y</label><input class="input" value="114"></div>
<div class="field"><label></label><input class="input" value="164"></div>
<div class="field"><label></label><input class="input" value="40"></div>
</div>
<div class="lt-fmeta" style="margin:14px 0 10px">抬头</div>
<div class="lt-grid2">
<div class="field"><label>高度</label><input class="input" value="24"></div>
</div>
</div>
</details>
<!-- 打印参数(常驻可见) -->
<div class="lt-sec" id="printSec">
<div class="lt-sectitle"><svg viewBox="0 0 24 24"><use href="#i-gear"/></svg>打印参数</div>
<div class="lt-grid2">
<div class="field"><label>纸张宽 (mm)</label><input class="input" value="40"></div>
<div class="field"><label>纸张高 (mm)</label><input class="input" value="20"></div>
<div class="field"><label>DPI</label><input class="input" value="203"></div>
<div class="field"><label>间隔 (mm)</label><input class="input" value="2"></div>
<div class="field"><label>打印密度</label><input class="input" value="10"></div>
<div class="field"><label>打印速度</label><input class="input" value="2"></div>
<div class="field"><label>份数</label><input class="input" value="1"></div>
</div>
<div class="lt-srow" style="margin-top:14px">
<span class="k">打印方向</span>
<div class="seg" id="dir">
<button class="on" onclick="pickSeg(this)">正向</button>
<button onclick="pickSeg(this)">反向</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 重命名弹窗 -->
<div class="overlay" id="ovRename"><div class="modal" style="width:420px">
<div class="modal-head"><h3>重命名模板</h3><svg class="x" viewBox="0 0 24 24" stroke-width="2" onclick="closeRename()"><use href="#i-close"/></svg></div>
<div class="modal-body">
<div class="frow"><div class="field"><label>模板名称</label><div class="ctrl input-like"><input id="renameInput" value="默认版式"></div></div></div>
</div>
<div class="modal-foot"><div class="btn ghost" onclick="closeRename()">取消</div><div class="btn primary" onclick="doRename()">保存</div></div>
</div></div>
<div class="toast" id="toast"></div>
<script src="shell.js"></script>
<script>
/* ── 字段清单:样式内嵌每行,手风琴展开 ── */
/* 1 期字体:仅系统字体 + 应用已内置思源黑体,不打包新字体(宋体已撤)。
楷体/等宽蹭系统,缺失回退默认。 */
// 字体不做(热敏单色 MVP);仅「品名」支持自动字号。同物理行两字段共用字号/加粗/对齐(PAIR)。
const ALIGN_LABEL = {left:'左', center:'居中', right:'右'};
const FIELDS = [
{f:'name', label:'品名', type:'text', on:true, auto:true, bold:true, align:'center', size:18, sample:'贵州茅台酒'},
{f:'shop', label:'店名(抬头)', type:'text', on:true, auto:false, bold:true, align:'center', size:14, sample:'鼎晟酒行'},
{f:'code', label:'编号', type:'text', on:true, auto:false, bold:false, align:'center', size:14, sample:'P1001'},
{f:'series', label:'型号 / 度数', type:'text', on:true, auto:false, bold:false, align:'center', size:14, sample:'飞天53度'},
{f:'spec', label:'版本 / 规格', type:'text', on:true, auto:false, bold:false, align:'center', size:14, sample:'500ml'},
{f:'date', label:'生产日期', type:'text', on:true, auto:false, bold:false, align:'center', size:14, sample:'2024-05-18'},
{f:'qr', label:'二维码', type:'graphic', on:true},
{f:'barcode', label:'条形码', type:'graphic', on:true},
];
// 编号↔型号 同物理行、版本↔日期 同物理行:字号/加粗/对齐共用(Option A)
const PAIR = {2:3, 3:2, 4:5, 5:4};
function metaText(fd){
if(fd.type==='graphic') return '图形 · 位置见高级';
const parts = [fd.auto ? '自动字号' : fd.size+'px'];
if(fd.bold) parts.push('加粗');
parts.push(ALIGN_LABEL[fd.align]);
return parts.join(' · ');
}
function fontBody(fd, i){
const autoCtrl = fd.f==='name'
? `<span class="lt-fmeta">自动</span>
<button class="switch${fd.auto?' on':''}" onclick="toggleAuto(event,${i})"></button>`
: '';
return `<div class="lt-fbody">
<div class="lt-srow"><span class="k">内容</span>
<input class="input" style="flex:1;width:auto" value="${fd.sample||''}" oninput="setSample(${i},this.value)"></div>
<p class="lt-gnote" style="margin:-4px 0 12px">仅改预览示例,真实打印按商品数据填充</p>
<div class="lt-srow"><span class="k">字号</span>
<div class="lt-stepper">
<input class="input" value="${fd.size}" ${fd.auto?'disabled':''} oninput="setSize(${i},this.value)">
<div class="lt-steparr">
<button type="button" ${fd.auto?'disabled':''} onclick="stepSize(${i},1)" aria-label="增大字号"><svg viewBox="0 0 24 24"><use href="#i-chevron-up"/></svg></button>
<button type="button" ${fd.auto?'disabled':''} onclick="stepSize(${i},-1)" aria-label="减小字号"><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></button>
</div>
</div>
${autoCtrl}</div>
<div class="lt-srow"><span class="k">加粗</span>
<button class="switch${fd.bold?' on':''}" onclick="toggleBold(event,${i})"></button></div>
<div class="lt-srow"><span class="k">对齐</span>
<div class="seg">
<button class="${fd.align==='left'?'on':''}" onclick="setAlign(event,${i},'left')">左</button>
<button class="${fd.align==='center'?'on':''}" onclick="setAlign(event,${i},'center')">居中</button>
<button class="${fd.align==='right'?'on':''}" onclick="setAlign(event,${i},'right')">右</button>
</div></div>
</div>`;
}
function graphicBody(fd){
return `<div class="lt-fbody"><p class="lt-gnote">图形字段,无文字样式。位置与尺寸在下方「高级 · 区域位置」中调整。</p></div>`;
}
function rowHtml(fd, i){
return `<div class="lt-field${fd.on?'':' disabled'}" data-i="${i}">
<div class="lt-frow" onclick="expandField(${i})">
<button class="switch${fd.on?' on':''}" onclick="tglField(event,${i})"></button>
<span class="lt-fname">${fd.label}</span>
<span class="lt-fmeta">${metaText(fd)}</span>
<svg class="chev" viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg>
</div>
${fd.type==='graphic' ? graphicBody(fd) : fontBody(fd, i)}
</div>`;
}
function renderFields(){
document.getElementById('fieldList').innerHTML = FIELDS.map(rowHtml).join('');
FIELDS.forEach((_,i)=>applyStyle(i));
}
function fieldEl(i){ return document.querySelector('.lt-field[data-i="'+i+'"]'); }
function previewEls(i){ return document.querySelectorAll('.lt-label [data-f="'+FIELDS[i].f+'"]'); }
function refreshMeta(i){ fieldEl(i).querySelector('.lt-fmeta').textContent = metaText(FIELDS[i]); }
function applyStyle(i){
const fd = FIELDS[i];
previewEls(i).forEach(el=>{
el.classList.toggle('lt-off', !fd.on);
if(fd.type==='text'){
el.style.fontWeight = fd.bold ? '700' : '400';
el.style.textAlign = fd.align;
el.style.fontSize = fd.auto ? '' : fd.size+'px';
}
});
}
/* 同物理行配对字段(编号↔型号、版本↔日期)共用字号/加粗/对齐 */
function mirror(i){
const p = PAIR[i]; if(p==null) return;
FIELDS[p].size = FIELDS[i].size;
FIELDS[p].bold = FIELDS[i].bold;
FIELDS[p].align = FIELDS[i].align;
applyStyle(p); refreshMeta(p);
}
/* 手风琴:一次只展开一行 */
function expandField(i){
const el = fieldEl(i), wasOpen = el.classList.contains('open');
document.querySelectorAll('.lt-field.open').forEach(f=>f.classList.remove('open'));
if(!wasOpen) el.classList.add('open');
}
function tglField(e, i){
e.stopPropagation();
FIELDS[i].on = !FIELDS[i].on;
e.currentTarget.classList.toggle('on', FIELDS[i].on);
fieldEl(i).classList.toggle('disabled', !FIELDS[i].on);
applyStyle(i);
}
function setSize(i, v){
const n = parseInt(v,10);
if(!isNaN(n)){ FIELDS[i].size = n; applyStyle(i); refreshMeta(i); mirror(i); }
}
/* 内容:仅改左侧预览示例文字,不影响真实打印(真实出签永远取商品数据) */
function setSample(i, v){
FIELDS[i].sample = v;
previewEls(i).forEach(el=>{ el.textContent = v; });
}
/* 步进器 +1/-1:夹在 [6,48],回写输入框并刷新预览 */
function stepSize(i, d){
if(FIELDS[i].auto) return;
let n = parseInt(FIELDS[i].size,10); if(isNaN(n)) n = 14;
n = Math.max(6, Math.min(48, n + d));
FIELDS[i].size = n;
const inp = fieldEl(i).querySelector('.lt-stepper .input');
if(inp) inp.value = n;
applyStyle(i); refreshMeta(i); mirror(i);
}
function toggleAuto(e, i){
e.stopPropagation();
FIELDS[i].auto = !FIELDS[i].auto;
e.currentTarget.classList.toggle('on', FIELDS[i].auto);
fieldEl(i).querySelector('.lt-srow .input').disabled = FIELDS[i].auto;
fieldEl(i).querySelectorAll('.lt-steparr button').forEach(b=>b.disabled = FIELDS[i].auto);
applyStyle(i); refreshMeta(i);
}
function toggleBold(e, i){
e.stopPropagation();
FIELDS[i].bold = !FIELDS[i].bold;
e.currentTarget.classList.toggle('on', FIELDS[i].bold);
applyStyle(i); refreshMeta(i); mirror(i);
}
function setAlign(e, i, a){
e.stopPropagation();
FIELDS[i].align = a;
e.currentTarget.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('on'));
e.currentTarget.classList.add('on');
applyStyle(i); refreshMeta(i); mirror(i);
}
function pickSeg(btn){
btn.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('on'));
btn.classList.add('on');
}
function setZoom(z, btn){
btn.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('on'));
btn.classList.add('on');
document.getElementById('label').style.transform = 'scale('+z+')';
}
function resetTpl(){ toast('已重置为默认版式'); }
/* 打印当前预览:真实端弹出签打印弹窗(选打印机/份数),此处仅提示 */
function printTpl(){ toast('打开打印预览…'); }
/* 模板:下拉选=正在编辑;「设默认」=打印实际使用的那个(二者可不同) */
const TPLS = ['默认版式', '大字号简约', '双语抬头'];
let tplEditing = '默认版式'; // 正在编辑
let tplActive = '默认版式'; // 出签打印默认
function renderTplPop(){
document.getElementById('tplPop').innerHTML = TPLS.map(n=>
`<div class="lt-fontitem${n===tplEditing?' on':''}" onclick="pickTpl(event,'${n}')">${n}${n===tplActive?'<span class="lt-tag">打印默认</span>':''}</div>`
).join('');
}
function toggleTpl(e){
e.stopPropagation();
const p = document.getElementById('tplPop'), open = p.classList.contains('show');
document.querySelectorAll('.lt-fontpop.show').forEach(x=>x.classList.remove('show'));
if(!open) p.classList.add('show');
}
function pickTpl(e, name){
e.stopPropagation();
tplEditing = name;
document.getElementById('tplName').textContent = name;
document.getElementById('renameInput').value = name;
document.getElementById('tplPop').classList.remove('show');
renderTplPop();
applyLock();
toast('正在编辑「'+name+'」');
}
/* 默认模板('默认版式')受保护只读:显示锁提示、禁用字段/高级/打印/重命名/删除/重置,
其余(下拉/新建/复制/设默认)保持可用;点「复制」生成可编辑副本 */
function isLockedTpl(){ return tplEditing === '默认版式'; }
function applyLock(){
const on = isLockedTpl();
document.getElementById('lockNote').style.display = on ? '' : 'none';
document.querySelector('.lt-panel').classList.toggle('locked', on);
document.querySelectorAll('.lt-lockoff').forEach(b=>b.classList.toggle('dis', on));
}
function setDefaultTpl(){
tplActive = tplEditing;
document.getElementById('tplActive').textContent = tplActive;
renderTplPop();
toast('已设为打印默认:'+tplActive);
}
renderTplPop();
document.addEventListener('click', ()=>document.querySelectorAll('.lt-fontpop.show').forEach(p=>p.classList.remove('show')));
renderFields();
applyLock();
function openRename(){ document.getElementById('ovRename').classList.add('show'); }
function closeRename(){ document.getElementById('ovRename').classList.remove('show'); }
function doRename(){ closeRename(); toast('已重命名 ✓'); }
document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeRename(); closeMenus(); } });
</script>
<a class="ds-fab" href="../index.html" title="返回设计系统" aria-label="返回设计系统"><svg viewBox="0 0 24 24"><use href="#i-arrow-left"/></svg></a>
<script src="fab.js"></script>
<script src="notify.js"></script>
<script src="statusbar.js"></script>
<script src="icons.js"></script>
</body>
</html>
+205
View File
@@ -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×1604× 超采样)</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>&lt;img&gt;</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>每款中文约 48MB</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>(区域+自动栈)+ 画布渲染器读模型;内置默认模板=现 constfacade/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>
+1
View File
@@ -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>
File diff suppressed because one or more lines are too long