From 021c4f17ccfb9b191125c24c7f64e39264c8deb4 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 27 Jun 2026 19:28:01 +0800 Subject: [PATCH] chore: release client-v1.0.86 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG-client.md | 5 + client/lib/core/utils/print_util_stub.dart | 388 +++++++++++-------- client/test/print_order_pagination_test.dart | 68 ++++ 3 files changed, 298 insertions(+), 163 deletions(-) create mode 100644 client/test/print_order_pagination_test.dart diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index c72b367..ed83553 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.86] - 2026-06-27 + +### 修复 +- 修复出库单 / 入库单打印时左侧竖线总是画满整页、与单据内容长度不一致的问题:现竖线随内容自动收缩,单据较短或跨页最后一页时只画到最后一行,不再多打一长截。 + ## [1.0.85] - 2026-06-27 ### 改进 diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 1d85944..4ccd877 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -16,7 +16,8 @@ import 'package:win32/win32.dart' ClosePrinter, DOC_INFO_1, WideCharToMultiByte; -import 'package:flutter/foundation.dart' show kIsWeb, debugPrint; +import 'package:flutter/foundation.dart' + show kIsWeb, debugPrint, visibleForTesting; import 'package:flutter/services.dart'; import '../errors/error_reporter.dart'; import 'package:pdf/pdf.dart'; @@ -729,14 +730,42 @@ List _buildOrderDoc({ pw.Widget metaText(String label, String value, pw.TextAlign align) => pw.Text('$label:$value', style: metaStyle, textAlign: align); - // 返回明细块的「子组件列表」而非单个 Container:作为 MultiPage 的顶层 - // children,明细表可跨页拆分。左侧竖线点缀改由 pageTheme 背景每页绘制 - // (见 _orderPageTheme)——原先用非可拆分的 pw.Container 包裹整块内容, - // 当内容超过一页时 MultiPage 无法拆分,分页算法不前进→无限加页、内存暴涨 - // →打印卡死(根因)。 + // 竖线(左侧点缀)由内容自身携带,而非整页背景——让竖线随内容高度收缩, + // 跨页时尾页也只画到最后一行,不再画满整页。结构分三段: + // 前段(抬头+信息条) / 明细表 / 后段(总计+提示+签字) + // 前后两段是「短」原子 Container(带左边框=竖线),明细表用 TableBorder.left + // 当竖线(表格可跨页拆分,左边框随行收缩)。三段紧贴堆叠→竖线连续;尾段为 + // 最后一个原子组件,其边框到内容末尾即止→竖线贴合内容。明细表是唯一可能超 + // 一页的组件且本身可拆分,不存在「原子大组件超页」→不会触发分页死循环 + // (1.0.84 修复的根因)。 + pw.Widget group(List children, + {double top = 0, double bottom = 0}) => + pw.Container( + decoration: const pw.BoxDecoration( + border: + pw.Border(left: pw.BorderSide(color: PdfColors.black, width: 3)), + ), + padding: pw.EdgeInsets.only(top: top, bottom: bottom), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + mainAxisSize: pw.MainAxisSize.min, + children: children, + ), + ); + // 明细表首列额外左缩进,使表格内容与各区块内容左缩进对齐。 + pw.EdgeInsets headPad(int i) => i == 0 + ? const pw.EdgeInsets.only(left: 11, right: 2, top: 2.5, bottom: 2.5) + : const pw.EdgeInsets.symmetric(horizontal: 2, vertical: 2.5); + pw.EdgeInsets bodyPad(int i) => i == 0 + ? const pw.EdgeInsets.only(left: 11, right: 3, top: 2, bottom: 2) + : const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2); + return [ - // ── 抬头:公司名 + 单据类型,左对齐内联 ────────────────────────── - pw.Row( + // ── 前段:抬头 + 信息条 ────────────────────────────────────────────── + group([ + pw.Padding( + padding: const pw.EdgeInsets.only(left: 11), + child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ pw.Text(shopName, @@ -755,128 +784,133 @@ List _buildOrderDoc({ ), ], ), - pw.SizedBox(height: 8), - // ── 抬头信息:灰底信息条(两行三列)──────────────────────────── - pw.Container( - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.black, width: 0.8), - ), - padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: pw.Column( - children: [ - pw.Row( - children: [ - pw.Expanded( - flex: 5, - child: metaText( - partnerLabel, partnerName ?? '', pw.TextAlign.left)), - pw.Expanded( - flex: 4, - child: metaText( - dateLabel, _d10(orderDate), pw.TextAlign.left)), - pw.Expanded( - flex: 3, - child: pw.Text('$noPrefix$orderNo', - style: metaBold, textAlign: pw.TextAlign.right)), - ], - ), - pw.SizedBox(height: 3), - pw.Row( - children: [ - pw.Expanded( - flex: 5, - child: metaText('联系电话', '', pw.TextAlign.left)), - pw.Expanded( - flex: 4, - child: metaText(addressLabel, '', pw.TextAlign.left)), - pw.Expanded( - flex: 3, - child: metaText( - '仓库', warehouseName ?? '', pw.TextAlign.right)), - ], - ), - ], - ), + ), + pw.SizedBox(height: 8), + pw.Container( + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.black, width: 0.8), ), - pw.SizedBox(height: 8), - // ── 明细表(密集、灰底表头、数字等宽)────────────────────────── - pw.Table( - border: pw.TableBorder.all(color: PdfColors.black, width: 0.8), - columnWidths: { - for (var i = 0; i < _orderColFlex.length; i++) - i: pw.FlexColumnWidth(_orderColFlex[i]) - }, + padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: pw.Column( children: [ - pw.TableRow( + pw.Row( children: [ - for (final h in _orderHeaders) - pw.Padding( - padding: const pw.EdgeInsets.symmetric( - horizontal: 2, vertical: 2.5), - child: pw.Text(h, - textAlign: pw.TextAlign.center, style: headStyle), - ), + pw.Expanded( + flex: 5, + child: metaText( + partnerLabel, partnerName ?? '', pw.TextAlign.left)), + pw.Expanded( + flex: 4, + child: + metaText(dateLabel, _d10(orderDate), pw.TextAlign.left)), + pw.Expanded( + flex: 3, + child: pw.Text('$noPrefix$orderNo', + style: metaBold, textAlign: pw.TextAlign.right)), ], ), - for (final row in rows) - pw.TableRow( - children: [ - for (var i = 0; i < row.length; i++) - pw.Padding( - padding: const pw.EdgeInsets.symmetric( - horizontal: 3, vertical: 2), - child: pw.Text(row[i], - textAlign: _colAlign(i), - style: (i >= 6 && i <= 8) ? numStyle : cellStyle), - ), - ], + pw.SizedBox(height: 3), + pw.Row( + children: [ + pw.Expanded( + flex: 5, + child: metaText('联系电话', '', pw.TextAlign.left)), + pw.Expanded( + flex: 4, + child: metaText(addressLabel, '', pw.TextAlign.left)), + pw.Expanded( + flex: 3, + child: metaText( + '仓库', warehouseName ?? '', pw.TextAlign.right)), + ], + ), + ], + ), + ), + ], bottom: 8), + // ── 明细表:左外边框 3 = 竖线(可跨页拆分,尾页随行收缩)──────────── + pw.Table( + border: const pw.TableBorder( + left: pw.BorderSide(color: PdfColors.black, width: 3), + top: pw.BorderSide(color: PdfColors.black, width: 0.8), + right: pw.BorderSide(color: PdfColors.black, width: 0.8), + bottom: pw.BorderSide(color: PdfColors.black, width: 0.8), + horizontalInside: pw.BorderSide(color: PdfColors.black, width: 0.8), + verticalInside: pw.BorderSide(color: PdfColors.black, width: 0.8), + ), + columnWidths: { + for (var i = 0; i < _orderColFlex.length; i++) + i: pw.FlexColumnWidth(_orderColFlex[i]) + }, + children: [ + pw.TableRow( + children: [ + for (var i = 0; i < _orderHeaders.length; i++) + pw.Padding( + padding: headPad(i), + child: pw.Text(_orderHeaders[i], + textAlign: pw.TextAlign.center, style: headStyle), ), ], ), - // ── 单据总计:灰底条,右对齐,仅数量 + 金额(等宽数字)────────── - pw.SizedBox(height: 6), + for (final row in rows) + pw.TableRow( + children: [ + for (var i = 0; i < row.length; i++) + pw.Padding( + padding: bodyPad(i), + child: pw.Text(row[i], + textAlign: _colAlign(i), + style: (i >= 6 && i <= 8) ? numStyle : cellStyle), + ), + ], + ), + ], + ), + // ── 后段:单据总计 + 温馨提示 + 签字行 ─────────────────────────────── + group([ + pw.Container( + width: double.infinity, + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.black, width: 0.8), + ), + padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text('单据总计', style: metaBold), + pw.SizedBox(width: 26), + pw.Text('数量 ', style: metaStyle), + pw.Text(_qtyStr(totalQty), + style: pw.TextStyle(font: monoBold, fontSize: 11)), + pw.SizedBox(width: 26), + pw.Text('金额 ', style: metaStyle), + pw.Text(totalAmt.toStringAsFixed(2), + style: pw.TextStyle(font: monoBold, fontSize: 12)), + ], + ), + ), + if (showTips) ...[ + pw.SizedBox(height: 8), pw.Container( width: double.infinity, decoration: pw.BoxDecoration( border: pw.Border.all(color: PdfColors.black, width: 0.8), ), - padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.end, - crossAxisAlignment: pw.CrossAxisAlignment.center, - children: [ - pw.Text('单据总计', style: metaBold), - pw.SizedBox(width: 26), - pw.Text('数量 ', style: metaStyle), - pw.Text(_qtyStr(totalQty), - style: pw.TextStyle(font: monoBold, fontSize: 11)), - pw.SizedBox(width: 26), - pw.Text('金额 ', style: metaStyle), - pw.Text(totalAmt.toStringAsFixed(2), - style: pw.TextStyle(font: monoBold, fontSize: 12)), - ], - ), + padding: const pw.EdgeInsets.all(5), + child: pw.Text(_kTipsText, + style: pw.TextStyle( + font: bold, + fontSize: 9, + fontWeight: pw.FontWeight.bold, + color: PdfColors.black)), ), - // ── 温馨提示(仅出库单)────────────────────────────────────────── - if (showTips) ...[ - pw.SizedBox(height: 8), - pw.Container( - width: double.infinity, - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.black, width: 0.8), - ), - padding: const pw.EdgeInsets.all(5), - child: pw.Text(_kTipsText, - style: pw.TextStyle( - font: bold, - fontSize: 9, - fontWeight: pw.FontWeight.bold, - color: PdfColors.black)), - ), - ], - pw.SizedBox(height: 24), - // ── 签字行:签收人 / 经办人 / 制单人 ────────────────────────────── - pw.Row( + ], + pw.SizedBox(height: 24), + pw.Padding( + padding: const pw.EdgeInsets.only(left: 11), + child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('$signerLabel:', style: metaStyle), @@ -885,42 +919,80 @@ List _buildOrderDoc({ pw.Text('制单人:${makerName ?? ''}', style: metaStyle), ], ), - if ((remark ?? '').isNotEmpty) ...[ - pw.SizedBox(height: 10), - pw.Text('备注:$remark', style: metaStyle), - ], + ), + if ((remark ?? '').isNotEmpty) ...[ + pw.SizedBox(height: 10), + pw.Padding( + padding: const pw.EdgeInsets.only(left: 11), + child: pw.Text('备注:$remark', style: metaStyle), + ), + ], + ], top: 8), ]; } -/// 出/入库单 MultiPage 页面主题:内容区左缩进 + 全页高左侧竖线点缀(每页都画)。 -/// 用 pageTheme 背景绘制竖线,让明细内容(含表格)可跨页拆分,避免非可拆分 -/// Container 在内容超一页时触发 MultiPage 分页死循环。 -pw.PageTheme _orderPageTheme() => pw.PageTheme( - pageFormat: PdfPageFormat.a4, - // 左边距 = 原始外边距 15mm + (竖线宽 3 + 内缩 14),与旧版视觉缩进一致。 - margin: const pw.EdgeInsets.fromLTRB( - 15 * PdfPageFormat.mm + 17, - 12 * PdfPageFormat.mm, - 15 * PdfPageFormat.mm, - 12 * PdfPageFormat.mm, - ), - buildBackground: (ctx) => pw.FullPage( - ignoreMargins: true, - child: pw.Padding( - padding: const pw.EdgeInsets.fromLTRB( - 15 * PdfPageFormat.mm, - 12 * PdfPageFormat.mm, - 0, - 12 * PdfPageFormat.mm, - ), - child: pw.Container( - decoration: const pw.BoxDecoration( - border: pw.Border( - left: pw.BorderSide(color: PdfColors.black, width: 3)), - ), - ), - ), - ), +/// 出/入库单文档:单一 MultiPage,竖线由 `_buildOrderDoc` 的内容自身携带 +/// (前/后两段原子 Container 的左边框 + 明细表 TableBorder.left),故竖线随内容 +/// 高度收缩、跨页时尾页只画到最后一行;明细表可跨页拆分,无原子大组件超页, +/// 不会触发分页死循环。 +pw.Document _composeOrderDoc(List Function() buildContent) { + final doc = pw.Document(); + doc.addPage(pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.symmetric( + horizontal: 15 * PdfPageFormat.mm, + vertical: 12 * PdfPageFormat.mm, + ), + build: (_) => buildContent(), + )); + return doc; +} + +Future _buildOrderPdf( + List Function() buildContent) async => + _composeOrderDoc(buildContent).save(); + +/// 测试用:构建文档并返回页数(用于回归「明细超一页不卡死且能正常分页」)。 +@visibleForTesting +Future debugOrderDocPageCount( + List Function() buildContent) async { + final doc = _composeOrderDoc(buildContent); + await doc.save(); + return doc.document.pdfPageList.pages.length; +} + +/// 测试用:暴露 `_buildOrderDoc`,便于在不依赖 model 的情况下构造内容。 +@visibleForTesting +List debugBuildOrderDoc({ + required pw.Font font, + required pw.Font bold, + required List> rows, + required double totalQty, + required double totalAmt, + bool showTips = true, +}) => + _buildOrderDoc( + font: font, + bold: bold, + shopName: '测试酒行', + title: '出 库 单', + orderNo: 'CK202503010002', + noPrefix: 'NO.', + partnerLabel: '客户名称', + partnerName: '张三', + dateLabel: '出库日期', + orderDate: '2025-03-01', + addressLabel: '送货地址', + warehouseName: '主仓', + signerLabel: '收货人(签字)', + operatorRoleLabel: '销售员', + operatorName: '李四', + makerName: '王五', + remark: null, + showTips: showTips, + rows: rows, + totalQty: totalQty, + totalAmt: totalAmt, ); /// 生成入库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。 @@ -948,10 +1020,7 @@ Future buildStockInOrderPdfImpl( ]); } - final doc = pw.Document(); - doc.addPage(pw.MultiPage( - pageTheme: _orderPageTheme(), - build: (_) => _buildOrderDoc( + return _buildOrderPdf(() => _buildOrderDoc( font: font, bold: bold, shopName: meta.shopName, title: '入 库 单', @@ -972,9 +1041,7 @@ Future buildStockInOrderPdfImpl( rows: rows, totalQty: totalQty, totalAmt: totalAmt, - ), - )); - return doc.save(); + )); } Future printStockInOrderImpl( @@ -1023,10 +1090,7 @@ Future buildStockOutOrderPdfImpl( ]); } - final doc = pw.Document(); - doc.addPage(pw.MultiPage( - pageTheme: _orderPageTheme(), - build: (_) => _buildOrderDoc( + return _buildOrderPdf(() => _buildOrderDoc( font: font, bold: bold, shopName: meta.shopName, title: '出 库 单', @@ -1047,9 +1111,7 @@ Future buildStockOutOrderPdfImpl( rows: rows, totalQty: totalQty, totalAmt: totalAmt, - ), - )); - return doc.save(); + )); } Future printStockOutOrderImpl( diff --git a/client/test/print_order_pagination_test.dart b/client/test/print_order_pagination_test.dart new file mode 100644 index 0000000..5bc6c6a --- /dev/null +++ b/client/test/print_order_pagination_test.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:jiu_client/core/utils/print_util_stub.dart'; + +/// 出/入库单打印分页回归测试。 +/// +/// 背景:1.0.83 的打印改版用「非可拆分 Container 包裹整块内容」,当明细超过一页 +/// 时 MultiPage 无法拆分、分页算法不前进 → 无限加页、内存暴涨 → 打印卡死。 +/// 1.0.86 改为三段结构(前/后两段短原子 Container + 可拆分明细表,竖线由内容 +/// 自身携带),明细表可跨页拆分,无「原子大组件超页」,正常分页且竖线尾页贴合 +/// 内容。本测试锁定该行为,防止再退回卡死实现。 +void main() { + late pw.Font font; + + setUpAll(() { + // 直接从仓库内字体文件加载(无需 Flutter asset bundle / binding)。 + final bytes = + File('assets/fonts/NotoSansSC-Regular.ttf').readAsBytesSync(); + font = pw.Font.ttf(bytes.buffer.asByteData()); + }); + + List> makeRows(int n) => [ + for (var i = 0; i < n; i++) + [ + 'ZXZ0112$i', + '茅台金贵宾2011', + '普通/53度', + '500ml/单品', + '2010-32/07852', + '2026-01-19', + '1', + '1000.00', + '1000.00', + '', + ], + ]; + + Future pageCount(int rows) => debugOrderDocPageCount( + () => debugBuildOrderDoc( + font: font, + bold: font, + rows: makeRows(rows), + totalQty: rows.toDouble(), + totalAmt: rows * 1000.0, + ), + ); + + test('单页:少量明细生成 1 页', () async { + expect(await pageCount(1), 1); + expect(await pageCount(3), 1); + }); + + test('跨页:明细超过一页能正常分页且不卡死(回归 1.0.84 打印卡死)', () async { + // 60 行远超一页:旧实现会在此无限加页→卡死。新实现应正常分页到多页。 + // 用 timeout 兜底——若退回卡死实现,此处会超时失败而非整个测试进程挂死。 + final pages = await pageCount(60); + expect(pages, greaterThan(1)); + }, timeout: const Timeout(Duration(seconds: 30))); + + test('明细行数越多页数单调不减(分页稳定推进,不会原地打转)', () async { + final p20 = await pageCount(20); + final p120 = await pageCount(120); + expect(p120, greaterThanOrEqualTo(p20)); + expect(p120, greaterThan(1)); + }, timeout: const Timeout(Duration(seconds: 45))); +}