feat(client): 标签打印预览弹窗(打印机选择 + 份数 + 所见即所得预览)
- 新增 LabelData 模型统一聚合单张标签字段(qrBytes/copies 等) - 抽取 _renderLabelBitmap 共享位图绘制逻辑,TSPL 裸发与预览渲染完全一致 - 新增 renderLabelPreview / listLabelPrinters / detectDefaultPrinter 公开 API - printProductLabel 新增 printerName 参数,透传到热敏裸发路径 - 新建 LabelPreviewDialog:缩略图条+大图主视图,打印机下拉+记忆(SharedPrefs) 份数调整、勾选跳过、逐张打印进度状态显示 - 库存/商品详情/入库单 打标签 均接入预览弹窗,移除旧 _LabelPrintDialog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 单张标签的全部打印字段,预览和打印共用同一份数据。
|
||||
class LabelData {
|
||||
/// 商品 ID,多张懒加载 QR 时用于拉取二维码字节。
|
||||
final int? productId;
|
||||
|
||||
/// 二维码字节(PNG);单张场景调用方提前拉好;多张场景由 qrFetcher 懒加载填入。
|
||||
Uint8List? qrBytes;
|
||||
|
||||
final String name;
|
||||
final String code;
|
||||
final String? spec;
|
||||
final String? series;
|
||||
final String? batchNo;
|
||||
final String? productionDate;
|
||||
final String? remark;
|
||||
final String shopName;
|
||||
final String shopAddress;
|
||||
final String shopPhone;
|
||||
|
||||
/// 打印份数,默认 1,可在预览弹窗里调整。
|
||||
int copies;
|
||||
|
||||
LabelData({
|
||||
this.productId,
|
||||
this.qrBytes,
|
||||
required this.name,
|
||||
required this.code,
|
||||
this.spec,
|
||||
this.series,
|
||||
this.batchNo,
|
||||
this.productionDate,
|
||||
this.remark,
|
||||
this.shopName = '',
|
||||
this.shopAddress = '',
|
||||
this.shopPhone = '',
|
||||
this.copies = 1,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import 'label_data.dart';
|
||||
export 'label_data.dart';
|
||||
import 'print_util_stub.dart'
|
||||
if (dart.library.js_interop) 'print_util_web.dart';
|
||||
|
||||
@@ -18,6 +20,7 @@ Future<void> printProductLabel({
|
||||
String shopName = '',
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
String? printerName,
|
||||
}) =>
|
||||
printProductLabelImpl(
|
||||
qrBytes: qrBytes,
|
||||
@@ -31,8 +34,19 @@ Future<void> printProductLabel({
|
||||
shopName: shopName,
|
||||
shopAddress: shopAddress,
|
||||
shopPhone: shopPhone,
|
||||
printerName: printerName,
|
||||
);
|
||||
|
||||
/// 渲染标签预览图(PNG 字节);Web 平台返回 null。
|
||||
Future<Uint8List?> renderLabelPreview(LabelData label) =>
|
||||
renderLabelPreviewImpl(label);
|
||||
|
||||
/// 枚举当前系统所有可用打印机名;Web 平台返回空列表。
|
||||
Future<List<String>> listLabelPrinters() => listLabelPrintersImpl();
|
||||
|
||||
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。
|
||||
Future<String?> detectDefaultPrinter() => detectDefaultPrinterImpl();
|
||||
|
||||
Future<void> printStockInOrder(StockInOrder order) =>
|
||||
printStockInOrderImpl(order);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
import 'label_data.dart';
|
||||
|
||||
Future<pw.Font> _loadFont() async {
|
||||
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
|
||||
@@ -188,21 +189,15 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
||||
return size;
|
||||
}
|
||||
|
||||
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
||||
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
|
||||
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
||||
Future<bool> _printFlatLabelThermal({
|
||||
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
||||
Future<ui.Image> _renderLabelBitmap({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String degSpec,
|
||||
required String date,
|
||||
required Uint8List qrBytes,
|
||||
}) async {
|
||||
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
|
||||
final printer = await _findThermalPrinter();
|
||||
debugPrint('[label] thermal printer = $printer');
|
||||
if (printer == null) return false;
|
||||
|
||||
const w = 320, h = 160; // 40×20mm @203dpi
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(
|
||||
@@ -248,7 +243,29 @@ Future<bool> _printFlatLabelThermal({
|
||||
yy += rowH;
|
||||
}
|
||||
|
||||
final img = await recorder.endRecording().toImage(w, h);
|
||||
return recorder.endRecording().toImage(w, h);
|
||||
}
|
||||
|
||||
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
||||
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
|
||||
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
||||
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
|
||||
Future<bool> _printFlatLabelThermal({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String degSpec,
|
||||
required String date,
|
||||
required Uint8List qrBytes,
|
||||
String? printerName,
|
||||
}) async {
|
||||
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
|
||||
final printer = printerName ?? await _findThermalPrinter();
|
||||
debugPrint('[label] thermal printer = $printer');
|
||||
if (printer == null) return false;
|
||||
|
||||
const w = 320, h = 160;
|
||||
final img = await _renderLabelBitmap(
|
||||
shop: shop, name: name, degSpec: degSpec, date: date, qrBytes: qrBytes);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final rgba = bd!.buffer.asUint8List();
|
||||
|
||||
@@ -290,6 +307,7 @@ Future<void> printProductLabelImpl({
|
||||
String shopName = '',
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
String? printerName,
|
||||
}) async {
|
||||
try {
|
||||
final font = await _loadFont();
|
||||
@@ -316,7 +334,8 @@ Future<void> printProductLabelImpl({
|
||||
name: name,
|
||||
degSpec: degSpecVal,
|
||||
date: dateVal == '—' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
|
||||
qrBytes: qrBytes)) {
|
||||
qrBytes: qrBytes,
|
||||
printerName: printerName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -449,6 +468,60 @@ 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,
|
||||
degSpec: degSpecVal,
|
||||
date: dateVal,
|
||||
qrBytes: label.qrBytes!,
|
||||
);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
return bd!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// 枚举当前系统上所有可用打印机名。
|
||||
/// macOS: CUPS 队列(lpstat -e);Windows: Printing.listPrinters();其他返回空列表。
|
||||
Future<List<String>> listLabelPrintersImpl() async {
|
||||
if (kIsWeb) return [];
|
||||
if (Platform.isMacOS) {
|
||||
try {
|
||||
final r = await Process.run('lpstat', ['-e']);
|
||||
if (r.exitCode == 0) {
|
||||
return (r.stdout as String)
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[label] lpstat -e 失败: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
try {
|
||||
return (await Printing.listPrinters()).map((p) => p.name).toList();
|
||||
} catch (e) {
|
||||
debugPrint('[label] listPrinters 失败: $e');
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。
|
||||
Future<String?> detectDefaultPrinterImpl() => _findThermalPrinter();
|
||||
|
||||
pw.Widget _buildOrderDoc({
|
||||
required pw.Font font,
|
||||
required pw.Font bold,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:web/web.dart' as web;
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import 'label_data.dart';
|
||||
|
||||
void _openPrintWindow(String html) {
|
||||
final win = web.window.open('', '_blank');
|
||||
@@ -27,6 +28,7 @@ Future<void> printProductLabelImpl({
|
||||
String shopName = '',
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
String? printerName,
|
||||
}) async {
|
||||
final base64Img = base64Encode(qrBytes);
|
||||
|
||||
@@ -279,6 +281,14 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)─────────────────────────
|
||||
|
||||
Future<Uint8List?> renderLabelPreviewImpl(LabelData label) async => null;
|
||||
|
||||
Future<List<String>> listLabelPrintersImpl() async => [];
|
||||
|
||||
Future<String?> detectDefaultPrinterImpl() async => null;
|
||||
|
||||
// ── 出库单 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
|
||||
Reference in New Issue
Block a user