diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index 21fd744..5f904aa 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,11 @@ 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.0.82] - 2026-06-26 + +### 新功能 +- 出库单 / 入库单打印新增「打印预览」:点击打印先弹出整页预览(所见即所得),确认无误后再调用系统打印,解决部分 Windows 电脑打印对话框无法预览的问题。 + ## [1.0.81] - 2026-06-24 ### 改进 diff --git a/client/lib/core/utils/print_util.dart b/client/lib/core/utils/print_util.dart index c1c01e9..df5405e 100644 --- a/client/lib/core/utils/print_util.dart +++ b/client/lib/core/utils/print_util.dart @@ -53,6 +53,14 @@ Future printStockInOrder(StockInOrder order) => Future printStockOutOrder(StockOutOrder order) => printStockOutOrderImpl(order); +/// 生成入库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。 +Future buildStockInOrderPdf(StockInOrder order) => + buildStockInOrderPdfImpl(order); + +/// 生成出库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。 +Future buildStockOutOrderPdf(StockOutOrder order) => + buildStockOutOrderPdfImpl(order); + /// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。 /// 用法:await safePrint(context, () => printStockInOrder(order)); Future safePrint(BuildContext context, Future Function() fn) async { diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 391b9fd..e2244d8 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -799,8 +799,8 @@ pw.Widget _sig(pw.Font font, pw.Font bold, String label) { ); } -Future printStockInOrderImpl(StockInOrder order) async { - try { +/// 生成入库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。 +Future buildStockInOrderPdfImpl(StockInOrder order) async { final font = await _loadFont(); final bold = await _loadBoldFont(); @@ -848,7 +848,12 @@ Future printStockInOrderImpl(StockInOrder order) async { ), ], )); - final bytes = await doc.save(); + return doc.save(); +} + +Future printStockInOrderImpl(StockInOrder order) async { + try { + final bytes = await buildStockInOrderPdfImpl(order); if (!kIsWeb && Platform.isMacOS) { await Future.delayed(Duration.zero); } @@ -862,7 +867,8 @@ Future printStockInOrderImpl(StockInOrder order) async { } } -Future printStockOutOrderImpl(StockOutOrder order) async { +/// 生成出库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。 +Future buildStockOutOrderPdfImpl(StockOutOrder order) async { final font = await _loadFont(); final bold = await _loadBoldFont(); @@ -909,8 +915,12 @@ Future printStockOutOrderImpl(StockOutOrder order) async { ), ], )); + return doc.save(); +} + +Future printStockOutOrderImpl(StockOutOrder order) async { try { - final bytes = await doc.save(); + final bytes = await buildStockOutOrderPdfImpl(order); if (!kIsWeb && Platform.isMacOS) { await Future.delayed(Duration.zero); } diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index f30c40c..8e38812 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -170,6 +170,14 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; } } } +// ── 单据 PDF 字节(Web 走 HTML 打印路径,不生成 PDF;预览弹窗仅桌面端调用)────── + +Future buildStockInOrderPdfImpl(StockInOrder order) async => + throw UnsupportedError('Web 端入库单走 HTML 打印,不生成 PDF'); + +Future buildStockOutOrderPdfImpl(StockOutOrder order) async => + throw UnsupportedError('Web 端出库单走 HTML 打印,不生成 PDF'); + // ── 入库单 ────────────────────────────────────────────────────────────────── Future printStockInOrderImpl(StockInOrder order) async { diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index 7ec7c5b..50feb6c 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -5,8 +5,8 @@ import '../../core/responsive/responsive.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../core/auth/auth_state.dart'; -import '../../core/utils/print_util.dart'; import '../../core/utils/date_util.dart'; +import '../../widgets/order_print_preview_dialog.dart'; import '../../models/stock_in.dart'; import '../../core/config/app_constants.dart'; import '../../providers/partner_provider.dart'; @@ -227,7 +227,7 @@ class _StockInFormScreenState extends ConsumerState { Future _printOrder() async { if (_loadedOrder == null) return; - await safePrint(context, () => printStockInOrder(_loadedOrder!)); + await showStockInOrderPrint(context, _loadedOrder!); } Future _submit(bool asDraft) async { 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 5a5632d..b69bb67 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -1,8 +1,9 @@ import '../../core/utils/dialog_util.dart'; import '../../core/errors/error_reporter.dart'; import '../../core/exceptions.dart'; -import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, LabelData; +import '../../core/utils/print_util.dart' show LabelData; import '../../widgets/label_preview_dialog.dart'; +import '../../widgets/order_print_preview_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -529,7 +530,7 @@ class _StockInListScreenState extends ConsumerState { onPrint: () async { final order = await ref.read(stockInRepositoryProvider).get(o.id); if (context.mounted) { - await safePrint(context, () => printStockInOrder(order)); + await showStockInOrderPrint(context, order); } }, afterPrint: [ @@ -1074,7 +1075,7 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { IconButton( icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark), tooltip: '打印', - onPressed: () => safePrint(context, () => printStockInOrder(_loadedOrder!)), + onPressed: () => showStockInOrderPrint(context, _loadedOrder!), ), ], ), diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index bce35b2..15aa878 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -7,8 +7,8 @@ import 'package:go_router/go_router.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; import '../../core/auth/auth_state.dart'; -import '../../core/utils/print_util.dart'; import '../../core/utils/date_util.dart'; +import '../../widgets/order_print_preview_dialog.dart'; import '../../models/inventory.dart'; import '../../models/stock_out.dart'; import '../../widgets/date_picker_field.dart'; @@ -258,7 +258,7 @@ class _StockOutFormScreenState extends ConsumerState { Future _printOrder() async { if (_loadedOrder == null) return; - await safePrint(context, () => printStockOutOrder(_loadedOrder!)); + await showStockOutOrderPrint(context, _loadedOrder!); } Future _submit(bool asDraft) async { 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 bc5a71b..4cca14e 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -1,6 +1,6 @@ import '../../core/utils/dialog_util.dart'; import '../../core/errors/error_reporter.dart'; -import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder; +import '../../widgets/order_print_preview_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -17,7 +17,6 @@ import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; import '../../widgets/search_chip.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'; @@ -559,7 +558,7 @@ class _StockOutListScreenState extends ConsumerState { onPrint: () async { final order = await ref.read(stockOutRepositoryProvider).get(o.id); if (context.mounted) { - await safePrint(context, () => printStockOutOrder(order)); + await showStockOutOrderPrint(context, order); } }, onSettle: () => _confirmSettle(context, o.id, 'stock_out'), @@ -987,7 +986,7 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> { IconButton( icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark), tooltip: '打印', - onPressed: () => safePrint(context, () => printStockOutOrder(_loadedOrder!)), + onPressed: () => showStockOutOrderPrint(context, _loadedOrder!), ), ], ), diff --git a/client/lib/widgets/order_print_preview_dialog.dart b/client/lib/widgets/order_print_preview_dialog.dart new file mode 100644 index 0000000..f31f8dd --- /dev/null +++ b/client/lib/widgets/order_print_preview_dialog.dart @@ -0,0 +1,258 @@ +import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:printing/printing.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 '../models/stock_in.dart'; +import '../models/stock_out.dart'; + +/// 单据打印预览弹窗:把待打印的 A4 PDF 光栅化成图片(所见即所得)逐页展示, +/// 页内点「打印」再调系统打印。解决部分 Windows 系统打印对话框无预览的问题。 +/// +/// 仅桌面端使用;Web 端走浏览器自带的打印预览(见 [showStockInOrderPrint])。 +class OrderPrintPreviewDialog extends StatefulWidget { + /// 标题,如「入库单打印预览」。 + final String title; + + /// 系统打印任务名(也作另存默认文件名前缀)。 + final String printName; + + /// 惰性生成 PDF 字节。 + final Future Function() pdfBuilder; + + const OrderPrintPreviewDialog({ + super.key, + required this.title, + required this.printName, + required this.pdfBuilder, + }); + + @override + State createState() => + _OrderPrintPreviewDialogState(); +} + +class _OrderPrintPreviewDialogState extends State { + Uint8List? _pdfBytes; + final List _pages = []; + bool _rendering = true; + bool _printing = false; + String _error = ''; + + @override + void initState() { + super.initState(); + _render(); + } + + Future _render() async { + try { + final bytes = await widget.pdfBuilder(); + if (!mounted) return; + setState(() => _pdfBytes = bytes); + // 逐页光栅化(progressive),DPI 取 110 兼顾清晰与速度。 + await for (final raster in Printing.raster(bytes, dpi: 110)) { + final png = await raster.toPng(); + if (!mounted) return; + setState(() => _pages.add(png)); + } + } catch (e, st) { + reportError(e, st); + if (mounted) setState(() => _error = '预览生成失败:$e'); + } finally { + if (mounted) setState(() => _rendering = false); + } + } + + Future _doPrint() async { + final bytes = _pdfBytes; + if (bytes == null || _printing) return; + setState(() => _printing = true); + try { + await Printing.layoutPdf( + name: '${widget.printName}_${_ts()}', + dynamicLayout: false, + onLayout: (_) async => bytes, + ); + if (mounted) Navigator.of(context).pop(); + } catch (e, st) { + reportError(e, st); + if (mounted) { + setState(() { + _printing = false; + _error = '打印失败,请检查打印机连接和驱动是否正常。\n详情:$e'; + }); + } + } + } + + String _ts() { + final n = DateTime.now(); + String p(int v) => v.toString().padLeft(2, '0'); + return '${n.year}${p(n.month)}${p(n.day)}_${p(n.hour)}${p(n.minute)}${p(n.second)}'; + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Container( + width: context.dialogWidth(720), + constraints: const BoxConstraints(maxHeight: 720), + 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: [ + Text(widget.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: + _printing ? null : () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + + // ── 预览区 ───────────────────────────────────────────────────────── + Expanded( + child: Container( + width: double.infinity, + color: const Color(0xFFE9ECF1), + child: _buildBody(), + ), + ), + + // ── 底栏 ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 14), + child: Row( + children: [ + Expanded( + child: Text( + _error.isNotEmpty + ? _error + : (_rendering + ? '正在生成预览…' + : '共 ${_pages.length} 页'), + style: TextStyle( + fontSize: 12, + color: _error.isNotEmpty + ? Colors.red + : AppTheme.textSecondary), + ), + ), + TextButton( + onPressed: + _printing ? null : () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + const SizedBox(width: 8), + FilledButton.icon( + icon: const Icon(Icons.print, size: 18), + onPressed: (_pdfBytes == null || _printing) + ? null + : _doPrint, + label: Text(_printing ? '打印中…' : '打印'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildBody() { + if (_pages.isEmpty) { + if (_error.isNotEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(_error, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red, fontSize: 13)), + ), + ); + } + return const Center(child: CircularProgressIndicator()); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + itemCount: _pages.length, + separatorBuilder: (_, __) => const SizedBox(height: 16), + itemBuilder: (_, i) => Center( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.18), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Image.memory(_pages[i], fit: BoxFit.contain), + ), + ), + ); + } +} + +/// 打印入库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。 +Future showStockInOrderPrint( + BuildContext context, StockInOrder order) async { + if (kIsWeb) { + await safePrint(context, () => printStockInOrder(order)); + return; + } + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => OrderPrintPreviewDialog( + title: '入库单打印预览', + printName: '入库单', + pdfBuilder: () => buildStockInOrderPdf(order), + ), + ); +} + +/// 打印出库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。 +Future showStockOutOrderPrint( + BuildContext context, StockOutOrder order) async { + if (kIsWeb) { + await safePrint(context, () => printStockOutOrder(order)); + return; + } + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => OrderPrintPreviewDialog( + title: '出库单打印预览', + printName: '出库单', + pdfBuilder: () => buildStockOutOrderPdf(order), + ), + ); +}