feat(client): 标签打印改为热敏机 TSPL 裸发 + 自适应扁平版式
- 桌面端(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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String?> _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<bool> _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<double>(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<void> printProductLabelImpl({
|
||||
required Uint8List qrBytes,
|
||||
@@ -68,19 +212,30 @@ Future<void> 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<void> 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<void> 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<void> 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<void> printProductLabelImpl({
|
||||
await Future<void>.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<void> printStockInOrderImpl(StockInOrder order) async {
|
||||
await Future<void>.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<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
await Future<void>.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');
|
||||
|
||||
@@ -30,11 +30,16 @@ Future<void> 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; }
|
||||
|
||||
<div class="middle">
|
||||
<div class="info">
|
||||
<div class="product-name">$name</div>
|
||||
<div class="product-name" style="font-size:${nameFontPt}pt">$name</div>
|
||||
<div class="specs">
|
||||
<div class="spec-row"><span class="lbl">规</span><span class="val">$specVal</span></div>
|
||||
<div class="spec-row"><span class="lbl">批</span><span class="val">$batchVal</span></div>
|
||||
<div class="spec-row"><span class="lbl">产</span><span class="val">$dateVal</span></div>
|
||||
<div class="spec-row"><span class="val">$degSpecVal</span></div>
|
||||
<div class="spec-row"><span class="val">$dateVal</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qr-wrap">
|
||||
|
||||
Reference in New Issue
Block a user