chore: release client-v1.1.12
Deploy Client / build-client-web (push) Successful in 5m19s
Deploy Client / build-macos (push) Successful in 7m36s
Deploy Client / build-android (push) Successful in 6m18s
Deploy Client / build-ios (push) Successful in 6m2s
Deploy Client / build-windows (push) Successful in 5m27s
Deploy Client / release-deploy-client (push) Successful in 10m49s

价签标签两栏布局改版 + 生产日期修复 + 模板预览接真实渲染 +
Windows/macOS 应用内更新进度条修复。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
This commit is contained in:
wangjia
2026-08-27 17:52:03 +08:00
parent eb7b0edc96
commit c3a127485f
11 changed files with 492 additions and 246 deletions
+323 -173
View File
@@ -3,6 +3,7 @@ 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:charset_converter/charset_converter.dart';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
@@ -155,10 +156,13 @@ bool _winRawPrint(String printerName, Uint8List data) {
}
}
/// 在 canvas 上画一行文字(黑色,单行不换行,CJK 走系统字体回退)
/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
void _drawText(
ui.Canvas canvas, String s, double x, double y, double size, double maxW,
{bool bold = false, bool center = false}) {
{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(
fontSize: size,
@@ -167,13 +171,28 @@ void _drawText(
maxLines: 1,
ellipsis: '',
))
..pushStyle(
ui.TextStyle(color: const ui.Color(0xFF000000))) // ds-ignore: 打印画布纸面固定色
..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(
@@ -196,6 +215,22 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
return size;
}
/// 组装标签底部平铺的三字段:度数(型号) / 规格(版本) / 生产日期。
/// 各值为空则跳过;日期截断到 10 字符(YYYY-MM-DD)。四路渲染共用,口径一致。
List<String> _labelSubFields(
String? series, String? spec, String? productionDate) {
final date = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10
? productionDate.substring(0, 10)
: productionDate)
: '';
return [
if ((series ?? '').isNotEmpty) series!,
if ((spec ?? '').isNotEmpty) spec!,
if (date.isNotEmpty) date,
];
}
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
@@ -203,8 +238,7 @@ Future<ui.Image> _renderLabelBitmap({
required String shop,
required String name,
required String code,
required String degSpec,
required String date,
required List<String> subs, // 底部平铺字段:度数 / 规格 / 日期(已过滤空)
required Uint8List qrBytes,
}) async {
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
@@ -214,61 +248,132 @@ Future<ui.Image> _renderLabelBitmap({
final canvas =
ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFFFFFF)); // ds-ignore: 打印画布纸面固定色
// 右侧二维码(解码后端 PNG 直接画上)
const qrS = 124.0;
const qrTop = 8.0;
const qrLeft = w - qrS - 4; // 192
// 纸面固定配色(对齐设计原型 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 编号 / 三字段
const cFoot = ui.Color(0xFF5A4E35); // 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, 5, 12, w - 20, bold: true, color: cCream);
// ── 米色页脚 ────────────────────────────────────────────────
const footerH = 15.0;
const footerTop = h - footerH; // 145
canvas.drawRect(ui.Rect.fromLTWH(0, footerTop, w.toDouble(), footerH),
ui.Paint()..color = cCream);
_drawText(canvas, shop, 10, footerTop + 3.5, 8, 180, color: cFoot);
final szW = _measureWidth('40×20mm', 8, false);
_drawText(canvas, '40×20mm', w - 10 - szW, footerTop + 3.5, 8, szW,
color: cFoot);
// ── 主体两列布局 ────────────────────────────────────────────
const bodyTop = headerH; // 24
const bodyBot = footerTop; // 145
const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间
const double colGap = 6.0; // 左列与二维码列间距(原 10,收小)
// 右列:二维码占主体整高(正方形)
const double qrSize = (bodyBot - bodyTop) - 2 * padV; // 109
const double qrTop = bodyTop + padV; // 30
const double qrLeft = w - padH - qrSize; // 203
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, qrS, qrS),
const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize),
ui.Paint());
} catch (e) {
debugPrint('[label] QR 解码失败: $e');
}
// 扫码溯源:居中于二维码正下方
_drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true);
// 左侧文字:固定字号 + 自适应行间距(与二维码上下对齐,行距均匀分配剩余空间)
const lx = 22.0;
const textMaxW = qrLeft - lx - 6; // 164
// 左列几何
const double leftX = padH; // 8
const double leftRight = qrLeft - colGap; // 197
const double leftW = leftRight - leftX; // 189
// 字号固定:只有商品名按宽度自适应,其余行均固定,杜绝自动放大
const double kShopSize = 17.0; // 酒行名,粗体
const double kSmallSize = 13.0; // 编号 / 度数 / 日期,统一小字
// 商品名:仅按宽度适配,上限 26px,下限 14px
// 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库
const double barH = 40.0;
const double barBottom = bodyBot - padV; // 137
const double barTop = barBottom - barH; // 97
_drawBarcode(canvas, code, leftX, barTop, leftW, barH);
// 左列文字块(品名+编号 一行居中;度数/规格/日期 平铺一行),在条码上方居中
const double infoTop = bodyTop + padV; // 32
const double infoBot = barTop - 4; // 93
const double infoH = infoBot - infoTop; // 61
// 品名自适应字号(为编号留出约 28% 宽度),上限 17
final double nameSize =
_fitFont(name, textMaxW, 1000, true, 26.0).clamp(14.0, 26.0);
_fitFont(name, leftW * 0.72, 1000, true, 17.0).clamp(11.0, 17.0);
const double codeSize = 10.0;
const double subSize = 10.0;
final double nameLineH = nameSize * 1.2;
const double subLineH = subSize * 1.2;
const double interGap = 8.0;
final double blockH = nameLineH + interGap + subLineH;
double blockTop = infoTop + (infoH - blockH) / 2;
if (blockTop < infoTop) blockTop = infoTop;
final codeText = code.isNotEmpty ? '编号:$code' : '';
final rows = <(String, double, bool)>[
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
if (date.isNotEmpty) (date, kSmallSize, false),
];
// 品名 + 编号:整体水平居中,基线对齐
final double nameW = _measureWidth(name, nameSize, true);
final bool hasCode = code.isNotEmpty;
const double codeGap = 9.0;
final double codeW = hasCode ? _measureWidth(code, codeSize, false) : 0;
final double groupW = nameW + (hasCode ? codeGap + codeW : 0);
double gx = leftX + (leftW - groupW) / 2;
if (gx < leftX) gx = leftX;
_drawText(canvas, name, gx, blockTop, nameSize, leftW,
bold: true, color: cInk);
if (hasCode) {
_drawText(canvas, code, gx + nameW + codeGap,
blockTop + (nameSize - codeSize) * 0.82, codeSize, codeW + 4,
color: cCode);
}
// 文字区域:整体略低于二维码顶部,底部延伸至扫码溯源文字下方
const double kTextTop = 16.0; // 比二维码顶部(8)低一些,整体下移
const double kTextBot = 150.0; // 延伸至底部,减少留白
const double kLineH = 1.35; // 行高倍数(字号 × 行高 = 该行占用高度)
final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
final int n = rows.length;
final double rawGap =
n > 0 ? (kTextBot - kTextTop - totalTextH) / (n + 1) : 4.0;
final double gap = rawGap < 1.5 ? 1.5 : rawGap;
double yy = kTextTop + gap;
for (final r in rows) {
_drawText(canvas, r.$1, lx, yy, r.$2, textMaxW, bold: r.$3);
yy += r.$2 * kLineH + gap;
// 三字段平铺一行(space-between,两侧留内边距使外白 > 中白)
final double subY = blockTop + nameLineH + interGap;
const double innerPad = 10.0; // 对齐原型 .lbl-sub padding:0 10px
const double innerLeft = leftX + innerPad; // 18
const double innerRight = leftRight - innerPad; // 187
const double innerW = innerRight - innerLeft; // 169
if (subs.length == 1) {
final double sw0 = _measureWidth(subs[0], subSize, false);
_drawText(
canvas, subs[0], innerLeft + (innerW - sw0) / 2, subY, subSize, innerW,
color: cCode);
} else if (subs.isNotEmpty) {
// 字段间必须留可见最小间隔;总宽超限时按比例缩字号,避免贴死
const double minGap = 5.0;
double fs = subSize;
double sumW = subs.fold(
0.0, (s, x) => s + _measureWidth(x, fs, false));
final double avail = innerW - minGap * (subs.length - 1);
if (sumW > avail && sumW > 0) {
fs = (subSize * avail / sumW).clamp(7.0, subSize);
sumW = subs.fold(0.0, (s, x) => s + _measureWidth(x, fs, false));
}
final double gap = subs.length > 1
? ((innerW - sumW) / (subs.length - 1)).clamp(minGap, innerW)
: 0.0;
double cx = innerLeft;
for (int i = 0; i < subs.length; i++) {
final double wi = _measureWidth(subs[i], fs, false);
_drawText(canvas, subs[i], cx, subY, fs, wi + 4, color: cCode);
cx += wi + gap;
}
}
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
@@ -308,6 +413,16 @@ Future<Uint8List> _encodeGbkAsync(String text) async {
return CharsetConverter.encode('GBK', text);
}
/// 估算 TSPL 内置点阵字体下一行文本的像素宽度(dots):
/// 全角(CJK) 字符宽 = 字高;半角(ASCII) 宽 = 字高/2。用于居中 / 平铺定位。
int _estTsplWidth(String s, int fontH) {
var w = 0;
for (final r in s.runes) {
w += r > 0x7F ? fontH : (fontH ~/ 2);
}
return w;
}
/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。
/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。
Future<void> _addTextCmd(
@@ -328,8 +443,7 @@ Future<bool> _printFlatLabelThermal({
required String shop,
required String name,
required String code,
required String degSpec,
required String date,
required List<String> subs, // 度数 / 规格 / 日期(已过滤空)
required Uint8List qrBytes,
String? printerName,
}) async {
@@ -338,12 +452,18 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
// QR BITMAP: 124×124 dots, 右侧 x=192, y=8
const qrW = 124, qrH = 124;
const qrX = 192, qrY = 8;
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
// 版面(320×160 dotsDIRECTION 1):深蓝头/米色脚在单色热敏上分别退化为
// 「黑底反白店名」「白底黑字页脚」;主体两列:左=文字+条码,右=二维码占整高。
const headerH = 26; // 头部黑带高
const bodyTop = headerH; // 26
const bodyBot = 134; // 主体下沿(页脚之上)
// 右列二维码:占主体整高(正方形),右对齐(放大,占满主体高度)
const qrW = 104, qrH = 104;
const qrX = 320 - 8 - qrW; // 208
const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 28
const qrWBytes = (qrW + 7) >> 3; // 13 bytes/row
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
final codec = await ui.instantiateImageCodec(qrBytes,
targetWidth: qrW, targetHeight: qrH);
final qrImg = (await codec.getNextFrame()).image;
@@ -356,7 +476,19 @@ Future<bool> _printFlatLabelThermal({
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
));
// QR BITMAP 命令
// ── 深蓝店名头(黑底反白):先画黑字店名,再 REVERSE 整条头部 → 白字黑底 ──
await _addTextCmd(buf, 8, 5, 'TSS16.BF2', shop);
buf.add(ascii.encode('REVERSE 0,0,320,$headerH\r\n'));
// ── 米色页脚(白底黑字):店名 + 尺寸 ──
const footerY = 140;
await _addTextCmd(buf, 8, footerY, 'TSS16.BF2', shop);
const sizeStr = '40×20mm';
final sizeW = _estTsplWidth(sizeStr, 16);
await _addTextCmd(
buf, (320 - 8 - sizeW).clamp(8, 312), footerY, 'TSS16.BF2', sizeStr);
// ── 右列二维码 BITMAP ──
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); // 默认白
@@ -372,40 +504,61 @@ Future<bool> _printFlatLabelThermal({
}
buf.add(ascii.encode('\r\n'));
// "扫码溯源" 居中于 QR 正下方
// TSS16 CJK 每字 16 dots; 4字=64; QR 中心 x=192+62=254; 文字起始 x=254-32=222
await _addTextCmd(buf, 222, qrY + qrH + 2, 'TSS16.BF2', '扫码溯源');
// 左列几何(与二维码列间距收小到 6
const leftX = 8,
leftRight = qrX - 6,
leftW = leftRight - leftX; // 8..202, 194
// 左侧文字(x=4, 宽度至 x=186)
// TSS24.BF2: 24×24 点阵; TSS16.BF2: 16×16 点阵
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
const kFontXL = 'TSS24.BF2'; // 大号(商品名,≤7字时使用)
final nameFont = name.runes.length <= 7 ? kFontXL : kFontL;
final nameH = nameFont == kFontXL ? 24 : 16;
final codeText = code.isNotEmpty ? '编号:$code' : '';
// ── 左列底部整幅一维条码(Code128,内容=商品编号),narrow 自适应铺满并居中 ──
const kBarH = 28;
const kBarY = bodyBot - 4 - kBarH; // 102
if (code.isNotEmpty) {
final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验)
final narrow = (leftW / estModules).floor().clamp(2, 4);
final barW = estModules * narrow;
final barX = (leftX + (leftW - barW) ~/ 2).clamp(leftX, leftRight - 10);
buf.add(ascii.encode(
'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n'));
}
// (文本, 字体, 字符高度点数)
final rows = <(String, String, int)>[];
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
if (date.isNotEmpty) rows.add((date, kFontL, 16));
// ── 左列文字块:品名+编号 一行居中;度数/规格/日期 平铺一行;整体在条码上方居中 ──
const blockTop = bodyTop, blockBot = kBarY - 4; // 26..98
final nameFont = name.runes.length <= 7 ? 'TSS24.BF2' : 'TSS16.BF2';
final nameH = nameFont == 'TSS24.BF2' ? 24 : 16;
const subH = 16, kGap = 8;
final blockH = nameH + kGap + subH;
var nameY = blockTop + ((blockBot - blockTop) - blockH) ~/ 2;
if (nameY < blockTop) nameY = blockTop;
final subY = nameY + nameH + kGap;
// 在 y=8..150 区间均匀分布各行
const kTextTop = 8;
const kTextBot = 150;
const kTextX = 4;
final totalH = rows.fold(0, (s, r) => s + r.$3);
final n = rows.length;
final gapSize = n > 0
? ((kTextBot - kTextTop - totalH) / (n + 1)).round().clamp(2, 20)
: 8;
// 品名 + 编号:整体水平居中,编号小字随后,基线对齐
final nameW = _estTsplWidth(name, nameH);
final codeW = code.isNotEmpty ? _estTsplWidth(code, 16) : 0;
final groupW = nameW + (code.isNotEmpty ? kGap + codeW : 0);
var nameX = leftX + (leftW - groupW) ~/ 2;
if (nameX < leftX) nameX = leftX;
await _addTextCmd(buf, nameX, nameY, nameFont, name);
if (code.isNotEmpty) {
await _addTextCmd(
buf, nameX + nameW + kGap, nameY + (nameH - subH), 'TSS16.BF2', code);
}
int yPos = kTextTop + gapSize;
for (final r in rows) {
await _addTextCmd(buf, kTextX, yPos, r.$2, r.$1);
yPos += r.$3 + gapSize;
// 三字段平铺一行(space-between,两侧留内边距使外白 > 中白)
const innerPad = 10; // 对齐原型 .lbl-sub padding:0 10px
const innerLeft = leftX + innerPad, innerW = leftW - 2 * innerPad; // 18, 174
if (subs.length == 1) {
final w0 = _estTsplWidth(subs[0], subH);
await _addTextCmd(
buf, innerLeft + (innerW - w0) ~/ 2, subY, 'TSS16.BF2', subs[0]);
} else if (subs.isNotEmpty) {
final widths = subs.map((s) => _estTsplWidth(s, subH)).toList();
final sumW = widths.fold(0, (s, x) => s + x);
final gap = (innerW - sumW) / (subs.length - 1);
var cx = innerLeft.toDouble();
for (int i = 0; i < subs.length; i++) {
await _addTextCmd(buf, cx.round(), subY, 'TSS16.BF2', subs[i]);
cx += widths[i] + gap;
}
}
buf.add(ascii.encode('PRINT 1,1\r\n'));
@@ -441,40 +594,23 @@ Future<void> printProductLabelImpl({
const headerH = labelH * 0.20;
const footerH = labelH * 0.11;
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal =
[seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10
? productionDate.substring(0, 10)
: productionDate)
: '';
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致)
final subs = _labelSubFields(series, spec, productionDate);
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
code: code,
degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
subs: subs,
qrBytes: qrBytes,
printerName: printerName)) {
return;
}
final contact = [
if (shopAddress.isNotEmpty) shopAddress,
if (shopPhone.isNotEmpty) shopPhone,
].join(' · ');
final footerLeft = contact.isNotEmpty ? contact : shopName;
// 标签生成时间
final now = DateTime.now();
final genTime =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'
' ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
// 商品名字号:随字数自适应(与预览一致,短名更大)
final double nameFs =
name.runes.length > 10 ? 6.5 : (name.runes.length > 7 ? 7.5 : 9.0);
doc.addPage(pw.Page(
pageFormat: const PdfPageFormat(labelW, labelH),
@@ -482,11 +618,11 @@ Future<void> printProductLabelImpl({
build: (_) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// ── Header酒行名称 ─────────────────────────────────────────────────
// ── Header深蓝店名头 ────────────────────────────────────────────────
pw.Container(
height: headerH,
color: _navy,
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
alignment: pw.Alignment.centerLeft,
child: pw.Text(shopName,
maxLines: 1,
@@ -500,93 +636,117 @@ Future<void> printProductLabelImpl({
)),
),
// ── Body ─────────────────────────────────────────────────────────────
// ── Body:左列(文字+条码) / 右列(二维码占整高) ─────────────────────────
pw.Expanded(
child: pw.Container(
color: PdfColors.white,
padding: const pw.EdgeInsets.fromLTRB(4, 2, 4, 2),
padding: const pw.EdgeInsets.fromLTRB(4, 3, 4, 3),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 左列
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 商品名(过长自动缩小并最多两行)
pw.Text(name,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font,
fontSize: name.runes.length > 10
? 5.5
: (name.runes.length > 7 ? 6.5 : 7.5),
fontWeight: pw.FontWeight.bold,
color: _ink,
letterSpacing: 0.3)),
pw.SizedBox(height: 2.5),
// 商品编号(度数上方)
// 文字块(品名+编号 居中 / 三字段平铺),条码上方居中
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 品名 + 编号(整体居中,编号小字随后)
pw.RichText(
textAlign: pw.TextAlign.center,
maxLines: 1,
overflow: pw.TextOverflow.clip,
text: pw.TextSpan(children: [
pw.TextSpan(
text: name,
style: pw.TextStyle(
font: font,
fontSize: nameFs,
fontWeight: pw.FontWeight.bold,
color: _ink,
letterSpacing: 0.3)),
if (code.isNotEmpty)
pw.TextSpan(
text: ' $code',
style: pw.TextStyle(
font: font,
fontSize: 5,
color: _footnote)),
]),
),
pw.SizedBox(height: 4),
// 三字段平铺一行(两侧留边距,外白 > 中白)
if (subs.isNotEmpty)
pw.Padding(
padding: const pw.EdgeInsets.symmetric(
horizontal: 4),
child: pw.Row(
mainAxisAlignment: subs.length == 1
? pw.MainAxisAlignment.center
: pw.MainAxisAlignment.spaceBetween,
children: [
for (final s in subs)
pw.Text(s,
style: pw.TextStyle(
font: font,
fontSize: 5,
color: _footnote)),
],
),
),
],
),
),
// 左列底部整幅一维条码(Code128,内容=商品编号),供扫码枪
if (code.isNotEmpty) ...[
pw.Text('编号:$code',
style: pw.TextStyle(
font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
pw.SizedBox(height: 3),
pw.SizedBox(
height: 13,
child: pw.BarcodeWidget(
barcode: pw.Barcode.code128(),
data: code,
drawText: false,
color: _ink,
),
),
],
// 度数(系列) + 规格 同一行, 无字段名标签
pw.Text(degSpecVal,
style: pw.TextStyle(
font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
// 生产日期(只显示日期,无标签)
pw.Text(dateVal,
style: pw.TextStyle(
font: font, fontSize: 5.5, color: _ink)),
],
),
),
pw.SizedBox(width: 4),
// QR
pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.SizedBox(
width: 38,
height: 38,
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
),
pw.SizedBox(height: 1),
pw.Text('扫码溯源',
style: pw.TextStyle(
font: font,
fontSize: 3.5,
color: PdfColors.grey600)),
],
// 右列:二维码占主体整高(正方形)
pw.AspectRatio(
aspectRatio: 1,
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
),
],
),
),
),
// ── Footer ───────────────────────────────────────────────────────────
// ── Footer:米色页脚(店名 + 尺寸)────────────────────────────────────
pw.Container(
height: footerH,
color: _cream,
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(footerLeft,
child: pw.Text(shopName,
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font, fontSize: 3, color: _footnote)),
font: font, fontSize: 3.5, color: _footnote)),
),
pw.Text(genTime,
pw.Text('40×20mm',
style: pw.TextStyle(
font: font, fontSize: 3, color: _footnote)),
font: font, fontSize: 3.5, color: _footnote)),
],
),
),
@@ -613,21 +773,11 @@ Future<void> printProductLabelImpl({
/// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。
/// Web 平台或 qrBytes 为空时抛异常。
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
final specVal = (label.spec ?? '').isNotEmpty ? label.spec! : '';
final seriesVal = (label.series ?? '').isNotEmpty ? label.series! : '';
final degSpecVal =
[seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (label.productionDate ?? '').isNotEmpty
? (label.productionDate!.length > 10
? label.productionDate!.substring(0, 10)
: label.productionDate!)
: '';
final img = await _renderLabelBitmap(
shop: label.shopName,
name: label.name,
code: label.code,
degSpec: degSpecVal,
date: dateVal,
subs: _labelSubFields(label.series, label.spec, label.productionDate),
qrBytes: label.qrBytes!,
);
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
+56 -62
View File
@@ -1,6 +1,7 @@
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';
@@ -35,21 +36,27 @@ Future<void> printProductLabelImpl({
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10
? productionDate.substring(0, 10)
: productionDate)
: '';
// 商品名过长时缩小字号(适当缩小,配合两行折行
: '';
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、PDF、TSPL 一致
final subs = <String>[
if (seriesVal.isNotEmpty) seriesVal,
if (specVal.isNotEmpty) specVal,
if (dateVal.isNotEmpty) dateVal,
];
final subSpans = subs.map((s) => '<span>$s</span>').join();
// 商品名过长时缩小字号
final nameLen = name.runes.length;
final nameFontPt = nameLen > 11 ? 5.0 : (nameLen > 8 ? 5.8 : 6.5);
final nameFontPt = nameLen > 10 ? 7.0 : (nameLen > 7 ? 8.0 : 9.0);
final contactLine = [
if (shopPhone.isNotEmpty) shopPhone,
if (shopAddress.isNotEmpty) shopAddress,
].join(' ');
// 一维条码(Code128,内容=商品编号):矢量 SVG 内联,扫码枪扫码出库。
final barcodeSvg = code.isNotEmpty
? bc.Barcode.code128()
.toSvg(code, width: 200, height: 46, drawText: false)
: '';
final html = '''<!DOCTYPE html>
<html>
@@ -58,76 +65,64 @@ Future<void> printProductLabelImpl({
<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>
/* 标签尺寸:38mm × 20mm,横向 */
@page { size: 38mm 20mm; margin: 0; }
/* 标签尺寸:40mm × 20mm,横向(对齐设计原型 devices.html 标签块)*/
@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: 38mm; height: 20mm; overflow: hidden; background: #fff; }
body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
.label {
width: 38mm; height: 20mm;
width: 40mm; height: 20mm;
display: flex; flex-direction: column;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #111;
}
/* ── Header酒行名称 3.5mm ── */
/* ── Header深蓝店名头 3.2mm ── */
.hdr {
background: #1f2a3a; color: #f4ecd8;
height: 3.5mm; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 0 1.5mm; overflow: hidden;
height: 3.2mm; flex-shrink: 0;
display: flex; align-items: center;
padding: 0 1.2mm; overflow: hidden;
}
.hdr-name {
font-size: 5.5pt; font-weight: 700; letter-spacing: 0.08em;
font-size: 6pt; font-weight: 700; letter-spacing: 0.05em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── 中部:左侧商品信息 + 右侧二维码 ── */
/* ── 主体:左列(文字+条码) / 右列(二维码占整高) ── */
.middle {
flex: 1; display: flex; flex-direction: row; overflow: hidden;
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; display: flex; flex-direction: column;
justify-content: center; padding: 0.8mm 1mm 0.5mm 1.5mm;
overflow: hidden;
flex: 1; min-height: 0; display: flex; flex-direction: column;
justify-content: center; gap: 1mm; overflow: hidden;
}
.product-name {
font-size: 6.5pt; font-weight: 700; line-height: 1.15;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden; word-break: break-all;
margin-bottom: 0.6mm;
.pname {
font-weight: 700; text-align: center; line-height: 1.1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.specs {
display: flex; flex-direction: column; gap: 0.5mm;
.pcode { font-size: 5pt; font-weight: 400; color: #333; margin-left: 1.2mm; }
.sub {
font-size: 5pt; color: #333; display: flex;
justify-content: space-between; gap: 1mm; padding: 0 1.2mm;
}
.spec-row {
display: flex; gap: 0.8mm; align-items: baseline;
font-size: 4pt; white-space: nowrap; overflow: hidden;
}
.lbl { color: #999; flex-shrink: 0; }
.val { font-family: "IBM Plex Mono", monospace;
overflow: hidden; text-overflow: ellipsis; }
.sub span { white-space: nowrap; }
.bc { flex-shrink: 0; height: 4.6mm; }
.bc svg { width: 100%; height: 100%; display: block; }
/* 右:二维码 */
.qr-wrap {
width: 16mm; flex-shrink: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 0.3mm;
padding: 0.3mm 0.3mm 0.3mm 0;
}
.qr-wrap img { width: 14.5mm; height: 14.5mm; object-fit: contain; }
.qr-cap { font-size: 3pt; color: #aaa; letter-spacing: 0.1em; }
/* 右:二维码占主体整高(正方形)*/
.qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; }
.qr img { width: 100%; height: 100%; object-fit: contain; display: block; }
/* ── Footer联系方式 3mm ── */
/* ── Footer米色页脚(店名 + 尺寸)── */
.ftr {
background: #f4ecd8; height: 3mm; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 0 1.5mm;
font-size: 3.5pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
text-overflow: ellipsis;
background: #f4ecd8; height: 2.4mm; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 0 1.2mm;
font-size: 3.4pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
}
</style>
</head>
@@ -139,20 +134,19 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
</div>
<div class="middle">
<div class="info">
<div class="product-name" style="font-size:${nameFontPt}pt">$name</div>
<div class="specs">
<div class="spec-row"><span class="val">$degSpecVal</span></div>
<div class="spec-row"><span class="val">$dateVal</span></div>
<div class="left">
<div class="info">
<div class="pname" style="font-size:${nameFontPt}pt">$name<span class="pcode">$code</span></div>
<div class="sub">$subSpans</div>
</div>
<div class="bc">$barcodeSvg</div>
</div>
<div class="qr-wrap">
<div class="qr">
<img src="data:image/png;base64,$base64Img" />
<div class="qr-cap">扫码溯源</div>
</div>
</div>
<div class="ftr">${contactLine.isNotEmpty ? contactLine : shopName}</div>
<div class="ftr"><span>$shopName</span><span>40×20mm</span></div>
</div>
<script>