Compare commits

..

6 Commits

Author SHA1 Message Date
wangjia 90e6b25cec chore: release client-v1.1.14
Deploy Client / build-windows (push) Successful in 1m56s
Deploy Client / build-client-web (push) Successful in 5m0s
Deploy Client / build-macos (push) Successful in 7m24s
Deploy Client / build-android (push) Successful in 1m28s
Deploy Client / build-ios (push) Successful in 6m13s
Deploy Client / release-deploy-client (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 02:31:29 +08:00
wangjia 1655769827 chore(client): AppConfig 默认连生产 jiu.51yanmei.com
BASE_URL/PUBLIC_URL/ABOUT_URL 默认值改为线上,flutter run 不带
--dart-define 即连生产;需本地后端时显式 --dart-define 覆盖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 02:28:56 +08:00
wangjia 46302d9e4a feat(client): 标签名下改两配对行居中(编号+度数/规格+日期)
保留深蓝表头+Code128 条码+位图字体+二维码外壳;品名与副字段
水平居中,行1=编号值+度数、行2=规格+日期,去掉「编号」前缀。
覆盖 canvas 预览/热敏栅格/PDF/web 四条渲染路径 + 原型 devices.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 02:28:51 +08:00
wangjia d299d4b0b0 fix(client): 删除价签底部米色页脚,二维码/条码放大填满纸面
原型与热敏/PDF/Web 三路渲染同步去掉标签底部那条印着「40×20mm」的
米色横条(打印时深色字会印到纸上,属冗余),主体直抵纸底:

- 原型 devices.html:删 .lbl-ftr CSS+DOM,.lbl-mid flex:1 自动填充
- Canvas 预览:删米色页脚绘制块+cFoot 常量,bodyBot 145→160
- 热敏 TSPL 原生:qrW/qrH 104→118、qrX 208→194、qrY 28→33,
  条码 kBarH 28→34、kBarY 102→118
- PDF:删 footerH 常量+页脚 Container,Expanded 主体撑满
- Web:删 .ftr CSS+div,.middle flex:1 填充

门禁:analyze 0 error / test 356 通过 / DS 原型 12 道 / L1 同源 / 颜色单源 全绿

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 00:15:21 +08:00
wangjia 848a9ee2c6 ci(deploy-client): iOS 上传加超时看门狗 + iOS 失败不阻断发版
Deploy Client / build-windows (push) Successful in 3m58s
Deploy Client / build-client-web (push) Successful in 6m17s
Deploy Client / build-macos (push) Successful in 13m39s
Deploy Client / build-android (push) Successful in 7m17s
Deploy Client / build-ios (push) Successful in 6m42s
Deploy Client / release-deploy-client (push) Successful in 11m50s
1.1.13 发版事故复盘:build-ios 的 altool 上传静默卡死数分钟无输出,超
runner 僵尸超时被 SIGKILL 判失败(软失败 else 分支没机会跑);且 build-ios
被 release-deploy 硬 needs → 连带 web/mac/win/android 整体没部署。

- compile-ios.sh: altool 上传套后台看门狗(本机无 timeout/coreutils),超
  420s 先 TERM 再 KILL,保证永远在僵尸窗口内干净收尾,失败只告警不阻断。
- deploy-client.yml: build-ios 加 continue-on-error;release-deploy 改
  if: always() 只校验 4 个真实平台成功即部署,iOS 真正不阻塞。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-27 22:41:28 +08:00
wangjia 7025ccd1b5 chore: release client-v1.1.13
Deploy Client / build-windows (push) Successful in 4m43s
Deploy Client / build-client-web (push) Successful in 6m22s
Deploy Client / build-macos (push) Successful in 7m36s
Deploy Client / build-android (push) Successful in 7m54s
Deploy Client / build-ios (push) Failing after 13m2s
Deploy Client / release-deploy-client (push) Has been skipped
扫码出库免回车(120ms 防抖自动加入明细)+ 热敏标签重写为光栅化,
消除字体重叠/与预览失真/底部重复店名(原型+画布+PDF+Web 同步)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-27 21:48:47 +08:00
8 changed files with 239 additions and 355 deletions
+11
View File
@@ -98,6 +98,9 @@ jobs:
build-ios: build-ios:
needs: build-android needs: build-android
runs-on: mac runs-on: mac
# iOS TestFlight 上传易抖/易卡,按 CLAUDE.md「iOS 不阻塞发版」:本 job 失败
# 不拖垮整个 run,更不阻断下面的 release-deploy(它已改为只校验 4 个真实平台)。
continue-on-error: true
env: env:
PUB_HOSTED_URL: https://pub.flutter-io.cn PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
@@ -159,6 +162,14 @@ jobs:
release-deploy-client: release-deploy-client:
needs: [build-client-web, build-macos, build-android, build-ios, build-windows] needs: [build-client-web, build-macos, build-android, build-ios, build-windows]
# iOS(build-ios)故意可失败:只要 4 个真实平台成功就照常发布部署。
# always() 保证 build-ios 失败时本 job 不被跳过;再逐个校验其余平台必须成功。
if: >-
${{ always()
&& needs.build-client-web.result == 'success'
&& needs.build-macos.result == 'success'
&& needs.build-android.result == 'success'
&& needs.build-windows.result == 'success' }}
runs-on: mac runs-on: mac
env: env:
GOPROXY: https://goproxy.cn,direct GOPROXY: https://goproxy.cn,direct
+14
View File
@@ -5,6 +5,20 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.14] - 2026-08-28
### 改进
- 商品价签(标签)版式优化:品名与下方字段在左栏居中排布,编号+度数一行、规格+日期一行,更整齐易读。
- 去掉价签底部多余的米色页脚,一维条码与二维码放大填满纸面,扫码更清晰。
## [1.1.13] - 2026-08-27
### 改进
- 扫码出库免按回车:扫码枪扫完商品二维码后自动加入明细,无需再手动敲一下回车。
### 修复
- 修复热敏打印机(得力等 TSPL 标签机)打印商品价签时字体重叠、且底部多出一行酒行名称的问题;现在热敏打印效果与屏幕预览完全一致,不再失真。
## [1.1.12] - 2026-08-27 ## [1.1.12] - 2026-08-27
### 改进 ### 改进
+6 -8
View File
@@ -1,22 +1,20 @@
/// 集中管理应用配置,通过 --dart-define=KEY=value 注入环境变量 /// 集中管理应用配置,通过 --dart-define=KEY=value 注入环境变量
/// ///
/// 开发默认值http://localhost:8080 /// 默认值 = 线上服务(jiu.51yanmei.com):本地 flutter run 不带 define 也连生产。
/// 生产部署示例flutter run \ /// 需连本地后端时显式覆盖flutter run \
/// --dart-define=BASE_URL=http://192.168.1.100:8080 \ /// --dart-define=BASE_URL=http://localhost:8080 \
/// --dart-define=SHOP_NAME=强朋友名酒行 \ /// --dart-define=PUBLIC_URL=http://localhost:8081
/// --dart-define=SHOP_ADDRESS=贵州省贵阳市云岩区中华北路88号 \
/// --dart-define=SHOP_PHONE=0851-12345678
class AppConfig { class AppConfig {
const AppConfig._(); const AppConfig._();
static const _baseUrl = String.fromEnvironment( static const _baseUrl = String.fromEnvironment(
'BASE_URL', 'BASE_URL',
defaultValue: 'http://localhost:8080', defaultValue: 'https://jiu.51yanmei.com',
); );
static const _publicUrl = String.fromEnvironment( static const _publicUrl = String.fromEnvironment(
'PUBLIC_URL', 'PUBLIC_URL',
defaultValue: 'http://localhost:8081', defaultValue: 'https://jiu.51yanmei.com',
); );
static const shopName = String.fromEnvironment( static const shopName = String.fromEnvironment(
+152 -312
View File
@@ -4,7 +4,6 @@ import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'dart:ffi'; import 'dart:ffi';
import 'package:barcode/barcode.dart' as bc; import 'package:barcode/barcode.dart' as bc;
import 'package:charset_converter/charset_converter.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart' import 'package:win32/win32.dart'
show show
@@ -15,8 +14,7 @@ import 'package:win32/win32.dart'
EndPagePrinter, EndPagePrinter,
EndDocPrinter, EndDocPrinter,
ClosePrinter, ClosePrinter,
DOC_INFO_1, DOC_INFO_1;
WideCharToMultiByte;
import 'package:flutter/foundation.dart' import 'package:flutter/foundation.dart'
show kIsWeb, debugPrint, visibleForTesting; show kIsWeb, debugPrint, visibleForTesting;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -49,8 +47,8 @@ String _fileTs() {
// ── 设计色彩 token ────────────────────────────────────────────────────────── // ── 设计色彩 token ──────────────────────────────────────────────────────────
const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip 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 _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 抬头反白店名
const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 footer text const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 编号 / 三字段小字
const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)──────── // ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
@@ -215,19 +213,28 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
return size; return size;
} }
/// 组装标签底部平铺的三字段:度数(型号) / 规格(版本) / 生产日期。 /// 组装标签左列名下两行副字段(居中排布):
/// 各值为空则跳过;日期截断到 10 字符(YYYY-MM-DD)。四路渲染共用,口径一致。 /// 行1 = 编号 + 度数(型号) 行2 = 规格(版本) + 生产日期
/// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
/// 各字段为空则跳过(该行可只剩一个字段或整行消失);日期截断到 10 字符。四路渲染共用。
List<String> _labelSubFields( List<String> _labelSubFields(
String? series, String? spec, String? productionDate) { String code, String? series, String? spec, String? productionDate) {
final date = (productionDate ?? '').isNotEmpty final date = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? (productionDate!.length > 10
? productionDate.substring(0, 10) ? productionDate.substring(0, 10)
: productionDate) : productionDate)
: ''; : '';
return [ final line1 = [
if (code.isNotEmpty) code,
if ((series ?? '').isNotEmpty) series!, if ((series ?? '').isNotEmpty) series!,
].join(' ');
final line2 = [
if ((spec ?? '').isNotEmpty) spec!, if ((spec ?? '').isNotEmpty) spec!,
if (date.isNotEmpty) date, if (date.isNotEmpty) date,
].join(' ');
return [
if (line1.isNotEmpty) line1,
if (line2.isNotEmpty) line2,
]; ];
} }
@@ -238,8 +245,10 @@ Future<ui.Image> _renderLabelBitmap({
required String shop, required String shop,
required String name, required String name,
required String code, required String code,
required List<String> subs, // 底部平铺字段:度数 / 规格 / 日期(已过滤空) required List<String> subs, // 左列逐行副字段:型号规格 / 日期(已过滤空)
required Uint8List qrBytes, required Uint8List qrBytes,
bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
}) async { }) async {
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸) const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const scale = 2; // 2× 超采样,内部以 640×320 绘制 const scale = 2; // 2× 超采样,内部以 640×320 绘制
@@ -253,10 +262,9 @@ Future<ui.Image> _renderLabelBitmap({
// 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。 // 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头 const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 米色页脚 / 头部字色 const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 抬头店名字色
const cInk = ui.Color(0xFF111111); // ds-ignore 正文 const cInk = ui.Color(0xFF111111); // ds-ignore 正文
const cCode = ui.Color(0xFF333333); // ds-ignore 编号 / 三字段 const cCode = ui.Color(0xFF333333); // ds-ignore 编号 / 三字段
const cFoot = ui.Color(0xFF5A4E35); // ds-ignore 页脚字
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()), canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = cWhite); ui.Paint()..color = cWhite);
@@ -267,176 +275,75 @@ Future<ui.Image> _renderLabelBitmap({
ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy); ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy);
_drawText(canvas, shop, 10, 5, 12, w - 20, bold: true, color: cCream); _drawText(canvas, shop, 10, 5, 12, w - 20, bold: true, color: cCream);
// ── 米色页脚 ──────────────────────────────────────────────── // ── 主体两列布局(无页脚:主体直抵纸底,2026-08-27 删掉底部米色尺寸条)────────
const footerH = 15.0;
const footerTop = h - footerH; // 145
canvas.drawRect(ui.Rect.fromLTWH(0, footerTop, w.toDouble(), footerH),
ui.Paint()..color = cCream);
_drawText(canvas, shop, 10, footerTop + 3.5, 8, 180, color: cFoot);
final szW = _measureWidth('40×20mm', 8, false);
_drawText(canvas, '40×20mm', w - 10 - szW, footerTop + 3.5, 8, szW,
color: cFoot);
// ── 主体两列布局 ────────────────────────────────────────────
const bodyTop = headerH; // 24 const bodyTop = headerH; // 24
const bodyBot = footerTop; // 145 const double bodyBot = 160.0; // 直抵纸底 h(原到页脚顶 145),二维码/条码顺势放大
const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间 const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间
const double colGap = 6.0; // 左列与二维码列间距(原 10,收小) const double colGap = 6.0; // 左列与二维码列间距(原 10,收小)
// 右列:二维码占主体高(正方形) // 右列:二维码略放大,占主体近满高(正方形)2026-08-28 用户口径「二维码调大一点」
const double qrSize = (bodyBot - bodyTop) - 2 * padV; // 109 const double qrPad = 1.0;
const double qrTop = bodyTop + padV; // 30 const double qrSize = (bodyBot - bodyTop) - 2 * qrPad; // 134
const double qrLeft = w - padH - qrSize; // 203 const double qrTop = bodyTop + qrPad; // 25
try { const double qrLeft = w - padH - qrSize; // 178
final codec = await ui.instantiateImageCodec(qrBytes); if (drawQr) {
final qrImg = (await codec.getNextFrame()).image; try {
canvas.drawImageRect( final codec = await ui.instantiateImageCodec(qrBytes);
qrImg, final qrImg = (await codec.getNextFrame()).image;
ui.Rect.fromLTWH(0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()), canvas.drawImageRect(
const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize), qrImg,
ui.Paint()); ui.Rect.fromLTWH(
} catch (e) { 0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
debugPrint('[label] QR 解码失败: $e'); const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize),
ui.Paint());
} catch (e) {
debugPrint('[label] QR 解码失败: $e');
}
} }
// 左列几何 // 左列几何
const double leftX = padH; // 8 const double leftX = padH; // 8
const double leftRight = qrLeft - colGap; // 197 const double leftRight = qrLeft - colGap; // 172
const double leftW = leftRight - leftX; // 189 const double leftW = leftRight - leftX; // 164
// 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 // 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库
const double barH = 40.0; const double barH = 40.0;
const double barBottom = bodyBot - padV; // 137 const double barBottom = bodyBot - padV; // 154
const double barTop = barBottom - barH; // 97 const double barTop = barBottom - barH; // 114
_drawBarcode(canvas, code, leftX, barTop, leftW, barH); if (drawBarcode) _drawBarcode(canvas, code, leftX, barTop, leftW, barH);
// 左列文字块品名+编号 一行居中;度数/规格/日期 平铺一行),在条码上方居中 // 左列文字块品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体水平居中
const double infoTop = bodyTop + padV; // 32 // 竖直均分。2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
const double infoBot = barTop - 4; // 93 const double infoTop = bodyTop + padV; // 30
const double infoH = infoBot - infoTop; // 61 const double infoBot = barTop - 4; // 110
const double infoH = infoBot - infoTop; // 80
// 品名自适应字号(为编号留出约 28% 宽度),上限 17 const double kSmallSize = 12.0; // 编号+度数 / 规格+日期
final double nameSize = final double nameSize =
_fitFont(name, leftW * 0.72, 1000, true, 17.0).clamp(11.0, 17.0); _fitFont(name, leftW, 1000, true, 16.0).clamp(11.0, 16.0);
const double codeSize = 10.0; final rows = <(String, double, bool)>[
const double subSize = 10.0; if (name.isNotEmpty) (name, nameSize, true), // 品名粗体
final double nameLineH = nameSize * 1.2; for (final s in subs) (s, kSmallSize, false), // 编号+度数 / 规格+日期
const double subLineH = subSize * 1.2; ];
const double interGap = 8.0; const double kLineH = 1.3; // 行高倍数
final double blockH = nameLineH + interGap + subLineH; final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
double blockTop = infoTop + (infoH - blockH) / 2; final int n = rows.length;
if (blockTop < infoTop) blockTop = infoTop; final double rawGap = n > 0 ? (infoH - totalTextH) / (n + 1) : 2.0;
final double gap = rawGap < 1.0 ? 1.0 : rawGap;
// 品名 + 编号:整体水平居中,基线对齐 double yy = infoTop + gap;
final double nameW = _measureWidth(name, nameSize, true); for (final r in rows) {
final bool hasCode = code.isNotEmpty; _drawText(canvas, r.$1, leftX, yy, r.$2, leftW,
const double codeGap = 9.0; bold: r.$3, color: r.$3 ? cInk : cCode, center: true);
final double codeW = hasCode ? _measureWidth(code, codeSize, false) : 0; yy += r.$2 * kLineH + gap;
final double groupW = nameW + (hasCode ? codeGap + codeW : 0);
double gx = leftX + (leftW - groupW) / 2;
if (gx < leftX) gx = leftX;
_drawText(canvas, name, gx, blockTop, nameSize, leftW,
bold: true, color: cInk);
if (hasCode) {
_drawText(canvas, code, gx + nameW + codeGap,
blockTop + (nameSize - codeSize) * 0.82, codeSize, codeW + 4,
color: cCode);
}
// 三字段平铺一行(space-between,两侧留内边距使外白 > 中白)
final double subY = blockTop + nameLineH + interGap;
const double innerPad = 10.0; // 对齐原型 .lbl-sub padding:0 10px
const double innerLeft = leftX + innerPad; // 18
const double innerRight = leftRight - innerPad; // 187
const double innerW = innerRight - innerLeft; // 169
if (subs.length == 1) {
final double sw0 = _measureWidth(subs[0], subSize, false);
_drawText(
canvas, subs[0], innerLeft + (innerW - sw0) / 2, subY, subSize, innerW,
color: cCode);
} else if (subs.isNotEmpty) {
// 字段间必须留可见最小间隔;总宽超限时按比例缩字号,避免贴死
const double minGap = 5.0;
double fs = subSize;
double sumW = subs.fold(
0.0, (s, x) => s + _measureWidth(x, fs, false));
final double avail = innerW - minGap * (subs.length - 1);
if (sumW > avail && sumW > 0) {
fs = (subSize * avail / sumW).clamp(7.0, subSize);
sumW = subs.fold(0.0, (s, x) => s + _measureWidth(x, fs, false));
}
final double gap = subs.length > 1
? ((innerW - sumW) / (subs.length - 1)).clamp(minGap, innerW)
: 0.0;
double cx = innerLeft;
for (int i = 0; i < subs.length; i++) {
final double wi = _measureWidth(subs[i], fs, false);
_drawText(canvas, subs[i], cx, subY, fs, wi + 4, color: cCode);
cx += wi + gap;
}
} }
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320 return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
} }
/// UTF-8 → GBK 字节: /// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与
/// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件; /// 预览画布 100% 一致——彻底消除内置点阵字(TSS16/24)与预览 TrueType 度量不一致
/// macOS 走 charset_converterCoreFoundation 原生支持 GBK)。 /// 导致的字体重叠 / 失真。文字/抬头走光栅;条码(Code128)与二维码仍走原生
Uint8List _encodeGbk(String text) { /// BARCODE / BITMAP 命令保证扫码可靠(光栅化后细条/密码易糊)。
if (text.isEmpty) return Uint8List(0);
if (Platform.isWindows) {
final wideStr = text.toNativeUtf16();
try {
final needed = WideCharToMultiByte(
936, 0, wideStr, -1, nullptr, 0, nullptr, nullptr);
if (needed <= 1) return Uint8List(0);
final buf = calloc<Uint8>(needed);
try {
WideCharToMultiByte(
936, 0, wideStr, -1, buf.cast(), needed, nullptr, nullptr);
return Uint8List.fromList(buf.asTypedList(needed - 1)); // 不含 null 终止符
} finally {
calloc.free(buf);
}
} finally {
calloc.free(wideStr);
}
}
// macOS: charset_converter(同步 CoreFoundation
// encode() 在 macOS 上是同步 channel 调用,此处以 Future.value 包装保持接口一致
throw UnsupportedError('call _encodeGbkAsync on macOS');
}
Future<Uint8List> _encodeGbkAsync(String text) async {
if (text.isEmpty) return Uint8List(0);
if (Platform.isWindows) return _encodeGbk(text);
return CharsetConverter.encode('GBK', text);
}
/// 估算 TSPL 内置点阵字体下一行文本的像素宽度(dots):
/// 全角(CJK) 字符宽 = 字高;半角(ASCII) 宽 = 字高/2。用于居中 / 平铺定位。
int _estTsplWidth(String s, int fontH) {
var w = 0;
for (final r in s.runes) {
w += r > 0x7F ? fontH : (fontH ~/ 2);
}
return w;
}
/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。
/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。
Future<void> _addTextCmd(
BytesBuilder buf, int x, int y, String font, String text) async {
if (text.isEmpty) return;
final gbk = await _encodeGbkAsync(text);
buf.add(ascii.encode('TEXT $x,$y,"$font",0,1,1,"'));
buf.add(gbk);
buf.add(ascii.encode('"\r\n'));
}
/// 桌面端把标签以 TSPL TEXT(内置中文点阵字体)+ QR BITMAP 混合方式裸发到热敏机。
/// TEXT 走 TSS24.BF2/TSS16.BF2GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。
/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。 /// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。 /// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({ Future<bool> _printFlatLabelThermal({
@@ -452,23 +359,23 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer'); debugPrint('[label] thermal printer = $printer');
if (printer == null) return false; if (printer == null) return false;
// 版面(320×160 dotsDIRECTION 1):深蓝头/米色脚在单色热敏上分别退化为 const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
// 「黑底反白店名」「白底黑字页脚」;主体两列:左=文字+条码,右=二维码占整高。 const wBytes = (w + 7) >> 3; // 40 bytes/row
const headerH = 26; // 头部黑带高
const bodyTop = headerH; // 26
const bodyBot = 134; // 主体下沿(页脚之上)
// 右列二维码:占主体整高(正方形),右对齐(放大,占满主体高度) // ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)─────────
const qrW = 104, qrH = 104; // 画布内部 2× 超采样(640×320),此处 2×2 盒式降采样 + 亮度 128 阈值二值化,边缘更干净。
const qrX = 320 - 8 - qrW; // 208 final img = await _renderLabelBitmap(
const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 28 shop: shop,
const qrWBytes = (qrW + 7) >> 3; // 13 bytes/row name: name,
code: code,
final codec = await ui.instantiateImageCodec(qrBytes, subs: subs,
targetWidth: qrW, targetHeight: qrH); qrBytes: qrBytes,
final qrImg = (await codec.getNextFrame()).image; drawBarcode: false,
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba); drawQr: false,
final qrRgba = qrBd!.buffer.asUint8List(); );
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
final iw = img.width; // 640
final buf = BytesBuilder(); final buf = BytesBuilder();
buf.add(ascii.encode( buf.add(ascii.encode(
@@ -476,22 +383,43 @@ Future<bool> _printFlatLabelThermal({
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n', 'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
)); ));
// ── 深蓝店名头(黑底反白):先画黑字店名,再 REVERSE 整条头部 → 白字黑底 ── // 整幅文字位图(mode 0 覆写整版):深蓝抬头→黑底反白,其余白底黑字,
await _addTextCmd(buf, 8, 5, 'TSS16.BF2', shop); // 均由亮度阈值自然还原,无需 REVERSE;bit 0=黑点,默认 0xFF=白。
buf.add(ascii.encode('REVERSE 0,0,320,$headerH\r\n')); buf.add(ascii.encode('BITMAP 0,0,$wBytes,$h,0,'));
for (int y = 0; y < h; y++) {
final row = Uint8List(wBytes)..fillRange(0, wBytes, 0xFF);
for (int x = 0; x < w; x++) {
int lum = 0;
for (int dy = 0; dy < 2; dy++) {
for (int dx = 0; dx < 2; dx++) {
final i = ((y * 2 + dy) * iw + (x * 2 + dx)) * 4;
final a = rgba[i + 3];
lum += a < 128
? 255
: (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
.round();
}
}
if (lum ~/ 4 < 128) row[x >> 3] &= ~(0x80 >> (x & 7));
}
buf.add(row);
}
buf.add(ascii.encode('\r\n'));
// ── 米色页脚(白底黑字):店名 + 尺寸 ── // ── 右列二维码(原生 BITMAP,避免光栅降采样糊码):占主体整高、右对齐 ──
const footerY = 140; const qrW = 130, qrH = 130; // 2026-08-28 放大,对齐画布 QR134
await _addTextCmd(buf, 8, footerY, 'TSS16.BF2', shop); const qrX = 320 - 8 - qrW; // 182
const sizeStr = '40×20mm'; const bodyTop = 24, bodyBot = 160; // 无页脚:主体直抵纸底
final sizeW = _estTsplWidth(sizeStr, 16); const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 27
await _addTextCmd( const qrWBytes = (qrW + 7) >> 3; // 13
buf, (320 - 8 - sizeW).clamp(8, 312), footerY, 'TSS16.BF2', sizeStr); final qrCodec = await ui.instantiateImageCodec(qrBytes,
targetWidth: qrW, targetHeight: qrH);
// ── 右列二维码 BITMAP ── final qrImg = (await qrCodec.getNextFrame()).image;
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
final qrRgba = qrBd!.buffer.asUint8List();
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,')); buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
for (int yy = 0; yy < qrH; yy++) { for (int yy = 0; yy < qrH; yy++) {
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白 final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF);
for (int xx = 0; xx < qrW; xx++) { for (int xx = 0; xx < qrW; xx++) {
final i = (yy * qrW + xx) * 4; final i = (yy * qrW + xx) * 4;
final a = qrRgba[i + 3]; final a = qrRgba[i + 3];
@@ -504,14 +432,10 @@ Future<bool> _printFlatLabelThermal({
} }
buf.add(ascii.encode('\r\n')); buf.add(ascii.encode('\r\n'));
// 左列几何(与二维码列间距收小到 6 // ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),narrow 自适应铺满居中 ──
const leftX = 8, const leftX = 8, leftRight = qrX - 6, leftW = leftRight - leftX; // 8..188, 180
leftRight = qrX - 6, const kBarH = 34;
leftW = leftRight - leftX; // 8..202, 194 const kBarY = bodyBot - 8 - kBarH; // 118
// ── 左列底部整幅一维条码(Code128,内容=商品编号),narrow 自适应铺满并居中 ──
const kBarH = 28;
const kBarY = bodyBot - 4 - kBarH; // 102
if (code.isNotEmpty) { if (code.isNotEmpty) {
final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验) final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验)
final narrow = (leftW / estModules).floor().clamp(2, 4); final narrow = (leftW / estModules).floor().clamp(2, 4);
@@ -521,50 +445,10 @@ Future<bool> _printFlatLabelThermal({
'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n')); 'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n'));
} }
// ── 左列文字块:品名+编号 一行居中;度数/规格/日期 平铺一行;整体在条码上方居中 ──
const blockTop = bodyTop, blockBot = kBarY - 4; // 26..98
final nameFont = name.runes.length <= 7 ? 'TSS24.BF2' : 'TSS16.BF2';
final nameH = nameFont == 'TSS24.BF2' ? 24 : 16;
const subH = 16, kGap = 8;
final blockH = nameH + kGap + subH;
var nameY = blockTop + ((blockBot - blockTop) - blockH) ~/ 2;
if (nameY < blockTop) nameY = blockTop;
final subY = nameY + nameH + kGap;
// 品名 + 编号:整体水平居中,编号小字随后,基线对齐
final nameW = _estTsplWidth(name, nameH);
final codeW = code.isNotEmpty ? _estTsplWidth(code, 16) : 0;
final groupW = nameW + (code.isNotEmpty ? kGap + codeW : 0);
var nameX = leftX + (leftW - groupW) ~/ 2;
if (nameX < leftX) nameX = leftX;
await _addTextCmd(buf, nameX, nameY, nameFont, name);
if (code.isNotEmpty) {
await _addTextCmd(
buf, nameX + nameW + kGap, nameY + (nameH - subH), 'TSS16.BF2', code);
}
// 三字段平铺一行(space-between,两侧留内边距使外白 > 中白)
const innerPad = 10; // 对齐原型 .lbl-sub padding:0 10px
const innerLeft = leftX + innerPad, innerW = leftW - 2 * innerPad; // 18, 174
if (subs.length == 1) {
final w0 = _estTsplWidth(subs[0], subH);
await _addTextCmd(
buf, innerLeft + (innerW - w0) ~/ 2, subY, 'TSS16.BF2', subs[0]);
} else if (subs.isNotEmpty) {
final widths = subs.map((s) => _estTsplWidth(s, subH)).toList();
final sumW = widths.fold(0, (s, x) => s + x);
final gap = (innerW - sumW) / (subs.length - 1);
var cx = innerLeft.toDouble();
for (int i = 0; i < subs.length; i++) {
await _addTextCmd(buf, cx.round(), subY, 'TSS16.BF2', subs[i]);
cx += widths[i] + gap;
}
}
buf.add(ascii.encode('PRINT 1,1\r\n')); buf.add(ascii.encode('PRINT 1,1\r\n'));
final tspl = buf.toBytes(); final tspl = buf.toBytes();
debugPrint('[label] TEXT+BITMAP tspl ${tspl.length}B -> $printer'); debugPrint('[label] raster BITMAP+QR+BARCODE tspl ${tspl.length}B -> $printer');
await _sendRaw(printer, tspl); await _sendRaw(printer, tspl);
return true; return true;
} }
@@ -592,10 +476,9 @@ Future<void> printProductLabelImpl({
const labelW = 40.0 * PdfPageFormat.mm; const labelW = 40.0 * PdfPageFormat.mm;
const labelH = 20.0 * PdfPageFormat.mm; const labelH = 20.0 * PdfPageFormat.mm;
const headerH = labelH * 0.20; const headerH = labelH * 0.20;
const footerH = labelH * 0.11;
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致) // 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致)
final subs = _labelSubFields(series, spec, productionDate); final subs = _labelSubFields(code, series, spec, productionDate);
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印) // 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal( if (await _printFlatLabelThermal(
@@ -649,55 +532,34 @@ Future<void> printProductLabelImpl({
child: pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch, crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [ children: [
// 文字块品名+编号 居中 / 三字段平铺),条码上方居中 // 文字块品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体居中
// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
pw.Expanded( pw.Expanded(
child: pw.Column( child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center, mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.stretch, crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [ children: [
// 品名 + 编号(整体居中,编号小字随后) pw.Text(name,
pw.RichText( textAlign: pw.TextAlign.center,
textAlign: pw.TextAlign.center, maxLines: 2,
maxLines: 1, overflow: pw.TextOverflow.clip,
overflow: pw.TextOverflow.clip, style: pw.TextStyle(
text: pw.TextSpan(children: [ font: font,
pw.TextSpan( fontSize: nameFs,
text: name, fontWeight: pw.FontWeight.bold,
style: pw.TextStyle( color: _ink,
font: font, letterSpacing: 0.3)),
fontSize: nameFs, for (final s in subs) ...[
fontWeight: pw.FontWeight.bold, pw.SizedBox(height: 2),
color: _ink, pw.Text(s,
letterSpacing: 0.3)), textAlign: pw.TextAlign.center,
if (code.isNotEmpty) maxLines: 1,
pw.TextSpan( overflow: pw.TextOverflow.clip,
text: ' $code', style: pw.TextStyle(
style: pw.TextStyle( font: font,
font: font, fontSize: 5,
fontSize: 5, color: _footnote)),
color: _footnote)), ],
]),
),
pw.SizedBox(height: 4),
// 三字段平铺一行(两侧留边距,外白 > 中白)
if (subs.isNotEmpty)
pw.Padding(
padding: const pw.EdgeInsets.symmetric(
horizontal: 4),
child: pw.Row(
mainAxisAlignment: subs.length == 1
? pw.MainAxisAlignment.center
: pw.MainAxisAlignment.spaceBetween,
children: [
for (final s in subs)
pw.Text(s,
style: pw.TextStyle(
font: font,
fontSize: 5,
color: _footnote)),
],
),
),
], ],
), ),
), ),
@@ -727,29 +589,6 @@ Future<void> printProductLabelImpl({
), ),
), ),
), ),
// ── Footer:米色页脚(店名 + 尺寸)────────────────────────────────────
pw.Container(
height: footerH,
color: _cream,
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(shopName,
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font, fontSize: 3.5, color: _footnote)),
),
pw.Text('40×20mm',
style: pw.TextStyle(
font: font, fontSize: 3.5, color: _footnote)),
],
),
),
], ],
), ),
)); ));
@@ -777,7 +616,8 @@ Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
shop: label.shopName, shop: label.shopName,
name: label.name, name: label.name,
code: label.code, code: label.code,
subs: _labelSubFields(label.series, label.spec, label.productionDate), subs: _labelSubFields(
label.code, label.series, label.spec, label.productionDate),
qrBytes: label.qrBytes!, qrBytes: label.qrBytes!,
); );
final bd = await img.toByteData(format: ui.ImageByteFormat.png); final bd = await img.toByteData(format: ui.ImageByteFormat.png);
+17 -21
View File
@@ -41,13 +41,21 @@ Future<void> printProductLabelImpl({
? productionDate.substring(0, 10) ? productionDate.substring(0, 10)
: productionDate) : productionDate)
: ''; : '';
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、PDF、TSPL 一致) // 左列名下两行副字段(居中):行1=编号+度数、行2=规格+日期(口径与预览、PDF、TSPL 一致)
final subs = <String>[ // 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
final line1 = [
if (code.isNotEmpty) code,
if (seriesVal.isNotEmpty) seriesVal, if (seriesVal.isNotEmpty) seriesVal,
].join(' ');
final line2 = [
if (specVal.isNotEmpty) specVal, if (specVal.isNotEmpty) specVal,
if (dateVal.isNotEmpty) dateVal, if (dateVal.isNotEmpty) dateVal,
].join(' ');
final subs = <String>[
if (line1.isNotEmpty) line1,
if (line2.isNotEmpty) line2,
]; ];
final subSpans = subs.map((s) => '<span>$s</span>').join(); final subLines = subs.map((s) => '<div class="sub">$s</div>').join();
// 商品名过长时缩小字号 // 商品名过长时缩小字号
final nameLen = name.runes.length; final nameLen = name.runes.length;
final nameFontPt = nameLen > 10 ? 7.0 : (nameLen > 7 ? 8.0 : 9.0); final nameFontPt = nameLen > 10 ? 7.0 : (nameLen > 7 ? 8.0 : 9.0);
@@ -98,32 +106,22 @@ body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; } .left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.info { .info {
flex: 1; min-height: 0; display: flex; flex-direction: column; flex: 1; min-height: 0; display: flex; flex-direction: column;
justify-content: center; gap: 1mm; overflow: hidden; justify-content: center; align-items: center; gap: 0.6mm; overflow: hidden;
} }
.pname { .pname {
font-weight: 700; text-align: center; line-height: 1.1; font-weight: 700; text-align: center; line-height: 1.1; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.pcode { font-size: 5pt; font-weight: 400; color: #333; margin-left: 1.2mm; }
.sub { .sub {
font-size: 5pt; color: #333; display: flex; font-size: 5pt; color: #333; line-height: 1.15; text-align: center;
justify-content: space-between; gap: 1mm; padding: 0 1.2mm; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.sub span { white-space: nowrap; }
.bc { flex-shrink: 0; height: 4.6mm; } .bc { flex-shrink: 0; height: 4.6mm; }
.bc svg { width: 100%; height: 100%; display: block; } .bc svg { width: 100%; height: 100%; display: block; }
/* 右:二维码占主体整高(正方形)*/ /* 右:二维码占主体整高(正方形)*/
.qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; } .qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; }
.qr img { width: 100%; height: 100%; object-fit: contain; display: block; } .qr img { width: 100%; height: 100%; object-fit: contain; display: block; }
/* ── Footer:米色页脚(店名 + 尺寸)── */
.ftr {
background: #f4ecd8; height: 2.4mm; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 0 1.2mm;
font-size: 3.4pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
}
</style> </style>
</head> </head>
<body> <body>
@@ -136,8 +134,8 @@ body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
<div class="middle"> <div class="middle">
<div class="left"> <div class="left">
<div class="info"> <div class="info">
<div class="pname" style="font-size:${nameFontPt}pt">$name<span class="pcode">$code</span></div> <div class="pname" style="font-size:${nameFontPt}pt">$name</div>
<div class="sub">$subSpans</div> $subLines
</div> </div>
<div class="bc">$barcodeSvg</div> <div class="bc">$barcodeSvg</div>
</div> </div>
@@ -146,8 +144,6 @@ body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
</div> </div>
</div> </div>
<div class="ftr"><span>$shopName</span><span>40×20mm</span></div>
</div> </div>
<script> <script>
if (document.fonts && document.fonts.ready) { if (document.fonts && document.fonts.ready) {
@@ -185,6 +185,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Map<String, _PickerItem> _codeIndex = {}; Map<String, _PickerItem> _codeIndex = {};
String _lastScanCode = ''; String _lastScanCode = '';
int _lastScanMs = 0; int _lastScanMs = 0;
// 扫码枪=HID键盘极快连打字符后停顿;停顿 120ms 无新输入即自动触发,免手动回车。
Timer? _scanDebounce;
static const _fields = ['qty', 'sale']; static const _fields = ['qty', 'sale'];
@@ -265,6 +267,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
_remarkCtrl.dispose(); _remarkCtrl.dispose();
_partnerFocus.dispose(); _partnerFocus.dispose();
_warehouseFocus.dispose(); _warehouseFocus.dispose();
_scanDebounce?.cancel();
_scanCtrl.dispose(); _scanCtrl.dispose();
_scanFocus.dispose(); _scanFocus.dispose();
for (final item in _items) { for (final item in _items) {
@@ -354,6 +357,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
/// 扫码枪回车触发。解析编码→本地整仓索引命中(已加载项)→未命中再走服务端 /// 扫码枪回车触发。解析编码→本地整仓索引命中(已加载项)→未命中再走服务端
/// 按编码精确查(覆盖库存超本地加载上限 1000 的大仓)→加行/累加数量。 /// 按编码精确查(覆盖库存超本地加载上限 1000 的大仓)→加行/累加数量。
Future<void> _onScan(String raw) async { Future<void> _onScan(String raw) async {
_scanDebounce?.cancel();
// 立即清空并保持聚焦,承接下一次扫码 // 立即清空并保持聚焦,承接下一次扫码
_scanCtrl.clear(); _scanCtrl.clear();
_scanFocus.requestFocus(); _scanFocus.requestFocus();
@@ -697,6 +701,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
controller: _scanCtrl, controller: _scanCtrl,
focusNode: _scanFocus, focusNode: _scanFocus,
hintText: '扫码枪扫码…', hintText: '扫码枪扫码…',
onChanged: (v) {
// 扫码枪整串打完会停顿,停顿 120ms 视为一次完整扫入 → 自动触发。
// 带回车后缀的枪仍走 onSubmitted_onScan 内 300ms 同码去重防重复入单。
_scanDebounce?.cancel();
if (v.trim().isEmpty) return;
_scanDebounce = Timer(
const Duration(milliseconds: 120), () => _onScan(v));
},
onSubmitted: _onScan, onSubmitted: _onScan,
suffix: Icon(LucideIcons.scanLine, suffix: Icon(LucideIcons.scanLine,
size: 16, color: context.tokens.muted), size: 16, color: context.tokens.muted),
+8 -10
View File
@@ -61,22 +61,20 @@
.tpl-desc{font-size:var(--fs-xs); color:var(--muted); margin-top:3px; line-height:1.4;} .tpl-desc{font-size:var(--fs-xs); color:var(--muted); margin-top:3px; line-height:1.4;}
.tpl-acts{display:flex; gap:8px; margin-top:10px;} .tpl-acts{display:flex; gap:8px; margin-top:10px;}
/* ---- 标签预览(40×20mm 热敏价签:抬头 + 商品信息/二维码 + 底部整幅一维条码 + 页脚)---- /* ---- 标签预览(40×20mm 热敏价签:抬头 + 商品信息/二维码 + 底部整幅一维条码,无页脚)----
打印纸面为固定专色(navy 抬头 / cream 页脚 / 墨黑正文),不随 App 主题切换,故按 ds-allow 豁免。 */ 打印纸面为固定专色(navy 抬头 / 墨黑正文),不随 App 主题切换,故按 ds-allow 豁免。 */
.lbl-wrap{display:flex; flex-direction:column; align-items:center; gap:14px;} .lbl-wrap{display:flex; flex-direction:column; align-items:center; gap:14px;}
.lbl-preview{width:340px; height:170px; border:1px solid var(--border); border-radius:var(--r-md); overflow:hidden; display:flex; flex-direction:column; box-shadow:var(--sh-2); background:#fff; color:#111;} /* ds-allow 打印纸面固定色 */ .lbl-preview{width:340px; height:170px; border:1px solid var(--border); border-radius:var(--r-md); overflow:hidden; display:flex; flex-direction:column; box-shadow:var(--sh-2); background:#fff; color:#111;} /* ds-allow 打印纸面固定色 */
.lbl-hdr{height:26px; flex:none; display:flex; align-items:center; padding:0 10px; font-size:13px; font-weight:700; letter-spacing:.05em; background:#1f2a3a; color:#f4ecd8;} /* ds-allow 打印纸面固定尺寸 */ .lbl-hdr{height:26px; flex:none; display:flex; align-items:center; padding:0 10px; font-size:13px; font-weight:700; letter-spacing:.05em; background:#1f2a3a; color:#f4ecd8;} /* ds-allow 打印纸面固定尺寸 */
.lbl-mid{flex:1; min-height:0; display:flex; gap:6px; padding:6px 8px;} .lbl-mid{flex:1; min-height:0; display:flex; gap:6px; padding:6px 8px;}
.lbl-left{flex:1; min-width:0; display:flex; flex-direction:column;} .lbl-left{flex:1; min-width:0; display:flex; flex-direction:column;}
.lbl-info{flex:1; min-height:0; display:flex; flex-direction:column; justify-content:center; gap:9px;} .lbl-info{flex:1; min-height:0; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:5px;}
.lbl-name{font-size:18px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-align:center;} .lbl-name{font-size:18px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-align:center; max-width:100%;} /* ds-allow 打印纸面固定尺寸 */
.lbl-code{font-size:11px; font-weight:400; color:#333; margin-left:10px;} /* ds-allow 打印纸面固定尺寸 */ .lbl-sub{font-size:11px; color:#333; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; line-height:1.15; text-align:center; max-width:100%;} /* ds-allow 打印纸面固定尺寸;名下两行居中 */
.lbl-sub{font-size:11px; color:#333; display:flex; justify-content:space-between; gap:6px; padding:0 10px;} /* ds-allow 打印纸面固定尺寸 */
.lbl-bc{flex:none; height:46px; margin-top:4px;} .lbl-bc{flex:none; height:46px; margin-top:4px;}
.lbl-bc img{width:100%; height:100%; display:block; object-fit:fill;} .lbl-bc img{width:100%; height:100%; display:block; object-fit:fill;}
.lbl-qr{flex:none; height:100%; aspect-ratio:1;} .lbl-qr{flex:none; height:100%; aspect-ratio:1;}
.lbl-qr img{width:100%; height:100%; display:block; object-fit:contain;} .lbl-qr img{width:100%; height:100%; display:block; object-fit:contain;}
.lbl-ftr{height:16px; flex:none; display:flex; align-items:center; justify-content:space-between; padding:0 10px; font-size:9px; background:#f4ecd8; color:#5a4e35;} /* ds-allow 打印纸面固定尺寸 */
.lbl-note{max-width:340px; font-size:var(--fs-sm); color:var(--muted); line-height:1.6; text-align:center;} .lbl-note{max-width:340px; font-size:var(--fs-sm); color:var(--muted); line-height:1.6; text-align:center;}
/* 设计期返回悬浮球(右下角,非侵入) */ /* 设计期返回悬浮球(右下角,非侵入) */
.ds-fab{position:fixed; right:22px; bottom:22px; width:44px; height:44px; border-radius:50%; background:var(--surface); color:var(--muted); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; box-shadow:var(--sh-2); z-index:var(--z-fab); opacity:.6; transition:opacity .18s,color .18s,border-color .18s,transform .18s,box-shadow .18s; text-decoration:none;} .ds-fab{position:fixed; right:22px; bottom:22px; width:44px; height:44px; border-radius:50%; background:var(--surface); color:var(--muted); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; box-shadow:var(--sh-2); z-index:var(--z-fab); opacity:.6; transition:opacity .18s,color .18s,border-color .18s,transform .18s,box-shadow .18s; text-decoration:none;}
@@ -165,14 +163,14 @@
<div class="lbl-mid"> <div class="lbl-mid">
<div class="lbl-left"> <div class="lbl-left">
<div class="lbl-info"> <div class="lbl-info">
<div class="lbl-name">飞天茅台<span class="lbl-code">P1297</span></div> <div class="lbl-name">飞天茅台</div>
<div class="lbl-sub"><span>53°</span><span>500ml</span><span>2023-05-18</span></div> <div class="lbl-sub">P1297&nbsp;&nbsp;&nbsp;&nbsp;53°</div>
<div class="lbl-sub">500ml&nbsp;&nbsp;&nbsp;&nbsp;2023-05-18</div>
</div> </div>
<div class="lbl-bc"><img alt="一维条码 P1297" src="data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwLjAwMDAwIDAuMDAwMDAgMzAwLjAwMDAwIDYwLjAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0gMC4wMDAwMCAwLjAwMDAwIGggNy41OTQ5NCB2IDYwLjAwMDAwIGggLTcuNTk0OTQgeiBNIDExLjM5MjQxIDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMzAuMzc5NzUgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSA0MS43NzIxNSAwLjAwMDAwIGggMTEuMzkyNDEgdiA2MC4wMDAwMCBoIC0xMS4zOTI0MSB6IE0gNTYuOTYyMDMgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDcyLjE1MTkwIDAuMDAwMDAgaCA3LjU5NDk0IHYgNjAuMDAwMDAgaCAtNy41OTQ5NCB6IE0gODMuNTQ0MzAgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSA5MS4xMzkyNCAwLjAwMDAwIGggMTEuMzkyNDEgdiA2MC4wMDAwMCBoIC0xMS4zOTI0MSB6IE0gMTA2LjMyOTExIDAuMDAwMDAgaCAxNS4xODk4NyB2IDYwLjAwMDAwIGggLTE1LjE4OTg3IHogTSAxMjUuMzE2NDYgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSAxMzIuOTExMzkgMC4wMDAwMCBoIDcuNTk0OTQgdiA2MC4wMDAwMCBoIC03LjU5NDk0IHogTSAxNDguMTAxMjcgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDE2Ny4wODg2MSAwLjAwMDAwIGggMTUuMTg5ODcgdiA2MC4wMDAwMCBoIC0xNS4xODk4NyB6IE0gMTg2LjA3NTk1IDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMTkzLjY3MDg5IDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMjA4Ljg2MDc2IDAuMDAwMDAgaCA3LjU5NDk0IHYgNjAuMDAwMDAgaCAtNy41OTQ5NCB6IE0gMjIwLjI1MzE2IDAuMDAwMDAgaCAxMS4zOTI0MSB2IDYwLjAwMDAwIGggLTExLjM5MjQxIHogTSAyNDMuMDM3OTcgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSAyNTAuNjMyOTEgMC4wMDAwMCBoIDcuNTk0OTQgdiA2MC4wMDAwMCBoIC03LjU5NDk0IHogTSAyNjkuNjIwMjUgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDI4NC44MTAxMyAwLjAwMDAwIGggMy43OTc0NyB2IDYwLjAwMDAwIGggLTMuNzk3NDcgeiBNIDI5Mi40MDUwNiAwLjAwMDAwIGggNy41OTQ5NCB2IDYwLjAwMDAwIGggLTcuNTk0OTQgeiAiIHN0eWxlPSJmaWxsOiAjMDAwMDAwIi8+PHRleHQgc3R5bGU9ImZpbGw6ICMwMDAwMDA7IGZvbnQtZmFtaWx5OiAmcXVvdDttb25vc3BhY2UmcXVvdDs7IGZvbnQtc2l6ZTogMTIuMDAwMDBweCIgeD0iMC4wMDAwMCIgeT0iMC4wMDAwMCI+PC90ZXh0Pjwvc3ZnPg==" /></div> <div class="lbl-bc"><img alt="一维条码 P1297" src="data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwLjAwMDAwIDAuMDAwMDAgMzAwLjAwMDAwIDYwLjAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0gMC4wMDAwMCAwLjAwMDAwIGggNy41OTQ5NCB2IDYwLjAwMDAwIGggLTcuNTk0OTQgeiBNIDExLjM5MjQxIDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMzAuMzc5NzUgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSA0MS43NzIxNSAwLjAwMDAwIGggMTEuMzkyNDEgdiA2MC4wMDAwMCBoIC0xMS4zOTI0MSB6IE0gNTYuOTYyMDMgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDcyLjE1MTkwIDAuMDAwMDAgaCA3LjU5NDk0IHYgNjAuMDAwMDAgaCAtNy41OTQ5NCB6IE0gODMuNTQ0MzAgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSA5MS4xMzkyNCAwLjAwMDAwIGggMTEuMzkyNDEgdiA2MC4wMDAwMCBoIC0xMS4zOTI0MSB6IE0gMTA2LjMyOTExIDAuMDAwMDAgaCAxNS4xODk4NyB2IDYwLjAwMDAwIGggLTE1LjE4OTg3IHogTSAxMjUuMzE2NDYgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSAxMzIuOTExMzkgMC4wMDAwMCBoIDcuNTk0OTQgdiA2MC4wMDAwMCBoIC03LjU5NDk0IHogTSAxNDguMTAxMjcgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDE2Ny4wODg2MSAwLjAwMDAwIGggMTUuMTg5ODcgdiA2MC4wMDAwMCBoIC0xNS4xODk4NyB6IE0gMTg2LjA3NTk1IDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMTkzLjY3MDg5IDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMjA4Ljg2MDc2IDAuMDAwMDAgaCA3LjU5NDk0IHYgNjAuMDAwMDAgaCAtNy41OTQ5NCB6IE0gMjIwLjI1MzE2IDAuMDAwMDAgaCAxMS4zOTI0MSB2IDYwLjAwMDAwIGggLTExLjM5MjQxIHogTSAyNDMuMDM3OTcgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSAyNTAuNjMyOTEgMC4wMDAwMCBoIDcuNTk0OTQgdiA2MC4wMDAwMCBoIC03LjU5NDk0IHogTSAyNjkuNjIwMjUgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDI4NC44MTAxMyAwLjAwMDAwIGggMy43OTc0NyB2IDYwLjAwMDAwIGggLTMuNzk3NDcgeiBNIDI5Mi40MDUwNiAwLjAwMDAwIGggNy41OTQ5NCB2IDYwLjAwMDAwIGggLTcuNTk0OTQgeiAiIHN0eWxlPSJmaWxsOiAjMDAwMDAwIi8+PHRleHQgc3R5bGU9ImZpbGw6ICMwMDAwMDA7IGZvbnQtZmFtaWx5OiAmcXVvdDttb25vc3BhY2UmcXVvdDs7IGZvbnQtc2l6ZTogMTIuMDAwMDBweCIgeD0iMC4wMDAwMCIgeT0iMC4wMDAwMCI+PC90ZXh0Pjwvc3ZnPg==" /></div>
</div> </div>
<div class="lbl-qr"><img alt="二维码" src="data:image/png;base64,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" /></div> <div class="lbl-qr"><img alt="二维码" src="data:image/png;base64,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" /></div>
</div> </div>
<div class="lbl-ftr"><span>岩美酒行</span><span>40×20mm</span></div>
</div> </div>
<div class="lbl-note">40×20mm 热敏价签:二维码溯源 + 底部整幅一维条码(Code128,内容=商品编号,供扫码枪扫码出库)。原型为示意布局,实际由客户端热敏/PDF/Web 三路渲染。</div> <div class="lbl-note">40×20mm 热敏价签:二维码溯源 + 底部整幅一维条码(Code128,内容=商品编号,供扫码枪扫码出库)。原型为示意布局,实际由客户端热敏/PDF/Web 三路渲染。</div>
</div> </div>
+19 -4
View File
@@ -102,11 +102,26 @@ echo "==> compile-ios: built ${IPA}"
# TestFlight 是 App Store 独立分发渠道,上传本身易抖(重复 build 号 / ASC 临时错误); # TestFlight 是 App Store 独立分发渠道,上传本身易抖(重复 build 号 / ASC 临时错误);
# 其失败【不应阻断】web/mac/win/android 的整体发版(见 CLAUDE.md「iOS ... 不阻塞」)。 # 其失败【不应阻断】web/mac/win/android 的整体发版(见 CLAUDE.md「iOS ... 不阻塞」)。
# IPA 已产出,上传失败仅告警,可稍后用 altool/Transporter 手动补传同一 IPA。 # IPA 已产出,上传失败仅告警,可稍后用 altool/Transporter 手动补传同一 IPA。
echo "==> compile-ios: uploading to TestFlight" # altool 上传是【静默】的:上传 IPA 到 Apple 期间数分钟无任何输出。网络到 Apple
if xcrun altool --upload-app -f "$IPA" -t ios \ # 抖动/变慢时会长时间挂住 → 超过 runner 僵尸超时(10min)整个 job 被 SIGKILL 判
# 失败,连下面的软失败分支都跑不到 → 阻断发版(1.1.13 就这么挂的)。
# 本机无 timeout(1)/gtimeout(1)(无 coreutils),故用后台看门狗:超 UPLOAD_TIMEOUT
# 先 TERM 再 KILL,保证上传永远在僵尸窗口内【干净收尾】,失败只告警不阻断。
UPLOAD_TIMEOUT=420
echo "==> compile-ios: uploading to TestFlight (超时保护 ${UPLOAD_TIMEOUT}s)"
xcrun altool --upload-app -f "$IPA" -t ios \
--apiKey "${KEY_ID}" \ --apiKey "${KEY_ID}" \
--apiIssuer "${ISSUER}"; then --apiIssuer "${ISSUER}" &
altool_pid=$!
( sleep "$UPLOAD_TIMEOUT"
kill -TERM "$altool_pid" 2>/dev/null || true
sleep 5
kill -KILL "$altool_pid" 2>/dev/null || true ) &
watchdog_pid=$!
if wait "$altool_pid"; then
echo "==> compile-ios: done — uploaded build ${BUILD} to TestFlight" echo "==> compile-ios: done — uploaded build ${BUILD} to TestFlight"
else else
echo "!! compile-ios: TestFlight 上传失败(build ${BUILD}),不阻断发版;IPA=${IPA} 已产出,可稍后手动补传。" >&2 echo "!! compile-ios: TestFlight 上传失败或超时(build ${BUILD}),不阻断发版;IPA=${IPA} 已产出,可稍后手动补传。" >&2
fi fi
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true