diff --git a/client/lib/core/utils/label_data.dart b/client/lib/core/utils/label_data.dart new file mode 100644 index 0000000..fb132fa --- /dev/null +++ b/client/lib/core/utils/label_data.dart @@ -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, + }); +} diff --git a/client/lib/core/utils/print_util.dart b/client/lib/core/utils/print_util.dart index 36f0a2d..c1c01e9 100644 --- a/client/lib/core/utils/print_util.dart +++ b/client/lib/core/utils/print_util.dart @@ -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 printProductLabel({ String shopName = '', String shopAddress = '', String shopPhone = '', + String? printerName, }) => printProductLabelImpl( qrBytes: qrBytes, @@ -31,8 +34,19 @@ Future printProductLabel({ shopName: shopName, shopAddress: shopAddress, shopPhone: shopPhone, + printerName: printerName, ); +/// 渲染标签预览图(PNG 字节);Web 平台返回 null。 +Future renderLabelPreview(LabelData label) => + renderLabelPreviewImpl(label); + +/// 枚举当前系统所有可用打印机名;Web 平台返回空列表。 +Future> listLabelPrinters() => listLabelPrintersImpl(); + +/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。 +Future detectDefaultPrinter() => detectDefaultPrinterImpl(); + Future printStockInOrder(StockInOrder order) => printStockInOrderImpl(order); diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 9e1f03c..c76a702 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -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 _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 _printFlatLabelThermal({ +/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。 +/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。 +Future _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 _printFlatLabelThermal({ yy += rowH; } - final img = await recorder.endRecording().toImage(w, h); + return recorder.endRecording().toImage(w, h); +} + +/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。 +/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。 +/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。 +/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。 +Future _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 printProductLabelImpl({ String shopName = '', String shopAddress = '', String shopPhone = '', + String? printerName, }) async { try { final font = await _loadFont(); @@ -316,7 +334,8 @@ Future printProductLabelImpl({ name: name, degSpec: degSpecVal, date: dateVal == '—' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行 - qrBytes: qrBytes)) { + qrBytes: qrBytes, + printerName: printerName)) { return; } @@ -449,6 +468,60 @@ 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, + 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> 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 detectDefaultPrinterImpl() => _findThermalPrinter(); + pw.Widget _buildOrderDoc({ required pw.Font font, required pw.Font bold, diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index 334d4cb..f30c40c 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -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 printProductLabelImpl({ String shopName = '', String shopAddress = '', String shopPhone = '', + String? printerName, }) async { final base64Img = base64Encode(qrBytes); @@ -279,6 +281,14 @@ Future printStockInOrderImpl(StockInOrder order) async { } } +// ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)───────────────────────── + +Future renderLabelPreviewImpl(LabelData label) async => null; + +Future> listLabelPrintersImpl() async => []; + +Future detectDefaultPrinterImpl() async => null; + // ── 出库单 ────────────────────────────────────────────────────────────────── Future printStockOutOrderImpl(StockOutOrder order) async { diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index 2027179..4620ca9 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -16,6 +16,8 @@ import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../core/utils/export_util.dart'; import '../../core/utils/print_util.dart'; +import '../../core/utils/dialog_util.dart'; +import '../../widgets/label_preview_dialog.dart'; import '../../providers/product_provider.dart'; import '../../providers/product_option_provider.dart'; import '../../providers/tab_state_provider.dart'; @@ -200,21 +202,23 @@ class _InventoryListScreenState extends ConsumerState { await ref.read(productRepositoryProvider).getQRCodeBytes(item.productId!); final shopInfo = ref.read(shopInfoProvider).valueOrNull; if (!context.mounted) return; - await safePrint( - context, - () => printProductLabel( - qrBytes: qrBytes, - name: item.productName, - code: item.productCode, - series: item.series.isEmpty ? null : item.series, - spec: item.spec.isEmpty ? null : item.spec, - batchNo: item.batchNo.isEmpty ? null : item.batchNo, - productionDate: item.productionDate, - remark: item.remark.isEmpty ? null : item.remark, - shopName: shopInfo?.name ?? '', - shopAddress: shopInfo?.address ?? '', - shopPhone: shopInfo?.phone ?? '', - ), + final label = LabelData( + productId: item.productId, + qrBytes: qrBytes, + name: item.productName, + code: item.productCode, + series: item.series.isEmpty ? null : item.series, + spec: item.spec.isEmpty ? null : item.spec, + batchNo: item.batchNo.isEmpty ? null : item.batchNo, + productionDate: item.productionDate, + remark: item.remark.isEmpty ? null : item.remark, + shopName: shopInfo?.name ?? '', + shopAddress: shopInfo?.address ?? '', + shopPhone: shopInfo?.phone ?? '', + ); + showAppDialog( + context: context, + builder: (_) => LabelPreviewDialog(labels: [label]), ); } diff --git a/client/lib/screens/products/product_detail_screen.dart b/client/lib/screens/products/product_detail_screen.dart index eb1bfb4..0f68933 100644 --- a/client/lib/screens/products/product_detail_screen.dart +++ b/client/lib/screens/products/product_detail_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/config/app_config.dart'; import '../../core/utils/print_util.dart'; +import '../../widgets/label_preview_dialog.dart'; import '../../core/theme/app_theme.dart'; import '../../models/product.dart'; import '../../models/product_image.dart'; @@ -656,8 +657,6 @@ class _QRCodeDialog extends ConsumerStatefulWidget { class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { late Future _future; Uint8List? _bytes; - bool _printing = false; - String _printStatus = ''; @override void initState() { @@ -670,25 +669,25 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { }); } - Future _print() async { - if (_bytes == null || _printing) return; - setState(() { _printing = true; _printStatus = '正在打印...'; }); + void _print() { + if (_bytes == null) return; final p = widget.product; final shopInfo = ref.read(shopInfoProvider).valueOrNull; - try { - await safePrint(context, () => printProductLabel( - qrBytes: _bytes!, - name: p.name, - code: p.code, - spec: p.spec, - series: p.series, - shopName: shopInfo?.name ?? '', - shopAddress: shopInfo?.address ?? '', - shopPhone: shopInfo?.phone ?? '', - )); - } finally { - if (mounted) setState(() { _printing = false; _printStatus = ''; }); - } + final label = LabelData( + productId: p.id, + qrBytes: _bytes, + name: p.name, + code: p.code, + spec: p.spec, + series: p.series, + shopName: shopInfo?.name ?? '', + shopAddress: shopInfo?.address ?? '', + shopPhone: shopInfo?.phone ?? '', + ); + showAppDialog( + context: context, + builder: (_) => LabelPreviewDialog(labels: [label]), + ); } @override @@ -717,15 +716,9 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { child: const Text('关闭'), ), FilledButton.icon( - onPressed: _bytes == null ? null : (_printing ? null : _print), - icon: _printing - ? const SizedBox( - width: 14, height: 14, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.print, size: 16), - label: Text(_printing - ? (_printStatus.isNotEmpty ? _printStatus : '准备中...') - : '打印'), + onPressed: _bytes == null ? null : _print, + icon: const Icon(Icons.print, size: 16), + label: const Text('打印'), ), ], ); diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 9043f33..e51b78f 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -1,6 +1,7 @@ import '../../core/utils/dialog_util.dart'; import '../../core/errors/error_reporter.dart'; -import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, printProductLabel; +import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, LabelData; +import '../../widgets/label_preview_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -15,7 +16,6 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; import '../../core/utils/export_util.dart'; -import '../../core/utils/print_util.dart'; import '../../providers/inventory_provider.dart'; import '../../providers/tab_state_provider.dart'; import '../../providers/product_provider.dart' show productRepositoryProvider; @@ -365,14 +365,25 @@ class _StockInListScreenState extends ConsumerState { final order = await ref.read(stockInRepositoryProvider).get(o.id); if (!context.mounted) return; final shopInfo = ref.read(shopInfoProvider).valueOrNull; - await showDialog( + final labels = order.items + .map((item) => LabelData( + productId: item.productId, + name: item.productName ?? '', + code: item.productCode ?? '', + series: item.productSeries, + spec: item.productSpec, + batchNo: item.batchNo, + productionDate: item.productionDate, + shopName: shopInfo?.name ?? '', + shopAddress: shopInfo?.address ?? '', + shopPhone: shopInfo?.phone ?? '', + )) + .toList(); + showAppDialog( context: context, - builder: (_) => _LabelPrintDialog( - order: order, - productRepo: ref.read(productRepositoryProvider), - shopName: shopInfo?.name ?? '', - shopAddress: shopInfo?.address ?? '', - shopPhone: shopInfo?.phone ?? '', + builder: (_) => LabelPreviewDialog( + labels: labels, + qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes, ), ); }, @@ -996,149 +1007,3 @@ class _StatusFilterDropdown extends StatelessWidget { } } -class _LabelPrintDialog extends StatefulWidget { - final StockInOrder order; - final ProductRepository productRepo; - final String shopName; - final String shopAddress; - final String shopPhone; - const _LabelPrintDialog({ - required this.order, - required this.productRepo, - this.shopName = '', - this.shopAddress = '', - this.shopPhone = '', - }); - - @override - State<_LabelPrintDialog> createState() => _LabelPrintDialogState(); -} - -class _LabelPrintDialogState extends State<_LabelPrintDialog> { - late final List _selected; - bool _printing = false; - String _status = ''; - - @override - void initState() { - super.initState(); - _selected = List.filled(widget.order.items.length, true); - } - - Future _print() async { - setState(() { _printing = true; _status = '正在打印...'; }); - int done = 0; - for (int i = 0; i < widget.order.items.length; i++) { - if (!_selected[i]) continue; - final item = widget.order.items[i]; - try { - final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId); - await printProductLabel( - qrBytes: qrBytes, - name: item.productName ?? '', - code: item.productCode ?? '', - series: item.productSeries, - spec: item.productSpec, - batchNo: item.batchNo, - productionDate: item.productionDate, - shopName: widget.shopName, - shopAddress: widget.shopAddress, - shopPhone: widget.shopPhone, - ); - done++; - if (mounted) setState(() => _status = '已打印 $done 张...'); - } catch (e, st) { - reportError(e, st); - if (mounted) setState(() => _status = '第${i + 1}行打印失败:$e'); - } - } - if (mounted) setState(() { _printing = false; _status = '完成,共打印 $done 张'; }); - } - - @override - Widget build(BuildContext context) { - final items = widget.order.items; - return Dialog( - child: Container( - width: context.dialogWidth(520), - constraints: const BoxConstraints(maxHeight: 520), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - decoration: const BoxDecoration( - color: AppTheme.primary, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - ), - child: Row( - children: [ - const Text('打印商品标签', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: items.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (_, i) { - final item = items[i]; - return CheckboxListTile( - value: _selected[i], - onChanged: _printing - ? null - : (v) => setState(() => _selected[i] = v ?? false), - title: Text( - '${item.productCode ?? ''} ${item.productName ?? ''}', - style: const TextStyle(fontSize: 13), - ), - subtitle: Text( - '${item.productSeries ?? ''} ${item.productSpec ?? ''}', - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), - ), - dense: true, - controlAffinity: ListTileControlAffinity.leading, - ); - }, - ), - ), - if (_status.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text(_status, - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('关闭'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: (_printing || !_selected.contains(true)) ? null : _print, - icon: const Icon(Icons.print_outlined, size: 16), - label: Text(_printing ? '打印中...' : '打印选中'), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index c64f519..457410b 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -1,7 +1,6 @@ -import '../../repositories/product_repository.dart'; import '../../core/utils/dialog_util.dart'; import '../../core/errors/error_reporter.dart'; -import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder, printProductLabel; +import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -920,149 +919,3 @@ class _StatusFilterDropdown extends StatelessWidget { } } -class _LabelPrintDialog extends StatefulWidget { - final StockOutOrder order; - final ProductRepository productRepo; - final String shopName; - final String shopAddress; - final String shopPhone; - const _LabelPrintDialog({ - required this.order, - required this.productRepo, - this.shopName = '', - this.shopAddress = '', - this.shopPhone = '', - }); - - @override - State<_LabelPrintDialog> createState() => _LabelPrintDialogState(); -} - -class _LabelPrintDialogState extends State<_LabelPrintDialog> { - late final List _selected; - bool _printing = false; - String _status = ''; - - @override - void initState() { - super.initState(); - _selected = List.filled(widget.order.items.length, true); - } - - Future _print() async { - setState(() { _printing = true; _status = '正在打印...'; }); - int done = 0; - for (int i = 0; i < widget.order.items.length; i++) { - if (!_selected[i]) continue; - final item = widget.order.items[i]; - try { - final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId); - await printProductLabel( - qrBytes: qrBytes, - name: item.productName ?? '', - code: item.productCode ?? '', - series: item.productSeries, - spec: item.productSpec, - shopName: widget.shopName, - shopAddress: widget.shopAddress, - shopPhone: widget.shopPhone, - ); - done++; - if (mounted) setState(() => _status = '已打印 $done 张...'); - } catch (e, st) { - reportError(e, st); - if (mounted) setState(() => _status = '第${i + 1}行打印失败:$e'); - } - } - if (mounted) { - setState(() { _printing = false; _status = '完成,共打印 $done 张'; }); - } - } - - @override - Widget build(BuildContext context) { - final items = widget.order.items; - return Dialog( - child: Container( - width: context.dialogWidth(520), - constraints: const BoxConstraints(maxHeight: 520), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - decoration: const BoxDecoration( - color: AppTheme.primary, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - ), - child: Row( - children: [ - const Text('打印商品标签', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: items.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (_, i) { - final item = items[i]; - return CheckboxListTile( - value: _selected[i], - onChanged: _printing - ? null - : (v) => setState(() => _selected[i] = v ?? false), - title: Text( - '${item.productCode ?? ''} ${item.productName ?? ''}', - style: const TextStyle(fontSize: 13), - ), - subtitle: Text( - '${item.productSeries ?? ''} ${item.productSpec ?? ''}', - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), - ), - dense: true, - controlAffinity: ListTileControlAffinity.leading, - ); - }, - ), - ), - if (_status.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text(_status, - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('关闭'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: (_printing || !_selected.contains(true)) ? null : _print, - icon: const Icon(Icons.print_outlined, size: 16), - label: Text(_printing ? '打印中...' : '打印选中'), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/client/lib/widgets/label_preview_dialog.dart b/client/lib/widgets/label_preview_dialog.dart new file mode 100644 index 0000000..9687c93 --- /dev/null +++ b/client/lib/widgets/label_preview_dialog.dart @@ -0,0 +1,434 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../core/errors/error_reporter.dart'; +import '../core/theme/app_theme.dart'; +import '../core/responsive/responsive.dart'; +import '../core/utils/print_util.dart'; +import '../core/utils/label_data.dart'; + +const _kPrinterKey = 'label_printer'; + +/// 标签打印预览弹窗:所见即所得预览(dart:ui 位图)+ 打印机选择 + 份数调整。 +/// +/// 单张模式(labels.length == 1):隐藏缩略图条,直接显示大图。 +/// 多张模式:左侧竖排缩略图(带勾选),右侧大图主视图。 +class LabelPreviewDialog extends StatefulWidget { + final List labels; + + /// 多张懒加载:按 productId 拉取 QR PNG 字节。单张场景可不传(qrBytes 已在 LabelData 中)。 + final Future Function(int productId)? qrFetcher; + + const LabelPreviewDialog({ + super.key, + required this.labels, + this.qrFetcher, + }); + + @override + State createState() => _LabelPreviewDialogState(); +} + +class _LabelPreviewDialogState extends State { + int _selectedIndex = 0; + late List _previews; + late List _include; + List _printers = []; + String? _printer; + bool _printing = false; + String _status = ''; + + @override + void initState() { + super.initState(); + _previews = List.filled(widget.labels.length, null, + growable: false); + _include = List.filled(widget.labels.length, true, growable: false); + _init(); + } + + Future _init() async { + // ── 枚举打印机,读取上次记忆 ── + final printers = await listLabelPrinters(); + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_kPrinterKey); + String? selected; + if (saved != null && printers.contains(saved)) { + selected = saved; + } else { + selected = await detectDefaultPrinter() ?? + (printers.isNotEmpty ? printers.first : null); + } + if (mounted) { + setState(() { + _printers = printers; + _printer = selected; + }); + } + + // ── 逐张渲染预览(progressive)── + for (int i = 0; i < widget.labels.length; i++) { + final label = widget.labels[i]; + try { + // 懒加载 QR + if (label.qrBytes == null && + label.productId != null && + widget.qrFetcher != null) { + label.qrBytes = await widget.qrFetcher!(label.productId!); + } + if (label.qrBytes == null) continue; + final png = await renderLabelPreview(label); + if (mounted && png != null) { + setState(() => _previews[i] = png); + } + } catch (e, st) { + debugPrint('[preview] 渲染第${i + 1}张失败: $e'); + reportError(e, st); + } + } + } + + int get _totalPrintCount { + int total = 0; + for (int i = 0; i < widget.labels.length; i++) { + if (_include[i]) total += widget.labels[i].copies; + } + return total; + } + + Future _doPrint() async { + if (_printing) return; + setState(() { + _printing = true; + _status = '正在打印...'; + }); + int done = 0; + final total = _totalPrintCount; + for (int i = 0; i < widget.labels.length; i++) { + if (!_include[i]) continue; + final label = widget.labels[i]; + for (int c = 0; c < label.copies; c++) { + try { + // 确保 QR 已拉取 + if (label.qrBytes == null && + label.productId != null && + widget.qrFetcher != null) { + label.qrBytes = await widget.qrFetcher!(label.productId!); + } + if (label.qrBytes == null) continue; + await printProductLabel( + qrBytes: label.qrBytes!, + name: label.name, + code: label.code, + spec: label.spec, + series: label.series, + batchNo: label.batchNo, + productionDate: label.productionDate, + remark: label.remark, + shopName: label.shopName, + shopAddress: label.shopAddress, + shopPhone: label.shopPhone, + printerName: _printer, + ); + done++; + if (mounted) { + setState(() => _status = '已打印 $done/$total 张...'); + } + } catch (e, st) { + reportError(e, st); + if (mounted) { + setState(() => _status = '第${i + 1}张第${c + 1}份打印失败:$e'); + } + } + } + } + if (mounted) { + setState(() { + _printing = false; + _status = '完成,共打印 $done 张'; + }); + } + } + + // ── 缩略图列表项 ───────────────────────────────────────────────────────────── + + Widget _buildThumbnail(int i) { + final isSelected = _selectedIndex == i; + return GestureDetector( + onTap: () => setState(() => _selectedIndex = i), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + border: Border.all( + color: isSelected ? AppTheme.primary : AppTheme.border, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(6), + color: isSelected + ? AppTheme.primary.withOpacity(0.04) + : AppTheme.surface, + ), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: Checkbox( + value: _include[i], + onChanged: _printing + ? null + : (v) => setState(() => _include[i] = v ?? true), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 4), + SizedBox( + width: 76, + height: 38, + child: _previews[i] != null + ? Image.memory(_previews[i]!, fit: BoxFit.contain) + : const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2))), + ), + const SizedBox(width: 6), + Text( + '${i + 1}', + style: TextStyle( + fontSize: 12, + color: isSelected + ? AppTheme.primary + : AppTheme.textSecondary, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ); + } + + // ── 大图 + 份数控件 ────────────────────────────────────────────────────────── + + Widget _buildMainPreview() { + final preview = _previews[_selectedIndex]; + final label = widget.labels[_selectedIndex]; + return Column( + children: [ + Expanded( + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 420, maxHeight: 230), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.border), + borderRadius: BorderRadius.circular(4), + color: Colors.white, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: preview != null + ? Image.memory(preview, fit: BoxFit.contain) + : const Center(child: CircularProgressIndicator()), + ), + ), + ), + ), + const SizedBox(height: 8), + // 份数调节 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('打印份数:', style: TextStyle(fontSize: 13)), + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 20), + onPressed: (_printing || label.copies <= 1) + ? null + : () => setState(() => label.copies--), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 4), + SizedBox( + width: 32, + child: Text( + '${label.copies}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 20), + onPressed: + _printing ? null : () => setState(() => label.copies++), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ], + ); + } + + // ── build ──────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final isMulti = widget.labels.length > 1; + final totalCount = _totalPrintCount; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Container( + width: context.dialogWidth(isMulti ? 640 : 440), + constraints: const BoxConstraints(maxHeight: 560), + child: Column( + children: [ + // ── 顶栏 ────────────────────────────────────────────────────────── + Container( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: const BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + const Text('打印预览', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + + // ── 打印机选择行 ────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + children: [ + const Text('打印机:', + style: TextStyle(fontSize: 13)), + const SizedBox(width: 8), + Expanded( + child: _printers.isEmpty + ? const Text('检测中或未检测到打印机...', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary)) + : DropdownButtonHideUnderline( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.border), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButton( + value: _printer, + isExpanded: true, + isDense: true, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textPrimary), + items: _printers + .map((p) => DropdownMenuItem( + value: p, + child: Text(p, + overflow: + TextOverflow.ellipsis), + )) + .toList(), + onChanged: _printing + ? null + : (v) async { + if (v == null) return; + setState(() => _printer = v); + final prefs = await SharedPreferences + .getInstance(); + await prefs.setString( + _kPrinterKey, v); + }, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + + // ── 主体:缩略图条 + 大图 ───────────────────────────────────────── + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isMulti) + SizedBox( + width: 148, + child: ListView.builder( + itemCount: widget.labels.length, + itemBuilder: (_, i) => _buildThumbnail(i), + ), + ), + if (isMulti) const SizedBox(width: 8), + Expanded(child: _buildMainPreview()), + ], + ), + ), + ), + + // ── 底栏 ───────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 14), + child: Row( + children: [ + if (_status.isNotEmpty) + Expanded( + child: Text( + _status, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary), + ), + ) + else + const Spacer(), + TextButton( + onPressed: + _printing ? null : () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: (_printing || totalCount == 0) ? null : _doPrint, + child: Text( + _printing ? '打印中...' : '打印 $totalCount 张'), + ), + ], + ), + ), + ], + ), + ), + ); + } +}