From c3a127485f22fd073c63015a81edbbe31a7eefb2 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 27 Aug 2026 17:52:03 +0800 Subject: [PATCH] chore: release client-v1.1.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 价签标签两栏布局改版 + 生产日期修复 + 模板预览接真实渲染 + Windows/macOS 应用内更新进度条修复。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5 --- CHANGELOG-client.md | 10 + client/lib/core/update/app_updater_io.dart | 15 +- client/lib/core/utils/print_util_stub.dart | 496 ++++++++++++------ client/lib/core/utils/print_util_web.dart | 118 ++--- .../devices/device_management_screen.dart | 43 +- .../products/product_detail_screen.dart | 1 + client/lib/widgets/product_editor_drawer.dart | 2 + client/pubspec.lock | 2 +- client/pubspec.yaml | 1 + design/CONTRACT.md | 2 +- design/prototype/screens/devices.html | 48 +- 11 files changed, 492 insertions(+), 246 deletions(-) diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index 36653a2..36f914a 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,16 @@ 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.12] - 2026-08-27 + +### 改进 +- 商品价签(标签)改版为两栏布局:左侧品名 / 编号 / 度数规格 / 生产日期 / 一维条码,右侧二维码占满整个高度,扫码更清晰;字段间距与二维码大小均已优化。 +- 设置 →「打印模板 · 商品价签」的「预览」现可直接查看真实标签样式(此前为占位提示)。 + +### 修复 +- 修复从「商品详情」和「基础数据」页打印标签时,生产日期不显示的问题(从入库 / 出库单打印此前已正常)。 +- 修复 Windows / macOS 版应用内更新时,下载进度条一直卡在 0% 的问题;现在能正常显示下载进度。 + ## [1.1.11] - 2026-08-26 ### 修复 diff --git a/client/lib/core/update/app_updater_io.dart b/client/lib/core/update/app_updater_io.dart index e882b27..6258059 100644 --- a/client/lib/core/update/app_updater_io.dart +++ b/client/lib/core/update/app_updater_io.dart @@ -26,13 +26,22 @@ Future downloadAndInstall( String url, void Function(double) onProgress) async { final tmp = await getTemporaryDirectory(); final dio = Dio(); + + // 关键:显式拒绝 gzip(Accept-Encoding: identity)。 + // nginx 会对 application/octet-stream(.exe) 做即时 gzip,压缩响应走 chunked 传输、 + // 不带 Content-Length,导致 onReceiveProgress 的 total 恒为 -1、进度卡在 0 + //(macOS 的 .zip 属 application/zip 不被 gzip,故一直正常)。 + // identity 让服务端回传原始字节 + Content-Length,total 才有效。 + final dlOpts = + Options(headers: {HttpHeaders.acceptEncodingHeader: 'identity'}); + void prog(int r, int t) { - if (t > 0) onProgress(r / t); + if (t > 0) onProgress((r / t).clamp(0.0, 1.0)); } if (Platform.isWindows) { final exe = '${tmp.path}\\jiu-update-setup.exe'; - await dio.download(url, exe, onReceiveProgress: prog); + await dio.download(url, exe, onReceiveProgress: prog, options: dlOpts); // 启动 Inno Setup 安装包后退出本应用,让其覆盖安装。 await Process.start(exe, const [], mode: ProcessStartMode.detached); await Future.delayed(AppConstants.updaterStepDelay); @@ -41,7 +50,7 @@ Future downloadAndInstall( if (Platform.isMacOS) { final zip = '${tmp.path}/jiu-update.zip'; - await dio.download(url, zip, onReceiveProgress: prog); + await dio.download(url, zip, onReceiveProgress: prog, options: dlOpts); final extractDir = '${tmp.path}/jiu-update'; await Process.run('/bin/rm', ['-rf', extractDir]); diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 301af18..5d37a38 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -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 _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 _renderLabelBitmap({ required String shop, required String name, required String code, - required String degSpec, - required String date, + required List subs, // 底部平铺字段:度数 / 规格 / 日期(已过滤空) required Uint8List qrBytes, }) async { const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸) @@ -214,61 +248,132 @@ Future _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 _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 _addTextCmd( @@ -328,8 +443,7 @@ Future _printFlatLabelThermal({ required String shop, required String name, required String code, - required String degSpec, - required String date, + required List subs, // 度数 / 规格 / 日期(已过滤空) required Uint8List qrBytes, String? printerName, }) async { @@ -338,12 +452,18 @@ Future _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 dots,DIRECTION 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 _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 _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 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 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 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 printProductLabelImpl({ /// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。 /// Web 平台或 qrBytes 为空时抛异常。 Future 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); diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index b7f23c5..77bc1ca 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -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 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 = [ + if (seriesVal.isNotEmpty) seriesVal, + if (specVal.isNotEmpty) specVal, + if (dateVal.isNotEmpty) dateVal, + ]; + final subSpans = subs.map((s) => '$s').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 = ''' @@ -58,76 +65,64 @@ Future printProductLabelImpl({ @@ -139,20 +134,19 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
-
-
$name
-
-
$degSpecVal
-
$dateVal
+
+
+
$name$code
+
$subSpans
+
$barcodeSvg
-
+
-
扫码溯源
-
${contactLine.isNotEmpty ? contactLine : shopName}
+
$shopName40×20mm
@@ -206,6 +249,7 @@ function openAdd(){ document.getElementById('addKind').textContent='标签打印 function pickKind(e){ openMenu(e.currentTarget, KINDS.map(v=>({v,label:v,sel:document.getElementById('addKind').textContent===v})), v=>{ document.getElementById('addKind').textContent=v; closeMenus(); }); } function pickConn(e){ openMenu(e.currentTarget, CONNS.map(v=>({v,label:v,sel:document.getElementById('addConn').textContent===v})), v=>{ document.getElementById('addConn').textContent=v; closeMenus(); }); } function closeOverlay(id){ document.getElementById(id).classList.remove('show'); } +function openLabelPreview(){ document.getElementById('ovLabel').classList.add('show'); }