Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a127485f | |||
| eb7b0edc96 | |||
| 6d64a9bfbf | |||
| fc15a34a77 | |||
| 7d0595105f |
@@ -5,6 +5,21 @@ 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.1.12] - 2026-08-27
|
||||
|
||||
### 改进
|
||||
- 商品价签(标签)改版为两栏布局:左侧品名 / 编号 / 度数规格 / 生产日期 / 一维条码,右侧二维码占满整个高度,扫码更清晰;字段间距与二维码大小均已优化。
|
||||
- 设置 →「打印模板 · 商品价签」的「预览」现可直接查看真实标签样式(此前为占位提示)。
|
||||
|
||||
### 修复
|
||||
- 修复从「商品详情」和「基础数据」页打印标签时,生产日期不显示的问题(从入库 / 出库单打印此前已正常)。
|
||||
- 修复 Windows / macOS 版应用内更新时,下载进度条一直卡在 0% 的问题;现在能正常显示下载进度。
|
||||
|
||||
## [1.1.11] - 2026-08-26
|
||||
|
||||
### 修复
|
||||
- 修复 Windows 版因构建流程缺陷未包含「扫码出库」功能的问题;升级后 Windows 用户即可使用扫码枪扫码出库(macOS / 网页 / 安卓版此前已正常)。
|
||||
|
||||
## [1.1.10] - 2026-08-25
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -26,13 +26,22 @@ Future<void> downloadAndInstall(
|
||||
String url, void Function(double) onProgress) async {
|
||||
final tmp = await getTemporaryDirectory();
|
||||
final dio = Dio();
|
||||
|
||||
// 关键:显式拒绝 gzip(Accept-Encoding: identity)。
|
||||
// nginx 会对 application/octet-stream(.exe) 做即时 gzip,压缩响应走 chunked 传输、
|
||||
// 不带 Content-Length,导致 onReceiveProgress 的 total 恒为 -1、进度卡在 0
|
||||
//(macOS 的 .zip 属 application/zip 不被 gzip,故一直正常)。
|
||||
// identity 让服务端回传原始字节 + Content-Length,total 才有效。
|
||||
final dlOpts =
|
||||
Options(headers: {HttpHeaders.acceptEncodingHeader: 'identity'});
|
||||
|
||||
void prog(int r, int t) {
|
||||
if (t > 0) onProgress(r / t);
|
||||
if (t > 0) onProgress((r / t).clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
final exe = '${tmp.path}\\jiu-update-setup.exe';
|
||||
await dio.download(url, exe, onReceiveProgress: prog);
|
||||
await dio.download(url, exe, onReceiveProgress: prog, options: dlOpts);
|
||||
// 启动 Inno Setup 安装包后退出本应用,让其覆盖安装。
|
||||
await Process.start(exe, const [], mode: ProcessStartMode.detached);
|
||||
await Future<void>.delayed(AppConstants.updaterStepDelay);
|
||||
@@ -41,7 +50,7 @@ Future<void> downloadAndInstall(
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
final zip = '${tmp.path}/jiu-update.zip';
|
||||
await dio.download(url, zip, onReceiveProgress: prog);
|
||||
await dio.download(url, zip, onReceiveProgress: prog, options: dlOpts);
|
||||
|
||||
final extractDir = '${tmp.path}/jiu-update';
|
||||
await Process.run('/bin/rm', ['-rf', extractDir]);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ffi';
|
||||
import 'package:barcode/barcode.dart' as bc;
|
||||
import 'package:charset_converter/charset_converter.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:win32/win32.dart'
|
||||
@@ -155,10 +156,13 @@ bool _winRawPrint(String printerName, Uint8List data) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在 canvas 上画一行文字(黑色,单行不换行,CJK 走系统字体回退)
|
||||
/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
|
||||
void _drawText(
|
||||
ui.Canvas canvas, String s, double x, double y, double size, double maxW,
|
||||
{bool bold = false, bool center = false}) {
|
||||
{bool bold = false,
|
||||
bool center = false,
|
||||
ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑)
|
||||
{
|
||||
if (s.isEmpty) return;
|
||||
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
fontSize: size,
|
||||
@@ -167,13 +171,28 @@ void _drawText(
|
||||
maxLines: 1,
|
||||
ellipsis: '',
|
||||
))
|
||||
..pushStyle(
|
||||
ui.TextStyle(color: const ui.Color(0xFF000000))) // ds-ignore: 打印画布纸面固定色
|
||||
..pushStyle(ui.TextStyle(color: color)) // ds-ignore: 打印画布纸面固定色
|
||||
..addText(s);
|
||||
final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
|
||||
canvas.drawParagraph(p, ui.Offset(x, y));
|
||||
}
|
||||
|
||||
/// 在 canvas 上把 [data] 画成 Code128 一维条码(黑条,铺满 x,y,w,h 区域,不带人读字符)。
|
||||
/// 扫码枪扫 1D 条码;条码内容 = 商品编号,与出库扫码一致。
|
||||
void _drawBarcode(
|
||||
ui.Canvas canvas, String data, double x, double y, double w, double h) {
|
||||
if (data.isEmpty) return;
|
||||
final paint = ui.Paint()
|
||||
..color = const ui.Color(0xFF000000) // ds-ignore: 打印画布纸面固定色
|
||||
..style = ui.PaintingStyle.fill;
|
||||
for (final e in bc.Barcode.code128().make(data, width: w, height: h)) {
|
||||
if (e is bc.BarcodeBar && e.black) {
|
||||
canvas.drawRect(
|
||||
ui.Rect.fromLTWH(x + e.left, y + e.top, e.width, e.height), paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 测量单行文本在指定字号下的宽度
|
||||
double _measureWidth(String s, double size, bool bold) {
|
||||
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
@@ -196,6 +215,22 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
||||
return size;
|
||||
}
|
||||
|
||||
/// 组装标签底部平铺的三字段:度数(型号) / 规格(版本) / 生产日期。
|
||||
/// 各值为空则跳过;日期截断到 10 字符(YYYY-MM-DD)。四路渲染共用,口径一致。
|
||||
List<String> _labelSubFields(
|
||||
String? series, String? spec, String? productionDate) {
|
||||
final date = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10
|
||||
? productionDate.substring(0, 10)
|
||||
: productionDate)
|
||||
: '';
|
||||
return [
|
||||
if ((series ?? '').isNotEmpty) series!,
|
||||
if ((spec ?? '').isNotEmpty) spec!,
|
||||
if (date.isNotEmpty) date,
|
||||
];
|
||||
}
|
||||
|
||||
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
|
||||
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
||||
@@ -203,8 +238,7 @@ Future<ui.Image> _renderLabelBitmap({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String code,
|
||||
required String degSpec,
|
||||
required String date,
|
||||
required List<String> subs, // 底部平铺字段:度数 / 规格 / 日期(已过滤空)
|
||||
required Uint8List qrBytes,
|
||||
}) async {
|
||||
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
|
||||
@@ -214,61 +248,132 @@ Future<ui.Image> _renderLabelBitmap({
|
||||
final canvas =
|
||||
ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
|
||||
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
|
||||
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
|
||||
ui.Paint()..color = const ui.Color(0xFFFFFFFF)); // ds-ignore: 打印画布纸面固定色
|
||||
|
||||
// 右侧二维码(解码后端 PNG 直接画上)
|
||||
const qrS = 124.0;
|
||||
const qrTop = 8.0;
|
||||
const qrLeft = w - qrS - 4; // 192
|
||||
// 纸面固定配色(对齐设计原型 devices.html 标签块)——预览为彩色设计稿,
|
||||
// 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
|
||||
const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
|
||||
const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
|
||||
const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 米色页脚 / 头部字色
|
||||
const cInk = ui.Color(0xFF111111); // 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()),
|
||||
ui.Paint()..color = cWhite);
|
||||
|
||||
// ── 深蓝店名头 ──────────────────────────────────────────────
|
||||
const headerH = 24.0;
|
||||
canvas.drawRect(
|
||||
ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy);
|
||||
_drawText(canvas, shop, 10, 5, 12, w - 20, bold: true, color: cCream);
|
||||
|
||||
// ── 米色页脚 ────────────────────────────────────────────────
|
||||
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 bodyBot = footerTop; // 145
|
||||
const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间
|
||||
const double colGap = 6.0; // 左列与二维码列间距(原 10,收小)
|
||||
|
||||
// 右列:二维码占主体整高(正方形)
|
||||
const double qrSize = (bodyBot - bodyTop) - 2 * padV; // 109
|
||||
const double qrTop = bodyTop + padV; // 30
|
||||
const double qrLeft = w - padH - qrSize; // 203
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(qrBytes);
|
||||
final qrImg = (await codec.getNextFrame()).image;
|
||||
canvas.drawImageRect(
|
||||
qrImg,
|
||||
ui.Rect.fromLTWH(0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
|
||||
const ui.Rect.fromLTWH(qrLeft, qrTop, qrS, qrS),
|
||||
const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize),
|
||||
ui.Paint());
|
||||
} catch (e) {
|
||||
debugPrint('[label] QR 解码失败: $e');
|
||||
}
|
||||
// 扫码溯源:居中于二维码正下方
|
||||
_drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true);
|
||||
|
||||
// 左侧文字:固定字号 + 自适应行间距(与二维码上下对齐,行距均匀分配剩余空间)
|
||||
const lx = 22.0;
|
||||
const textMaxW = qrLeft - lx - 6; // 164
|
||||
// 左列几何
|
||||
const double leftX = padH; // 8
|
||||
const double leftRight = qrLeft - colGap; // 197
|
||||
const double leftW = leftRight - leftX; // 189
|
||||
|
||||
// 字号固定:只有商品名按宽度自适应,其余行均固定,杜绝自动放大
|
||||
const double kShopSize = 17.0; // 酒行名,粗体
|
||||
const double kSmallSize = 13.0; // 编号 / 度数 / 日期,统一小字
|
||||
// 商品名:仅按宽度适配,上限 26px,下限 14px
|
||||
// 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库
|
||||
const double barH = 40.0;
|
||||
const double barBottom = bodyBot - padV; // 137
|
||||
const double barTop = barBottom - barH; // 97
|
||||
_drawBarcode(canvas, code, leftX, barTop, leftW, barH);
|
||||
|
||||
// 左列文字块(品名+编号 一行居中;度数/规格/日期 平铺一行),在条码上方居中
|
||||
const double infoTop = bodyTop + padV; // 32
|
||||
const double infoBot = barTop - 4; // 93
|
||||
const double infoH = infoBot - infoTop; // 61
|
||||
|
||||
// 品名自适应字号(为编号留出约 28% 宽度),上限 17
|
||||
final double nameSize =
|
||||
_fitFont(name, textMaxW, 1000, true, 26.0).clamp(14.0, 26.0);
|
||||
_fitFont(name, leftW * 0.72, 1000, true, 17.0).clamp(11.0, 17.0);
|
||||
const double codeSize = 10.0;
|
||||
const double subSize = 10.0;
|
||||
final double nameLineH = nameSize * 1.2;
|
||||
const double subLineH = subSize * 1.2;
|
||||
const double interGap = 8.0;
|
||||
final double blockH = nameLineH + interGap + subLineH;
|
||||
double blockTop = infoTop + (infoH - blockH) / 2;
|
||||
if (blockTop < infoTop) blockTop = infoTop;
|
||||
|
||||
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
||||
final rows = <(String, double, bool)>[
|
||||
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
|
||||
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
|
||||
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
|
||||
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
|
||||
if (date.isNotEmpty) (date, kSmallSize, false),
|
||||
];
|
||||
// 品名 + 编号:整体水平居中,基线对齐
|
||||
final double nameW = _measureWidth(name, nameSize, true);
|
||||
final bool hasCode = code.isNotEmpty;
|
||||
const double codeGap = 9.0;
|
||||
final double codeW = hasCode ? _measureWidth(code, codeSize, false) : 0;
|
||||
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);
|
||||
}
|
||||
|
||||
// 文字区域:整体略低于二维码顶部,底部延伸至扫码溯源文字下方
|
||||
const double kTextTop = 16.0; // 比二维码顶部(8)低一些,整体下移
|
||||
const double kTextBot = 150.0; // 延伸至底部,减少留白
|
||||
const double kLineH = 1.35; // 行高倍数(字号 × 行高 = 该行占用高度)
|
||||
final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
|
||||
final int n = rows.length;
|
||||
final double rawGap =
|
||||
n > 0 ? (kTextBot - kTextTop - totalTextH) / (n + 1) : 4.0;
|
||||
final double gap = rawGap < 1.5 ? 1.5 : rawGap;
|
||||
|
||||
double yy = kTextTop + gap;
|
||||
for (final r in rows) {
|
||||
_drawText(canvas, r.$1, lx, yy, r.$2, textMaxW, bold: r.$3);
|
||||
yy += r.$2 * kLineH + gap;
|
||||
// 三字段平铺一行(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)
|
||||
@@ -308,6 +413,16 @@ Future<Uint8List> _encodeGbkAsync(String text) async {
|
||||
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(
|
||||
@@ -328,8 +443,7 @@ Future<bool> _printFlatLabelThermal({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String code,
|
||||
required String degSpec,
|
||||
required String date,
|
||||
required List<String> subs, // 度数 / 规格 / 日期(已过滤空)
|
||||
required Uint8List qrBytes,
|
||||
String? printerName,
|
||||
}) async {
|
||||
@@ -338,12 +452,18 @@ Future<bool> _printFlatLabelThermal({
|
||||
debugPrint('[label] thermal printer = $printer');
|
||||
if (printer == null) return false;
|
||||
|
||||
// QR BITMAP: 124×124 dots, 右侧 x=192, y=8
|
||||
const qrW = 124, qrH = 124;
|
||||
const qrX = 192, qrY = 8;
|
||||
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
|
||||
// 版面(320×160 dots,DIRECTION 1):深蓝头/米色脚在单色热敏上分别退化为
|
||||
// 「黑底反白店名」「白底黑字页脚」;主体两列:左=文字+条码,右=二维码占整高。
|
||||
const headerH = 26; // 头部黑带高
|
||||
const bodyTop = headerH; // 26
|
||||
const bodyBot = 134; // 主体下沿(页脚之上)
|
||||
|
||||
// 右列二维码:占主体整高(正方形),右对齐(放大,占满主体高度)
|
||||
const qrW = 104, qrH = 104;
|
||||
const qrX = 320 - 8 - qrW; // 208
|
||||
const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 28
|
||||
const qrWBytes = (qrW + 7) >> 3; // 13 bytes/row
|
||||
|
||||
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
|
||||
final codec = await ui.instantiateImageCodec(qrBytes,
|
||||
targetWidth: qrW, targetHeight: qrH);
|
||||
final qrImg = (await codec.getNextFrame()).image;
|
||||
@@ -356,7 +476,19 @@ Future<bool> _printFlatLabelThermal({
|
||||
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
|
||||
));
|
||||
|
||||
// QR BITMAP 命令
|
||||
// ── 深蓝店名头(黑底反白):先画黑字店名,再 REVERSE 整条头部 → 白字黑底 ──
|
||||
await _addTextCmd(buf, 8, 5, 'TSS16.BF2', shop);
|
||||
buf.add(ascii.encode('REVERSE 0,0,320,$headerH\r\n'));
|
||||
|
||||
// ── 米色页脚(白底黑字):店名 + 尺寸 ──
|
||||
const footerY = 140;
|
||||
await _addTextCmd(buf, 8, footerY, 'TSS16.BF2', shop);
|
||||
const sizeStr = '40×20mm';
|
||||
final sizeW = _estTsplWidth(sizeStr, 16);
|
||||
await _addTextCmd(
|
||||
buf, (320 - 8 - sizeW).clamp(8, 312), footerY, 'TSS16.BF2', sizeStr);
|
||||
|
||||
// ── 右列二维码 BITMAP ──
|
||||
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
|
||||
for (int yy = 0; yy < qrH; yy++) {
|
||||
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白
|
||||
@@ -372,40 +504,61 @@ Future<bool> _printFlatLabelThermal({
|
||||
}
|
||||
buf.add(ascii.encode('\r\n'));
|
||||
|
||||
// "扫码溯源" 居中于 QR 正下方
|
||||
// TSS16 CJK 每字 16 dots; 4字=64; QR 中心 x=192+62=254; 文字起始 x=254-32=222
|
||||
await _addTextCmd(buf, 222, qrY + qrH + 2, 'TSS16.BF2', '扫码溯源');
|
||||
// 左列几何(与二维码列间距收小到 6)
|
||||
const leftX = 8,
|
||||
leftRight = qrX - 6,
|
||||
leftW = leftRight - leftX; // 8..202, 194
|
||||
|
||||
// 左侧文字(x=4, 宽度至 x=186)
|
||||
// TSS24.BF2: 24×24 点阵; TSS16.BF2: 16×16 点阵
|
||||
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
|
||||
const kFontXL = 'TSS24.BF2'; // 大号(商品名,≤7字时使用)
|
||||
final nameFont = name.runes.length <= 7 ? kFontXL : kFontL;
|
||||
final nameH = nameFont == kFontXL ? 24 : 16;
|
||||
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
||||
// ── 左列底部整幅一维条码(Code128,内容=商品编号),narrow 自适应铺满并居中 ──
|
||||
const kBarH = 28;
|
||||
const kBarY = bodyBot - 4 - kBarH; // 102
|
||||
if (code.isNotEmpty) {
|
||||
final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验)
|
||||
final narrow = (leftW / estModules).floor().clamp(2, 4);
|
||||
final barW = estModules * narrow;
|
||||
final barX = (leftX + (leftW - barW) ~/ 2).clamp(leftX, leftRight - 10);
|
||||
buf.add(ascii.encode(
|
||||
'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n'));
|
||||
}
|
||||
|
||||
// (文本, 字体, 字符高度点数)
|
||||
final rows = <(String, String, int)>[];
|
||||
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
|
||||
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
|
||||
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
|
||||
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
|
||||
if (date.isNotEmpty) rows.add((date, kFontL, 16));
|
||||
// ── 左列文字块:品名+编号 一行居中;度数/规格/日期 平铺一行;整体在条码上方居中 ──
|
||||
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;
|
||||
|
||||
// 在 y=8..150 区间均匀分布各行
|
||||
const kTextTop = 8;
|
||||
const kTextBot = 150;
|
||||
const kTextX = 4;
|
||||
final totalH = rows.fold(0, (s, r) => s + r.$3);
|
||||
final n = rows.length;
|
||||
final gapSize = n > 0
|
||||
? ((kTextBot - kTextTop - totalH) / (n + 1)).round().clamp(2, 20)
|
||||
: 8;
|
||||
// 品名 + 编号:整体水平居中,编号小字随后,基线对齐
|
||||
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);
|
||||
}
|
||||
|
||||
int yPos = kTextTop + gapSize;
|
||||
for (final r in rows) {
|
||||
await _addTextCmd(buf, kTextX, yPos, r.$2, r.$1);
|
||||
yPos += r.$3 + gapSize;
|
||||
// 三字段平铺一行(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'));
|
||||
@@ -441,40 +594,23 @@ Future<void> printProductLabelImpl({
|
||||
const headerH = labelH * 0.20;
|
||||
const footerH = labelH * 0.11;
|
||||
|
||||
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
|
||||
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
|
||||
// 度数(系列) + 规格 同一行
|
||||
final degSpecVal =
|
||||
[seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||
final dateVal = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10
|
||||
? productionDate.substring(0, 10)
|
||||
: productionDate)
|
||||
: '—';
|
||||
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致)
|
||||
final subs = _labelSubFields(series, spec, productionDate);
|
||||
|
||||
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
|
||||
if (await _printFlatLabelThermal(
|
||||
shop: shopName,
|
||||
name: name,
|
||||
code: code,
|
||||
degSpec: degSpecVal,
|
||||
date: dateVal == '—' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
|
||||
subs: subs,
|
||||
qrBytes: qrBytes,
|
||||
printerName: printerName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final contact = [
|
||||
if (shopAddress.isNotEmpty) shopAddress,
|
||||
if (shopPhone.isNotEmpty) shopPhone,
|
||||
].join(' · ');
|
||||
final footerLeft = contact.isNotEmpty ? contact : shopName;
|
||||
|
||||
// 标签生成时间
|
||||
final now = DateTime.now();
|
||||
final genTime =
|
||||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'
|
||||
' ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||
// 商品名字号:随字数自适应(与预览一致,短名更大)
|
||||
final double nameFs =
|
||||
name.runes.length > 10 ? 6.5 : (name.runes.length > 7 ? 7.5 : 9.0);
|
||||
|
||||
doc.addPage(pw.Page(
|
||||
pageFormat: const PdfPageFormat(labelW, labelH),
|
||||
@@ -482,11 +618,11 @@ Future<void> printProductLabelImpl({
|
||||
build: (_) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Header:酒行名称 ─────────────────────────────────────────────────
|
||||
// ── Header:深蓝店名头 ────────────────────────────────────────────────
|
||||
pw.Container(
|
||||
height: headerH,
|
||||
color: _navy,
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
|
||||
alignment: pw.Alignment.centerLeft,
|
||||
child: pw.Text(shopName,
|
||||
maxLines: 1,
|
||||
@@ -500,93 +636,117 @@ Future<void> printProductLabelImpl({
|
||||
)),
|
||||
),
|
||||
|
||||
// ── Body ─────────────────────────────────────────────────────────────
|
||||
// ── Body:左列(文字+条码) / 右列(二维码占整高) ─────────────────────────
|
||||
pw.Expanded(
|
||||
child: pw.Container(
|
||||
color: PdfColors.white,
|
||||
padding: const pw.EdgeInsets.fromLTRB(4, 2, 4, 2),
|
||||
padding: const pw.EdgeInsets.fromLTRB(4, 3, 4, 3),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 左列
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 商品名(过长自动缩小并最多两行)
|
||||
pw.Text(name,
|
||||
maxLines: 2,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: name.runes.length > 10
|
||||
? 5.5
|
||||
: (name.runes.length > 7 ? 6.5 : 7.5),
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _ink,
|
||||
letterSpacing: 0.3)),
|
||||
pw.SizedBox(height: 2.5),
|
||||
// 商品编号(度数上方)
|
||||
// 文字块(品名+编号 居中 / 三字段平铺),条码上方居中
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 品名 + 编号(整体居中,编号小字随后)
|
||||
pw.RichText(
|
||||
textAlign: pw.TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
text: pw.TextSpan(children: [
|
||||
pw.TextSpan(
|
||||
text: name,
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: nameFs,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _ink,
|
||||
letterSpacing: 0.3)),
|
||||
if (code.isNotEmpty)
|
||||
pw.TextSpan(
|
||||
text: ' $code',
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: 5,
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 左列底部整幅一维条码(Code128,内容=商品编号),供扫码枪
|
||||
if (code.isNotEmpty) ...[
|
||||
pw.Text('编号:$code',
|
||||
style: pw.TextStyle(
|
||||
font: font, fontSize: 5.5, color: _ink)),
|
||||
pw.SizedBox(height: 1.5),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.SizedBox(
|
||||
height: 13,
|
||||
child: pw.BarcodeWidget(
|
||||
barcode: pw.Barcode.code128(),
|
||||
data: code,
|
||||
drawText: false,
|
||||
color: _ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
// 度数(系列) + 规格 同一行, 无字段名标签
|
||||
pw.Text(degSpecVal,
|
||||
style: pw.TextStyle(
|
||||
font: font, fontSize: 5.5, color: _ink)),
|
||||
pw.SizedBox(height: 1.5),
|
||||
// 生产日期(只显示日期,无标签)
|
||||
pw.Text(dateVal,
|
||||
style: pw.TextStyle(
|
||||
font: font, fontSize: 5.5, color: _ink)),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.SizedBox(width: 4),
|
||||
// QR
|
||||
pw.Column(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
children: [
|
||||
pw.SizedBox(
|
||||
width: 38,
|
||||
height: 38,
|
||||
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
|
||||
),
|
||||
pw.SizedBox(height: 1),
|
||||
pw.Text('扫码溯源',
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: 3.5,
|
||||
color: PdfColors.grey600)),
|
||||
],
|
||||
// 右列:二维码占主体整高(正方形)
|
||||
pw.AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Footer ───────────────────────────────────────────────────────────
|
||||
// ── Footer:米色页脚(店名 + 尺寸)────────────────────────────────────
|
||||
pw.Container(
|
||||
height: footerH,
|
||||
color: _cream,
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Flexible(
|
||||
child: pw.Text(footerLeft,
|
||||
child: pw.Text(shopName,
|
||||
maxLines: 1,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
style: pw.TextStyle(
|
||||
font: font, fontSize: 3, color: _footnote)),
|
||||
font: font, fontSize: 3.5, color: _footnote)),
|
||||
),
|
||||
pw.Text(genTime,
|
||||
pw.Text('40×20mm',
|
||||
style: pw.TextStyle(
|
||||
font: font, fontSize: 3, color: _footnote)),
|
||||
font: font, fontSize: 3.5, color: _footnote)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -613,21 +773,11 @@ Future<void> printProductLabelImpl({
|
||||
/// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。
|
||||
/// Web 平台或 qrBytes 为空时抛异常。
|
||||
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
|
||||
final specVal = (label.spec ?? '').isNotEmpty ? label.spec! : '';
|
||||
final seriesVal = (label.series ?? '').isNotEmpty ? label.series! : '';
|
||||
final degSpecVal =
|
||||
[seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||
final dateVal = (label.productionDate ?? '').isNotEmpty
|
||||
? (label.productionDate!.length > 10
|
||||
? label.productionDate!.substring(0, 10)
|
||||
: label.productionDate!)
|
||||
: '';
|
||||
final img = await _renderLabelBitmap(
|
||||
shop: label.shopName,
|
||||
name: label.name,
|
||||
code: label.code,
|
||||
degSpec: degSpecVal,
|
||||
date: dateVal,
|
||||
subs: _labelSubFields(label.series, label.spec, label.productionDate),
|
||||
qrBytes: label.qrBytes!,
|
||||
);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:typed_data';
|
||||
import 'package:barcode/barcode.dart' as bc;
|
||||
import 'package:web/web.dart' as web;
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
@@ -35,21 +36,27 @@ Future<void> printProductLabelImpl({
|
||||
|
||||
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
|
||||
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
|
||||
// 度数(系列) + 规格 同一行
|
||||
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||
final dateVal = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10
|
||||
? productionDate.substring(0, 10)
|
||||
: productionDate)
|
||||
: '—';
|
||||
// 商品名过长时缩小字号(适当缩小,配合两行折行)
|
||||
: '';
|
||||
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、PDF、TSPL 一致)
|
||||
final subs = <String>[
|
||||
if (seriesVal.isNotEmpty) seriesVal,
|
||||
if (specVal.isNotEmpty) specVal,
|
||||
if (dateVal.isNotEmpty) dateVal,
|
||||
];
|
||||
final subSpans = subs.map((s) => '<span>$s</span>').join();
|
||||
// 商品名过长时缩小字号
|
||||
final nameLen = name.runes.length;
|
||||
final nameFontPt = nameLen > 11 ? 5.0 : (nameLen > 8 ? 5.8 : 6.5);
|
||||
final nameFontPt = nameLen > 10 ? 7.0 : (nameLen > 7 ? 8.0 : 9.0);
|
||||
|
||||
final contactLine = [
|
||||
if (shopPhone.isNotEmpty) shopPhone,
|
||||
if (shopAddress.isNotEmpty) shopAddress,
|
||||
].join(' ');
|
||||
// 一维条码(Code128,内容=商品编号):矢量 SVG 内联,扫码枪扫码出库。
|
||||
final barcodeSvg = code.isNotEmpty
|
||||
? bc.Barcode.code128()
|
||||
.toSvg(code, width: 200, height: 46, drawText: false)
|
||||
: '';
|
||||
|
||||
final html = '''<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -58,76 +65,64 @@ Future<void> printProductLabelImpl({
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* 标签尺寸:38mm × 20mm,横向 */
|
||||
@page { size: 38mm 20mm; margin: 0; }
|
||||
/* 标签尺寸:40mm × 20mm,横向(对齐设计原型 devices.html 标签块)*/
|
||||
@page { size: 40mm 20mm; margin: 0; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
|
||||
.label {
|
||||
width: 38mm; height: 20mm;
|
||||
width: 40mm; height: 20mm;
|
||||
display: flex; flex-direction: column;
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ── Header:酒行名称 3.5mm ── */
|
||||
/* ── Header:深蓝店名头 3.2mm ── */
|
||||
.hdr {
|
||||
background: #1f2a3a; color: #f4ecd8;
|
||||
height: 3.5mm; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 1.5mm; overflow: hidden;
|
||||
height: 3.2mm; flex-shrink: 0;
|
||||
display: flex; align-items: center;
|
||||
padding: 0 1.2mm; overflow: hidden;
|
||||
}
|
||||
.hdr-name {
|
||||
font-size: 5.5pt; font-weight: 700; letter-spacing: 0.08em;
|
||||
font-size: 6pt; font-weight: 700; letter-spacing: 0.05em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── 中部:左侧商品信息 + 右侧二维码 ── */
|
||||
/* ── 主体:左列(文字+条码) / 右列(二维码占整高) ── */
|
||||
.middle {
|
||||
flex: 1; display: flex; flex-direction: row; overflow: hidden;
|
||||
flex: 1; min-height: 0; display: flex; gap: 0.8mm;
|
||||
padding: 0.7mm 0.9mm; overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左:商品名 + 规格批次 */
|
||||
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.info {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
justify-content: center; padding: 0.8mm 1mm 0.5mm 1.5mm;
|
||||
overflow: hidden;
|
||||
flex: 1; min-height: 0; display: flex; flex-direction: column;
|
||||
justify-content: center; gap: 1mm; overflow: hidden;
|
||||
}
|
||||
.product-name {
|
||||
font-size: 6.5pt; font-weight: 700; line-height: 1.15;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
|
||||
overflow: hidden; word-break: break-all;
|
||||
margin-bottom: 0.6mm;
|
||||
.pname {
|
||||
font-weight: 700; text-align: center; line-height: 1.1;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.specs {
|
||||
display: flex; flex-direction: column; gap: 0.5mm;
|
||||
.pcode { font-size: 5pt; font-weight: 400; color: #333; margin-left: 1.2mm; }
|
||||
.sub {
|
||||
font-size: 5pt; color: #333; display: flex;
|
||||
justify-content: space-between; gap: 1mm; padding: 0 1.2mm;
|
||||
}
|
||||
.spec-row {
|
||||
display: flex; gap: 0.8mm; align-items: baseline;
|
||||
font-size: 4pt; white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.lbl { color: #999; flex-shrink: 0; }
|
||||
.val { font-family: "IBM Plex Mono", monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; }
|
||||
.sub span { white-space: nowrap; }
|
||||
.bc { flex-shrink: 0; height: 4.6mm; }
|
||||
.bc svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* 右:二维码 */
|
||||
.qr-wrap {
|
||||
width: 16mm; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 0.3mm;
|
||||
padding: 0.3mm 0.3mm 0.3mm 0;
|
||||
}
|
||||
.qr-wrap img { width: 14.5mm; height: 14.5mm; object-fit: contain; }
|
||||
.qr-cap { font-size: 3pt; color: #aaa; letter-spacing: 0.1em; }
|
||||
/* 右:二维码占主体整高(正方形)*/
|
||||
.qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; }
|
||||
.qr img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
|
||||
/* ── Footer:联系方式 3mm ── */
|
||||
/* ── Footer:米色页脚(店名 + 尺寸)── */
|
||||
.ftr {
|
||||
background: #f4ecd8; height: 3mm; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 1.5mm;
|
||||
font-size: 3.5pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
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>
|
||||
</head>
|
||||
@@ -139,20 +134,19 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
</div>
|
||||
|
||||
<div class="middle">
|
||||
<div class="info">
|
||||
<div class="product-name" style="font-size:${nameFontPt}pt">$name</div>
|
||||
<div class="specs">
|
||||
<div class="spec-row"><span class="val">$degSpecVal</span></div>
|
||||
<div class="spec-row"><span class="val">$dateVal</span></div>
|
||||
<div class="left">
|
||||
<div class="info">
|
||||
<div class="pname" style="font-size:${nameFontPt}pt">$name<span class="pcode">$code</span></div>
|
||||
<div class="sub">$subSpans</div>
|
||||
</div>
|
||||
<div class="bc">$barcodeSvg</div>
|
||||
</div>
|
||||
<div class="qr-wrap">
|
||||
<div class="qr">
|
||||
<img src="data:image/png;base64,$base64Img" />
|
||||
<div class="qr-cap">扫码溯源</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ftr">${contactLine.isNotEmpty ? contactLine : shopName}</div>
|
||||
<div class="ftr"><span>$shopName</span><span>40×20mm</span></div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
|
||||
@@ -9,8 +9,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/utils/print_util.dart' show LabelData;
|
||||
import '../../widgets/label_preview_dialog.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
@@ -932,8 +935,9 @@ class _DeviceManagementScreenState
|
||||
// ── 区块 C:打印模板(静态两卡,仅桌面;拍板移动端无打印)────
|
||||
Widget _tplGrid(dynamic t) {
|
||||
final cards = [
|
||||
_tplCard(
|
||||
t, LucideIcons.tag, '标签模板 · 商品价签', '40×30mm · 品名 / 规格 / 条码 / 零售价'),
|
||||
_tplCard(t, LucideIcons.tag, '标签模板 · 商品价签',
|
||||
'40×20mm · 店名 / 品名 / 编号 / 度数规格 / 生产日期 / 二维码 + 底部整幅一维条码',
|
||||
onPreview: _previewLabelTemplate),
|
||||
_tplCard(t, LucideIcons.receiptText, '小票模板 · 出库单据',
|
||||
'58mm 热敏 · 抬头 / 明细 / 合计 / 经手人'),
|
||||
];
|
||||
@@ -944,7 +948,8 @@ class _DeviceManagementScreenState
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _tplCard(dynamic t, IconData icon, String name, String desc) {
|
||||
Widget _tplCard(dynamic t, IconData icon, String name, String desc,
|
||||
{VoidCallback? onPreview}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -980,7 +985,8 @@ class _DeviceManagementScreenState
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
DsButton('预览',
|
||||
small: true, onPressed: () => _snack('模板预览即将上线')),
|
||||
small: true,
|
||||
onPressed: onPreview ?? () => _snack('模板预览即将上线')),
|
||||
const SizedBox(width: 8),
|
||||
DsButton('编辑',
|
||||
small: true, onPressed: () => _snack('模板编辑即将上线')),
|
||||
@@ -992,6 +998,35 @@ class _DeviceManagementScreenState
|
||||
);
|
||||
}
|
||||
|
||||
// ── 标签模板预览:用示例数据 + 本地二维码,走真实标签渲染 ────
|
||||
Future<void> _previewLabelTemplate() async {
|
||||
final shop = ref.read(shopInfoProvider).valueOrNull;
|
||||
// 本地生成一张示例二维码(与真实标签同一渲染路径,仅数据是示例)。
|
||||
final painter = QrPainter(
|
||||
data: 'https://jiu.51yanmei.com/p/sample',
|
||||
version: QrVersions.auto,
|
||||
gapless: true,
|
||||
);
|
||||
final img = await painter.toImageData(240);
|
||||
if (!mounted || img == null) return;
|
||||
final label = LabelData(
|
||||
qrBytes: img.buffer.asUint8List(),
|
||||
name: '示例·贵州茅台',
|
||||
code: 'P0001',
|
||||
series: '飞天',
|
||||
spec: '500ml',
|
||||
productionDate: '2024-05-18',
|
||||
shopName: shop?.name ?? '示例酒行',
|
||||
shopAddress: shop?.address ?? '',
|
||||
shopPhone: shop?.phone ?? '',
|
||||
);
|
||||
if (!mounted) return;
|
||||
showAppDialog(
|
||||
context: context,
|
||||
builder: (_) => LabelPreviewDialog(labels: [label]),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 外设增删(店级 custom_fields.peripherals)───────────────
|
||||
Future<void> _savePeripherals(List<Peripheral> items) async {
|
||||
final shop = await ref.read(shopInfoProvider.future);
|
||||
|
||||
@@ -987,6 +987,7 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
|
||||
code: p.code,
|
||||
spec: p.spec,
|
||||
series: p.series,
|
||||
productionDate: p.productionDate,
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
shopPhone: shopInfo?.phone ?? '',
|
||||
|
||||
@@ -733,6 +733,8 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
|
||||
code: p.code,
|
||||
series: (p.series ?? '').isEmpty ? null : p.series,
|
||||
spec: (p.spec ?? '').isEmpty ? null : p.spec,
|
||||
productionDate:
|
||||
(p.productionDate ?? '').isEmpty ? null : p.productionDate,
|
||||
remark: (p.remark ?? '').isEmpty ? null : p.remark,
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
barcode:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: barcode
|
||||
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||
|
||||
@@ -34,6 +34,7 @@ dependencies:
|
||||
lucide_icons_flutter: ^3.1.14+2
|
||||
# 购买卡 render_type=qr 内嵌二维码(纯 Dart,全平台安全,无原生依赖)
|
||||
qr_flutter: ^4.1.0
|
||||
barcode: ^2.2.9
|
||||
# Web 端去 URL # 前缀(usePathUrlStrategy),Flutter SDK 内置包
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
|
||||
+1
-1
@@ -210,7 +210,7 @@ AppTokens 目前**仅颜色**。原型还驱动:
|
||||
> - T7 体检结论:`node tools/fidelity.mjs inventory stock-in stock-out finance` 12 屏×主题全绿(数值见块 4 对应行,均 ≤ 各屏阈值,阈值本身 ≤8% 铁律线);移动侧对应 8 个 golden 自比测试文件全绿;手册核查 `web/content/docs.md`/`docs/manual/user-manual.html` 均未见与「出入库默认显示/状态筛选」「财务筛选」冲突的既有描述,按红线不新增描述,本轮手册未改动。
|
||||
>
|
||||
> **设备/设置/用户/关于 已知差异(2026-07-02 Phase2 第 6-8 屏)**:
|
||||
> - 设备:外设设备=登记式本地存档(店级 `custom_fields.peripherals`,无真实连接协议),测试打印/配置/模板预览为占位 toast;打印模板静态两卡。
|
||||
> - 设备:外设设备=登记式本地存档(店级 `custom_fields.peripherals`,无真实连接协议),测试打印/配置为占位 toast;打印模板静态两卡。**价签模板「预览」已升为真实弹窗**(`ovLabel`,2026-08):40×20mm 热敏价签 = 深蓝店名头 + **主体两列**(左列:品名居中·编号紧随其后小字 → 下方度数/规格/日期三字段平铺一行[两侧留边距,外白 > 中白] → 底部整幅一维条码 Code128[内容=商品编号,供扫码枪扫码出库];右列:二维码占主体整高正方形)+ 米色页脚(店名 + 40×20mm)。去「扫码溯源」文案。对应客户端四路渲染(canvas 预览 / TSPL 热敏 / PDF 兜底 / Web HTML)已同步落地此布局;**热敏为单色介质**,深蓝头/米色脚在 TSPL 上退化为「黑底反白店名」「白底黑字页脚」(REVERSE 命令),布局一致。
|
||||
> - 设置:子导航比原型多「编号规则」(真实功能保留);门店卡多 Logo 行;授权卡保留降级说明;偏好即时生效无「保存」按钮;原型「系统参数」假设置删除。
|
||||
> - 用户:独立页挂 `/settings/users`(原型导航同样高亮系统设置);角色四级(后端为准),原型 settings 预览三级口径弃用;授权数据 settings/about 同源 licenseProvider(原型两处数字不一致)。
|
||||
> - 关于:旧版 帮助文档/扫码防伪/系统信息 卡删除,构建号并入版本 cell;更新日志走 `/public/release`(与官网时间线同源),接口不可达回退只显当前版本。
|
||||
|
||||
@@ -60,6 +60,24 @@
|
||||
.tpl-name{font-size:var(--fs-body); font-weight:700; color:var(--heading);}
|
||||
.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;}
|
||||
|
||||
/* ---- 标签预览(40×20mm 热敏价签:抬头 + 商品信息/二维码 + 底部整幅一维条码 + 页脚)----
|
||||
打印纸面为固定专色(navy 抬头 / cream 页脚 / 墨黑正文),不随 App 主题切换,故按 ds-allow 豁免。 */
|
||||
.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-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-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-name{font-size:18px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-align:center;}
|
||||
.lbl-code{font-size:11px; font-weight:400; color:#333; margin-left:10px;} /* 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 img{width:100%; height:100%; display:block; object-fit:fill;}
|
||||
.lbl-qr{flex:none; height:100%; aspect-ratio:1;}
|
||||
.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;}
|
||||
/* 设计期返回悬浮球(右下角,非侵入) */
|
||||
.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:hover{opacity:1; color:var(--primary); border-color:var(--primary); box-shadow:var(--sh-3); transform:translateY(-2px);}
|
||||
@@ -96,9 +114,9 @@
|
||||
<div class="tpl-prev"><svg viewBox="0 0 24 24"><use href="#i-ic12"/></svg></div>
|
||||
<div class="tpl-body">
|
||||
<div class="tpl-name">标签模板 · 商品价签</div>
|
||||
<div class="tpl-desc">40×30mm · 品名 / 规格 / 条码 / 零售价</div>
|
||||
<div class="tpl-desc">40×20mm · 店名 / 品名 / 编号 / 度数规格 / 生产日期 / 二维码 + 底部整幅一维条码</div>
|
||||
<div class="tpl-acts">
|
||||
<div class="btn ghost sm" onclick="toast('打开标签模板预览(原型)')"><svg viewBox="0 0 24 24"><use href="#i-eye"/></svg>预览</div>
|
||||
<div class="btn ghost sm" onclick="openLabelPreview()"><svg viewBox="0 0 24 24"><use href="#i-eye"/></svg>预览</div>
|
||||
<div class="btn ghost sm" onclick="toast('编辑标签模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>编辑</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,6 +155,31 @@
|
||||
<div class="modal-foot"><div class="btn ghost" onclick="closeOverlay('ovAdd')">取消</div><div class="btn primary" onclick="closeOverlay('ovAdd'); toast('设备已添加并连接(原型)✓')">保存并连接</div></div>
|
||||
</div></div>
|
||||
|
||||
<!-- 标签模板预览:40×20mm 热敏价签(二维码溯源 + 底部整幅一维条码,扫码枪扫码出库) -->
|
||||
<div class="overlay" id="ovLabel"><div class="modal" style="width:420px">
|
||||
<div class="modal-head"><h3>标签模板 · 商品价签</h3><svg class="x" viewBox="0 0 24 24" stroke-width="2" onclick="closeOverlay('ovLabel')"><use href="#i-close"/></svg></div>
|
||||
<div class="modal-body">
|
||||
<div class="lbl-wrap">
|
||||
<div class="lbl-preview">
|
||||
<div class="lbl-hdr">岩美酒行</div>
|
||||
<div class="lbl-mid">
|
||||
<div class="lbl-left">
|
||||
<div class="lbl-info">
|
||||
<div class="lbl-name">飞天茅台<span class="lbl-code">P1297</span></div>
|
||||
<div class="lbl-sub"><span>53°</span><span>500ml</span><span>2023-05-18</span></div>
|
||||
</div>
|
||||
<div class="lbl-bc"><img alt="一维条码 P1297" src="data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwLjAwMDAwIDAuMDAwMDAgMzAwLjAwMDAwIDYwLjAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0gMC4wMDAwMCAwLjAwMDAwIGggNy41OTQ5NCB2IDYwLjAwMDAwIGggLTcuNTk0OTQgeiBNIDExLjM5MjQxIDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMzAuMzc5NzUgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSA0MS43NzIxNSAwLjAwMDAwIGggMTEuMzkyNDEgdiA2MC4wMDAwMCBoIC0xMS4zOTI0MSB6IE0gNTYuOTYyMDMgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDcyLjE1MTkwIDAuMDAwMDAgaCA3LjU5NDk0IHYgNjAuMDAwMDAgaCAtNy41OTQ5NCB6IE0gODMuNTQ0MzAgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSA5MS4xMzkyNCAwLjAwMDAwIGggMTEuMzkyNDEgdiA2MC4wMDAwMCBoIC0xMS4zOTI0MSB6IE0gMTA2LjMyOTExIDAuMDAwMDAgaCAxNS4xODk4NyB2IDYwLjAwMDAwIGggLTE1LjE4OTg3IHogTSAxMjUuMzE2NDYgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSAxMzIuOTExMzkgMC4wMDAwMCBoIDcuNTk0OTQgdiA2MC4wMDAwMCBoIC03LjU5NDk0IHogTSAxNDguMTAxMjcgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDE2Ny4wODg2MSAwLjAwMDAwIGggMTUuMTg5ODcgdiA2MC4wMDAwMCBoIC0xNS4xODk4NyB6IE0gMTg2LjA3NTk1IDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMTkzLjY3MDg5IDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMjA4Ljg2MDc2IDAuMDAwMDAgaCA3LjU5NDk0IHYgNjAuMDAwMDAgaCAtNy41OTQ5NCB6IE0gMjIwLjI1MzE2IDAuMDAwMDAgaCAxMS4zOTI0MSB2IDYwLjAwMDAwIGggLTExLjM5MjQxIHogTSAyNDMuMDM3OTcgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSAyNTAuNjMyOTEgMC4wMDAwMCBoIDcuNTk0OTQgdiA2MC4wMDAwMCBoIC03LjU5NDk0IHogTSAyNjkuNjIwMjUgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDI4NC44MTAxMyAwLjAwMDAwIGggMy43OTc0NyB2IDYwLjAwMDAwIGggLTMuNzk3NDcgeiBNIDI5Mi40MDUwNiAwLjAwMDAwIGggNy41OTQ5NCB2IDYwLjAwMDAwIGggLTcuNTk0OTQgeiAiIHN0eWxlPSJmaWxsOiAjMDAwMDAwIi8+PHRleHQgc3R5bGU9ImZpbGw6ICMwMDAwMDA7IGZvbnQtZmFtaWx5OiAmcXVvdDttb25vc3BhY2UmcXVvdDs7IGZvbnQtc2l6ZTogMTIuMDAwMDBweCIgeD0iMC4wMDAwMCIgeT0iMC4wMDAwMCI+PC90ZXh0Pjwvc3ZnPg==" /></div>
|
||||
</div>
|
||||
<div class="lbl-qr"><img alt="二维码" src="data:image/png;base64,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" /></div>
|
||||
</div>
|
||||
<div class="lbl-ftr"><span>岩美酒行</span><span>40×20mm</span></div>
|
||||
</div>
|
||||
<div class="lbl-note">40×20mm 热敏价签:二维码溯源 + 底部整幅一维条码(Code128,内容=商品编号,供扫码枪扫码出库)。原型为示意布局,实际由客户端热敏/PDF/Web 三路渲染。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot"><div class="btn ghost" onclick="closeOverlay('ovLabel')">关闭</div></div>
|
||||
</div></div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="shell.js"></script>
|
||||
@@ -206,6 +249,7 @@ function openAdd(){ document.getElementById('addKind').textContent='标签打印
|
||||
function pickKind(e){ openMenu(e.currentTarget, KINDS.map(v=>({v,label:v,sel:document.getElementById('addKind').textContent===v})), v=>{ document.getElementById('addKind').textContent=v; closeMenus(); }); }
|
||||
function pickConn(e){ openMenu(e.currentTarget, CONNS.map(v=>({v,label:v,sel:document.getElementById('addConn').textContent===v})), v=>{ document.getElementById('addConn').textContent=v; closeMenus(); }); }
|
||||
function closeOverlay(id){ document.getElementById(id).classList.remove('show'); }
|
||||
function openLabelPreview(){ document.getElementById('ovLabel').classList.add('show'); }
|
||||
|
||||
|
||||
|
||||
|
||||
+81
-10
@@ -29,17 +29,69 @@ rm -rf /tmp/jiu-web-new
|
||||
mkdir -p /tmp/jiu-web-new
|
||||
tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1
|
||||
|
||||
setup_ssh
|
||||
echo "==> deploy-client: uploading files to ${TARGET_HOST}"
|
||||
${SCP} "$VERSION_YAML" "${TARGET_USER}@${TARGET_HOST}:/tmp/version.yaml"
|
||||
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
|
||||
/tmp/jiu-web-new/ "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-web-new/"
|
||||
# --- 版本一致性校验(发版前,本地产物)--------------------------------------
|
||||
# 部署错版事故的通用防线:确认将要上线的产物版本 == 本次发版 tag 版本。
|
||||
# ① version.yaml 的 version:(后端 /version 与官网下载页实时读它)
|
||||
# ② Flutter web 产物 version.json 的 version(自动更新客户端据此判断新版)
|
||||
# 任一不符即中止发版(exit 1),绝不把错版切上线。
|
||||
TAG_VER="$(ver_from_tag "$TAG")"
|
||||
# 解析器均把路径经 argv 传入、用 chr(34)/chr(39) 规避引号字面量,免嵌套引号踩坑。
|
||||
YAML_VER="$(python3 -c 'import sys
|
||||
for line in open(sys.argv[1], encoding="utf-8"):
|
||||
if line.startswith("version:"):
|
||||
print(line.split(":", 1)[1].strip().strip(chr(34)).strip(chr(39))); break' "$VERSION_YAML")"
|
||||
WEB_VER="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("version", ""))' /tmp/jiu-web-new/version.json 2>/dev/null || echo '')"
|
||||
echo "==> deploy-client: 版本校验 tag=${TAG_VER} version.yaml=${YAML_VER} web=${WEB_VER}"
|
||||
if [ "$YAML_VER" != "$TAG_VER" ]; then
|
||||
echo "ERROR: version.yaml 版本(${YAML_VER}) != 发版 tag(${TAG_VER}),中止发版。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$WEB_VER" ] && [ "$WEB_VER" != "$TAG_VER" ]; then
|
||||
echo "ERROR: Flutter web 产物版本(${WEB_VER}) != 发版 tag(${TAG_VER}),中止发版。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Desktop / mobile installers (served from /downloads/ by nginx). Guarded —
|
||||
# may be absent in a partial manual deploy.
|
||||
[ -f dist/jiu-windows-x64-setup.exe ] && ${SCP} dist/jiu-windows-x64-setup.exe "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-windows-x64-setup.exe" || true
|
||||
[ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-macos-x64.zip" || true
|
||||
[ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-android.apk" || true
|
||||
setup_ssh
|
||||
RSYNC_SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
|
||||
|
||||
# 经隧道推文件到 ali 偶发中断,大安装包尤甚(见 client-v1.1.10 run #362 的
|
||||
# Deploy → Ali 失败:android 83M scp 传到一半连接 reset,整个 job 挂 14min)。
|
||||
# 用 rsync --partial(断点续传)+ 重试替代 scp:断了从断点续传、不整段重来。
|
||||
# 单文件推送,重试 5 次;安装包已压缩(apk/zip/exe)故不加 -z。
|
||||
push_file() { # push_file <local-file> <remote-abs-path>
|
||||
local src="$1" dst="$2" i
|
||||
for i in 1 2 3 4 5; do
|
||||
if rsync -av --partial --timeout=120 -e "$RSYNC_SSH" \
|
||||
"$src" "${TARGET_USER}@${TARGET_HOST}:${dst}"; then
|
||||
return 0
|
||||
fi
|
||||
echo "!! push_file: ${src} 传输中断(第 ${i}/5 次),5s 后 --partial 续传重试…" >&2
|
||||
sleep 5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> deploy-client: uploading files to ${TARGET_HOST}"
|
||||
# version.yaml 承载版本号/更新日志,必达;5 次仍失败则 set -e 中止发版。
|
||||
push_file "$VERSION_YAML" /tmp/version.yaml
|
||||
|
||||
# Flutter web 目录同步(--delete 保持一致;--partial 续传;重试 3 次)。web 是
|
||||
# 发版核心产物,3 次仍失败即中止(避免把不完整目录切上线)。
|
||||
web_ok=0
|
||||
for i in 1 2 3; do
|
||||
if rsync -avz --delete --partial --timeout=120 -e "$RSYNC_SSH" \
|
||||
/tmp/jiu-web-new/ "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-web-new/"; then
|
||||
web_ok=1; break
|
||||
fi
|
||||
echo "!! web 同步中断(第 ${i}/3 次),5s 后 --partial 续传重试…" >&2; sleep 5
|
||||
done
|
||||
[ "$web_ok" = 1 ] || { echo "ERROR: web 同步 3 次均失败,中止发版。" >&2; exit 1; }
|
||||
|
||||
# Desktop / mobile installers(nginx 从 /downloads/ 提供)。文件缺失可跳过
|
||||
# (部分手动发版);传输 5 次仍失败仅告警不阻断 web/version 切换(下次发版补)。
|
||||
[ -f dist/jiu-windows-x64-setup.exe ] && { push_file dist/jiu-windows-x64-setup.exe /tmp/jiu-windows-x64-setup.exe || echo "!! windows 安装包 5 次仍失败,跳过。" >&2; } || true
|
||||
[ -f dist/jiu-macos-x64.zip ] && { push_file dist/jiu-macos-x64.zip /tmp/jiu-macos-x64.zip || echo "!! macos 安装包 5 次仍失败,跳过。" >&2; } || true
|
||||
[ -f dist/jiu-android.apk ] && { push_file dist/jiu-android.apk /tmp/jiu-android.apk || echo "!! android 安装包 5 次仍失败,跳过。" >&2; } || true
|
||||
|
||||
${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH'
|
||||
set -e
|
||||
@@ -64,6 +116,25 @@ rm -f /opt/jiu/downloads/jiu-windows-* /opt/jiu/downloads/jiu-macos-* /opt/jiu/d
|
||||
echo "Client deploy complete!"
|
||||
ENDSSH
|
||||
|
||||
# --- 版本一致性校验(发版后,线上实测)--------------------------------------
|
||||
# 切换完成后实测线上 /version(后端每请求实时读 version.yaml),确认真正上线的
|
||||
# 版本 == 发版 tag。不符即判定部署失败(exit 1)。域名 fronts 当前 active 机器。
|
||||
VERIFY_URL="${VERIFY_URL:-https://jiu.51yanmei.com}"
|
||||
LIVE_VER=""
|
||||
for i in 1 2 3 4 5; do
|
||||
LIVE_VER="$(curl -fsS --max-time 15 "${VERIFY_URL}/version" 2>/dev/null \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('version',''))" 2>/dev/null || echo '')"
|
||||
[ "$LIVE_VER" = "$TAG_VER" ] && break
|
||||
echo "!! 线上 /version=${LIVE_VER:-<空>} 尚未更新到 ${TAG_VER}(第 ${i}/5 次),3s 后重试…" >&2
|
||||
sleep 3
|
||||
done
|
||||
if [ "$LIVE_VER" != "$TAG_VER" ]; then
|
||||
echo "ERROR: 部署后线上 ${VERIFY_URL}/version 版本(${LIVE_VER:-<空>}) != 发版 tag(${TAG_VER}),部署失败。" >&2
|
||||
teardown_ssh
|
||||
exit 1
|
||||
fi
|
||||
echo "==> deploy-client: 线上版本校验通过 /version=${LIVE_VER}"
|
||||
|
||||
teardown_ssh
|
||||
rm -rf /tmp/jiu-web-new
|
||||
echo "==> deploy-client: done"
|
||||
|
||||
@@ -35,6 +35,32 @@ MSG
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- client/ 子树 SHA 强校验(根因防线)---------------------------------------
|
||||
# winstage 的 GitHub Release 是**构建当时** github HEAD 上打的轻量 tag,只按版本名
|
||||
# 匹配无法保证它构建自本次发版的同一提交。历史事故(client-v1.1.10):winstage-v1.1.10
|
||||
# 打在了扫码提交的父提交上 → 发出「版本号对但缺扫码功能」的错版 Windows 包。
|
||||
# 这里比对**发版提交**与**winstage 提交**的 `client/` 子树 SHA(git 内容寻址,同源提交
|
||||
# 必然同 tree),不一致直接硬失败,逼迫先从发版提交重建 winstage,绝不发出错版。
|
||||
REL_TREE=$(git rev-parse HEAD:client)
|
||||
git fetch --depth=1 "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPO}.git" \
|
||||
"refs/tags/${STAGE_TAG}" >/dev/null 2>&1 || {
|
||||
echo "ERROR: 无法从 GitHub 拉取 ${STAGE_TAG} 提交以校验 client/ 子树。" >&2
|
||||
exit 1
|
||||
}
|
||||
WIN_TREE=$(git rev-parse FETCH_HEAD:client)
|
||||
if [ "$REL_TREE" != "$WIN_TREE" ]; then
|
||||
cat >&2 <<MSG
|
||||
ERROR: ${STAGE_TAG} 的 Windows 包构建自**错误的提交**,client/ 子树与本次发版不一致:
|
||||
发版提交(${TAG}) client/ tree = ${REL_TREE}
|
||||
winstage 提交 client/ tree = ${WIN_TREE}
|
||||
→ winstage-v${VER} 是在别的提交上打的 tag,发出去会是「版本号对但代码错版」的包。
|
||||
修复:在 GitHub 用**本次发版的提交**重建 Windows 包,再重跑发版:
|
||||
gh workflow run windows.yml -f ver=${VER} (仓库 ${GH_REPO},确保其 main 已含本次发版提交)
|
||||
MSG
|
||||
exit 1
|
||||
fi
|
||||
echo "==> fetch-windows-staged: client/ 子树校验通过(tree ${REL_TREE}),winstage 与发版同源。"
|
||||
|
||||
# 取同名资产的 GitHub API asset url(用 Accept: octet-stream 拉二进制,302 跳 codeload,走 github.com 不过 frps)
|
||||
aid=$(printf '%s' "$info" | python3 -c "import sys,json; print(next((a['id'] for a in json.load(sys.stdin).get('assets',[]) if a['name']=='${NAME}'), ''))")
|
||||
if [ -z "$aid" ]; then
|
||||
|
||||
@@ -44,8 +44,13 @@ for line in raw.splitlines(keepends=True):
|
||||
if line.startswith('version:'):
|
||||
out.append('version: "%s"\n' % ver)
|
||||
elif line.startswith('build_number:'):
|
||||
# build_number 由版本号公式派生(major*10000+minor*100+patch),与 pubspec
|
||||
# 的 CFBundleVersion 同源、天然单调。旧法「读仓库 version.yaml +1」有 bug:
|
||||
# CI/本地发版都不回写仓库那份,它永远冻结 → 每次都算出同一个值(见 1.1.9/1.1.10
|
||||
# 均为 5 的历史)。改公式后无状态依赖,CI 与 local-release 结果一致。
|
||||
try:
|
||||
b = int(line.split(':', 1)[1].strip()) + 1
|
||||
_mj, _mn, _pt = (int(x) for x in ver.split('.')[:3])
|
||||
b = _mj * 10000 + _mn * 100 + _pt
|
||||
except Exception:
|
||||
b = 1
|
||||
out.append('build_number: %d\n' % b)
|
||||
|
||||
@@ -381,8 +381,12 @@ for line in raw.splitlines(keepends=True):
|
||||
if line.startswith('version:'):
|
||||
out.append('version: "%s"\n' % ver)
|
||||
elif line.startswith('build_number:'):
|
||||
# build_number 由版本号公式派生(major*10000+minor*100+patch),与 pubspec
|
||||
# 的 CFBundleVersion(BUILD)同源、天然单调。旧法「读仓库 version.yaml +1」有 bug:
|
||||
# 仓库那份从不回写、永远冻结 → 每次都算出同一个值(1.1.9/1.1.10 均为 5)。
|
||||
try:
|
||||
b = int(line.split(':', 1)[1].strip()) + 1
|
||||
_mj, _mn, _pt = (int(x) for x in ver.split('.')[:3])
|
||||
b = _mj * 10000 + _mn * 100 + _pt
|
||||
except Exception:
|
||||
b = 1
|
||||
out.append('build_number: %d\n' % b)
|
||||
|
||||
Reference in New Issue
Block a user