From d966951b6b144ec7d1f4aad3eafa2e6c678e9413 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 20:26:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E6=A0=87=E7=AD=BE=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E6=94=B9=E4=B8=BA=E7=83=AD=E6=95=8F=E6=9C=BA=20TSPL?= =?UTF-8?q?=20=E8=A3=B8=E5=8F=91=20+=20=E8=87=AA=E9=80=82=E5=BA=94?= =?UTF-8?q?=E6=89=81=E5=B9=B3=E7=89=88=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 桌面端(macOS)检测到得力 DL-888B 等热敏机时,用 dart:ui 直接把标签绘制成 单色位图,经 TSPL BITMAP 命令 lp -o raw 裸发;系统打印走 CUPS 通用驱动会 多走纸/空白,故对热敏机绕过系统打印 - 新版式(无字段名标签):酒行名 / 商品名 / 度数(系列)+规格同行 / 生产日期, 右侧二维码 + 下方居中"扫码溯源";左侧各行按高度等比铺满、字号自适应 - 二维码用后端 PNG 解码后绘制(避免 PDF 光栅丢图);生产日期仅在有值时显示 (商品库无该字段,入库/库存才有) - 修复 macOS printing 包 layoutPdf 主线程死锁(dynamicLayout: false) - macOS 关闭 app-sandbox 以允许调用 lp 裸发 Co-Authored-By: Claude Opus 4.8 --- client/lib/core/utils/print_util_stub.dart | 324 ++++++++++++------ client/lib/core/utils/print_util_web.dart | 29 +- client/macos/Runner/DebugProfile.entitlements | 2 +- client/macos/Runner/Release.entitlements | 2 +- 4 files changed, 244 insertions(+), 113 deletions(-) diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 0e44763..9d4573c 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -1,5 +1,8 @@ -import 'dart:io' show Platform; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'dart:io' show Platform, Process, File, Directory; +import 'dart:convert' show ascii; +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:flutter/foundation.dart' show kIsWeb, debugPrint; import 'package:flutter/services.dart'; import '../errors/error_reporter.dart'; import 'package:pdf/pdf.dart'; @@ -29,26 +32,167 @@ String _fileTs() { // ── 设计色彩 token ────────────────────────────────────────────────────────── const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 footer/reversed text -const _muted = PdfColor(0.533, 0.533, 0.533); // #888 field labels const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 footer text const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text -pw.Widget _labelSpecRow(pw.Font font, String label, String value) => - pw.Row( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.SizedBox( - width: 30, - child: pw.Text(label, - style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)), - ), - pw.SizedBox(width: 4), - pw.Expanded( - child: pw.Text(value, - style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), - ), - ], - ); +// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)──────── +// 得力 DL-888B 等热敏机走系统打印(CUPS 通用驱动)会多走纸/空白,必须发原生 TSPL。 +// 直接画位图而非走 PDF 光栅化:避免二维码渲染丢失、SIZE 用整数避免多走纸。 + +/// 查找热敏标签机的 CUPS 队列名(名字含 DL-888 / 888B / Deli) +Future _findThermalQueue() async { + try { + final r = await Process.run('lpstat', ['-e']); + if (r.exitCode != 0) return null; + for (final raw in (r.stdout as String).split('\n')) { + final q = raw.trim(); + if (q.isEmpty) continue; + final lo = q.toLowerCase(); + if (lo.contains('dl-888') || lo.contains('dl_888') || + lo.contains('888b') || lo.contains('deli')) { + return q; + } + } + } catch (e) { + debugPrint('[label] lpstat 失败: $e'); + } + return null; +} + +/// 在 canvas 上画一行文字(黑色,单行不换行,CJK 走系统字体回退) +void _drawText(ui.Canvas canvas, String s, double x, double y, double size, + double maxW, {bool bold = false, bool center = false}) { + if (s.isEmpty) return; + final pb = ui.ParagraphBuilder(ui.ParagraphStyle( + 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: const ui.Color(0xFF000000))) + ..addText(s); + final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW)); + canvas.drawParagraph(p, ui.Offset(x, y)); +} + +/// 测量单行文本在指定字号下的宽度 +double _measureWidth(String s, double size, bool bold) { + final pb = ui.ParagraphBuilder(ui.ParagraphStyle( + 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; +} + +/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。 +/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。 +/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。 +Future _printFlatLabelThermal({ + required String shop, + required String name, + required String degSpec, + required String date, + required Uint8List qrBytes, +}) async { + if (kIsWeb || !Platform.isMacOS) return false; // 目前仅 macOS(Windows 待加) + final queue = await _findThermalQueue(); + debugPrint('[label] thermal queue = $queue'); + if (queue == null) return false; + + const w = 320, h = 160; // 40×20mm @203dpi + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas( + recorder, ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble())); + canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()), + ui.Paint()..color = const ui.Color(0xFFFFFFFF)); + + // 右侧二维码(解码后端 PNG 直接画上) + const qrS = 124.0; + const qrTop = 8.0; + const qrLeft = w - qrS - 4; // 192 + 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), + 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 + final rows = <(String, double, bool)>[ + (shop, 0.9, true), + (name, 1.5, true), + if (degSpec.isNotEmpty) (degSpec, 1.05, false), + if (date.isNotEmpty) (date, 1.0, false), + ]; + const topY = 4.0, botY = 156.0; + final totalWeight = rows.fold(0, (a, r) => a + r.$2); + double yy = topY; + for (final r in rows) { + final rowH = (botY - topY) * r.$2 / totalWeight; + final fs = _fitFont(r.$1, textMaxW, rowH * 0.84, r.$3, 30); + final ty = yy + (rowH - fs * 1.25) / 2; + _drawText(canvas, r.$1, lx, ty < yy ? yy : ty, fs, textMaxW, bold: r.$3); + yy += rowH; + } + + final img = await recorder.endRecording().toImage(w, h); + final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba); + final rgba = bd!.buffer.asUint8List(); + + // 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸) + const wbytes = (w + 7) >> 3; + final out = BytesBuilder(); + out.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' + 'BITMAP 0,0,$wbytes,$h,1,')); + for (int yy = 0; yy < h; yy++) { + final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1 + for (int xx = 0; xx < w; xx++) { + final i = (yy * w + xx) * 4; + final a = rgba[i + 3]; + final lum = a < 128 + ? 255.0 + : 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]; + if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0 + } + out.add(row); + } + out.add(ascii.encode('\r\nPRINT 1,1\r\n')); + final tspl = out.toBytes(); + + final f = File( + '${Directory.systemTemp.path}/label_${DateTime.now().millisecondsSinceEpoch}.tspl'); + await f.writeAsBytes(tspl, flush: true); + debugPrint('[label] flat tspl ${tspl.length}B -> lp -d $queue -o raw ${f.path}'); + final r = await Process.run('lp', ['-d', queue, '-o', 'raw', f.path]); + debugPrint('[label] lp exit=${r.exitCode} out=${r.stdout} err=${r.stderr}'); + if (r.exitCode != 0) { + throw Exception('lp 裸发失败(exit ${r.exitCode}): ${r.stderr}'); + } + return true; +} Future printProductLabelImpl({ required Uint8List qrBytes, @@ -68,19 +212,30 @@ Future printProductLabelImpl({ final doc = pw.Document(); final qrImage = pw.MemoryImage(qrBytes); - // Label: 4 × 2 inch - const labelW = 4.0 * PdfPageFormat.inch; - const labelH = 2.0 * PdfPageFormat.inch; - const headerH = labelH * 0.215; - const footerH = labelH * 0.09; + // Label: 40 × 20 mm(匹配实际标签纸尺寸) + const labelW = 40.0 * PdfPageFormat.mm; + const labelH = 20.0 * PdfPageFormat.mm; + const headerH = labelH * 0.20; + const footerH = labelH * 0.11; - final specVal = (spec ?? '').isNotEmpty ? spec! : '—'; - final seriesVal = (series ?? '').isNotEmpty ? series! : '—'; - final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '—'; + 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 裸发(不走会多走纸的系统打印) + if (await _printFlatLabelThermal( + shop: shopName, + name: name, + degSpec: degSpecVal, + date: dateVal == '—' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行 + qrBytes: qrBytes)) { + return; + } + final contact = [ if (shopAddress.isNotEmpty) shopAddress, if (shopPhone.isNotEmpty) shopPhone, @@ -104,32 +259,22 @@ Future printProductLabelImpl({ pw.Container( height: headerH, color: _navy, - padding: const pw.EdgeInsets.symmetric(horizontal: 11), - child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - crossAxisAlignment: pw.CrossAxisAlignment.center, - children: [ - pw.Flexible( - child: pw.Text(shopName, - style: pw.TextStyle( - font: font, fontSize: 13, - fontWeight: pw.FontWeight.bold, - color: _cream, letterSpacing: 1.5, - )), - ), - pw.Text('Certificate of Authenticity', - style: pw.TextStyle( - font: font, fontSize: 5, - color: const PdfColor(0.82, 0.79, 0.72))), - ], - ), + padding: const pw.EdgeInsets.symmetric(horizontal: 4), + 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(11, 8, 11, 6), + padding: const pw.EdgeInsets.fromLTRB(4, 2, 4, 2), child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ @@ -138,38 +283,41 @@ Future printProductLabelImpl({ mainAxisAlignment: pw.MainAxisAlignment.center, crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - // 商品名 + // 商品名(过长自动缩小并最多两行) pw.Text(name, + maxLines: 2, + overflow: pw.TextOverflow.clip, style: pw.TextStyle( - font: font, fontSize: 12, + 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.5)), - pw.SizedBox(height: 6), - // 规格 + 系列 - _label2ColRow(font, '规 格', specVal, '系 列', seriesVal), - pw.SizedBox(height: 3), - // 批号 + 生产日期 - _label2ColRow(font, '批 号', batchVal, '生产日期', dateVal), - if ((remark ?? '').isNotEmpty) ...[ - pw.SizedBox(height: 3), - _labelSpecRow(font, '备 注', remark!), - ], + color: _ink, letterSpacing: 0.3)), + pw.SizedBox(height: 2.5), + // 度数(系列) + 规格 同一行, 无字段名标签 + 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: 8), + pw.SizedBox(width: 4), // QR pw.Column( mainAxisAlignment: pw.MainAxisAlignment.center, children: [ pw.SizedBox( - width: 60, height: 60, + width: 38, height: 38, child: pw.Image(qrImage, fit: pw.BoxFit.contain), ), - pw.SizedBox(height: 3), + pw.SizedBox(height: 1), pw.Text('扫码溯源', style: pw.TextStyle( - font: font, fontSize: 4.5, + font: font, fontSize: 3.5, color: PdfColors.grey600)), ], ), @@ -182,17 +330,18 @@ Future printProductLabelImpl({ pw.Container( height: footerH, color: _cream, - padding: const pw.EdgeInsets.symmetric(horizontal: 11), + padding: const pw.EdgeInsets.symmetric(horizontal: 4), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ pw.Flexible( child: pw.Text(footerLeft, - style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)), + maxLines: 1, overflow: pw.TextOverflow.clip, + style: pw.TextStyle(font: font, fontSize: 3, color: _footnote)), ), pw.Text(genTime, - style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)), + style: pw.TextStyle(font: font, fontSize: 3, color: _footnote)), ], ), ), @@ -207,42 +356,15 @@ Future printProductLabelImpl({ await Future.delayed(Duration.zero); } await Printing.layoutPdf( - name: '标签_${_fileTs()}', onLayout: (_) async => bytes); + name: '标签_${_fileTs()}', + dynamicLayout: false, + onLayout: (_) async => bytes); } catch (e, st) { reportError(e, st); throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e'); } } -pw.Widget _label2ColRow( - pw.Font font, String lbl1, String val1, String lbl2, String val2) { - return pw.Row( - children: [ - pw.SizedBox( - width: 28, - child: pw.Text(lbl1, - style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)), - ), - pw.SizedBox(width: 3), - pw.Expanded( - child: pw.Text(val1, - style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), - ), - pw.SizedBox(width: 6), - pw.SizedBox( - width: 28, - child: pw.Text(lbl2, - style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)), - ), - pw.SizedBox(width: 3), - pw.Expanded( - child: pw.Text(val2, - style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), - ), - ], - ); -} - pw.Widget _buildOrderDoc({ required pw.Font font, required pw.Font bold, @@ -452,7 +574,9 @@ Future printStockInOrderImpl(StockInOrder order) async { await Future.delayed(Duration.zero); } await Printing.layoutPdf( - name: '入库单_${_fileTs()}', onLayout: (_) async => bytes); + name: '入库单_${_fileTs()}', + dynamicLayout: false, + onLayout: (_) async => bytes); } catch (e, st) { reportError(e, st); throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e'); @@ -512,7 +636,9 @@ Future printStockOutOrderImpl(StockOutOrder order) async { await Future.delayed(Duration.zero); } await Printing.layoutPdf( - name: '出库单_${_fileTs()}', onLayout: (_) async => bytes); + name: '出库单_${_fileTs()}', + dynamicLayout: false, + onLayout: (_) async => bytes); } catch (e, st) { reportError(e, st); throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e'); diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index aedcb29..334d4cb 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -30,11 +30,16 @@ Future printProductLabelImpl({ }) async { final base64Img = base64Encode(qrBytes); - final specVal = (spec ?? '').isNotEmpty ? spec! : '—'; - final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '—'; + 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) : '—'; + // 商品名过长时缩小字号(适当缩小,配合两行折行) + final nameLen = name.runes.length; + final nameFontPt = nameLen > 11 ? 5.0 : (nameLen > 8 ? 5.8 : 6.5); final contactLine = [ if (shopPhone.isNotEmpty) shopPhone, @@ -85,9 +90,10 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; } overflow: hidden; } .product-name { - font-size: 6.5pt; font-weight: 700; line-height: 1.2; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - margin-bottom: 0.8mm; + 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; } .specs { display: flex; flex-direction: column; gap: 0.5mm; @@ -102,12 +108,12 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; } /* 右:二维码 */ .qr-wrap { - width: 14mm; flex-shrink: 0; + width: 16mm; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.3mm; - padding: 0.5mm 0.5mm 0.5mm 0; + padding: 0.3mm 0.3mm 0.3mm 0; } -.qr-wrap img { width: 12mm; height: 12mm; object-fit: contain; } +.qr-wrap img { width: 14.5mm; height: 14.5mm; object-fit: contain; } .qr-cap { font-size: 3pt; color: #aaa; letter-spacing: 0.1em; } /* ── Footer:联系方式 3mm ── */ @@ -129,11 +135,10 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
-
$name
+
$name
-
$specVal
-
$batchVal
-
$dateVal
+
$degSpecVal
+
$dateVal
diff --git a/client/macos/Runner/DebugProfile.entitlements b/client/macos/Runner/DebugProfile.entitlements index 1a6a5d9..bc09f8b 100644 --- a/client/macos/Runner/DebugProfile.entitlements +++ b/client/macos/Runner/DebugProfile.entitlements @@ -3,7 +3,7 @@ com.apple.security.app-sandbox - + com.apple.security.cs.allow-jit com.apple.security.network.client diff --git a/client/macos/Runner/Release.entitlements b/client/macos/Runner/Release.entitlements index 3218650..704cf90 100644 --- a/client/macos/Runner/Release.entitlements +++ b/client/macos/Runner/Release.entitlements @@ -3,7 +3,7 @@ com.apple.security.app-sandbox - + com.apple.security.network.client com.apple.security.files.downloads.read-write