Compare commits

...

16 Commits

Author SHA1 Message Date
wangjia 4ed2c612a9 chore: release client-v1.1.15
Deploy Client / build-windows (push) Successful in 2m1s
Deploy Client / build-client-web (push) Successful in 1m3s
Deploy Client / build-macos (push) Successful in 2m41s
Deploy Client / build-android (push) Successful in 1m41s
Deploy Client / build-ios (push) Successful in 3m35s
Deploy Client / release-deploy-client (push) Successful in 3m31s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 17:31:04 +08:00
wangjia 78e6a4c192 fix(client): 热敏打印价签中文字体清晰化(NotoSansSC+4x超采样+覆盖率投票二值化,字号上调)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 17:31:00 +08:00
wangjia 801b8528ec feat(client): 库存规格/系列筛选改可搜索单选下拉、仓库单选,修复下拉过宽过高溢出
原型与代码同提交(design-first):下拉宽度锚定触发器宽 max(宽,220)、列表 264 封顶超出滚动、底部计数常驻。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 17:30:55 +08:00
wangjia a1a3118c88 fix(ci): download_release_assets 改用 browser_download_url + 下载后大小校验
Forgejo 的 /releases/{id}/assets/{asset_id} API 端点返回 JSON 元数据(约245字节)
而非二进制,导致下载到坏 web.tar.gz、部署时 tar 报 Unrecognized archive format。
改用 asset.browser_download_url(http→https 跟随重定向 + token)拉真正的文件,
并在下载后校验字节数与声明 size 一致,不符即当场失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 12:48:17 +08:00
wangjia 2797c998f8 fix(ci): 发版 create_release/upload_asset 改幂等,重跑不再 409 崩
根因:release-client.sh 建 Release 非幂等——首跑已建好 Release+传齐产物但
deploy 未跑完时,retry 再 POST 同 tag 的 Release,Forgejo 回 409
("Release is has no Tag"),整个 release-deploy 在部署前就硬失败,发版卡死。

- create_release:撞 409 时改为按 tag 取回既有 Release 复用其 id 继续。
- upload_asset:同名 asset 已存在时先删旧再重传,避免重名 409。

重跑发版从此安全;免重编上线用 Manual Deploy(manual.yml)拉既有 Release 产物部署。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 12:21:06 +08:00
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
wangjia c3a127485f chore: release client-v1.1.12
Deploy Client / build-client-web (push) Successful in 5m19s
Deploy Client / build-macos (push) Successful in 7m36s
Deploy Client / build-android (push) Successful in 6m18s
Deploy Client / build-ios (push) Successful in 6m2s
Deploy Client / build-windows (push) Successful in 5m27s
Deploy Client / release-deploy-client (push) Successful in 10m49s
价签标签两栏布局改版 + 生产日期修复 + 模板预览接真实渲染 +
Windows/macOS 应用内更新进度条修复。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-27 17:52:03 +08:00
wangjia eb7b0edc96 chore: release client-v1.1.11
Deploy Client / build-windows (push) Successful in 54s
Deploy Client / build-client-web (push) Successful in 5m43s
Deploy Client / build-macos (push) Successful in 7m19s
Deploy Client / build-android (push) Successful in 7m10s
Deploy Client / build-ios (push) Successful in 6m14s
Deploy Client / release-deploy-client (push) Successful in 13m3s
修复 Windows 版因构建流程缺陷未包含扫码出库功能(github-mode winstage
校验缺失所致,本版已由 fetch-windows-staged.sh 的 client/ 子树 SHA 校验兜底)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-26 16:40:09 +08:00
wangjia 6d64a9bfbf fix(devops): 发版加版本一致性硬校验,杜绝错版 Windows 包/错版部署
根因:github 模式的 windows 构建按版本名(winstage-vX)拉包,不校验提交,
winstage tag 可能打在错误提交上 → 发出「版本号对但代码错版」的包
(client-v1.1.10 缺扫码功能事故)。

- fetch-windows-staged.sh:比对发版提交与 winstage 提交的 client/ 子树 SHA,
  不一致硬失败并提示从发版提交重建 winstage。
- deploy-client.sh:发版前校验 version.yaml + web version.json 版本==tag,
  发版后实测线上 /version==tag(重试5次),任一不符即部署失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-26 16:14:07 +08:00
wangjia fc15a34a77 fix(ci): deploy-client 推 ali 改用 rsync --partial+重试替代 scp
client-v1.1.10 run #362 的 Deploy → Ali 失败:安装包(android 83M 等)经 frp/
pangolin 隧道 scp 传到一半连接 reset,scp 无续传→整个 job 挂 14min。改为:
- 新增 push_file():rsync -av --partial --timeout=120 + 重试 5 次,断点续传;
- version.yaml 用 push_file(必达,失败即 set -e 中止);
- web 目录 rsync 加 --partial+重试 3 次,失败即中止(不切不完整目录);
- 三个安装包用 push_file,5 次仍失败仅告警不阻断 web/version 切换。
本机手动补发 1.1.10 时正是用 rsync --partial 续传成功补上 android apk。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-26 02:43:33 +08:00
wangjia 7d0595105f fix(ci): version.yaml 的 build_number 改用版本号公式派生
旧法读仓库 backend/config/version.yaml 的 build_number 再 +1,但 CI 与
local-release 部署时都不回写仓库那份,它永远冻结 → 每次发版都算出同一个值
(1.1.9 / 1.1.10 线上 build_number 均为 5)。改用 major*10000+minor*100+patch
(与 pubspec CFBundleVersion 同源、天然单调),无状态依赖,CI 与本机结果一致。
自更新按版本字符串判断,build_number 仅展示,历史 5 无害。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-26 01:56:17 +08:00
24 changed files with 939 additions and 422 deletions
+11
View File
@@ -98,6 +98,9 @@ jobs:
build-ios:
needs: build-android
runs-on: mac
# iOS TestFlight 上传易抖/易卡,按 CLAUDE.md「iOS 不阻塞发版」:本 job 失败
# 不拖垮整个 run,更不阻断下面的 release-deploy(它已改为只校验 4 个真实平台)。
continue-on-error: true
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
@@ -159,6 +162,14 @@ jobs:
release-deploy-client:
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
env:
GOPROXY: https://goproxy.cn,direct
+38
View File
@@ -5,6 +5,44 @@ 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.15] - 2026-08-28
### 改进
- 库存管理「规格 / 系列」筛选改为可搜索单选下拉,长列表可直接打字过滤快速定位;「仓库」筛选为单选下拉,操作更顺手。
- 热敏打印机(标签机)打印商品价签的中文字体更清晰锐利、字号整体调大,不再发虚模糊。
### 修复
- 修复库存筛选下拉框过宽、列表过高溢出屏幕的问题:现在下拉宽度贴合筛选按钮,超长列表在框内滚动、底部结果计数常驻可见。
## [1.1.14] - 2026-08-28
### 改进
- 商品价签(标签)版式优化:品名与下方字段在左栏居中排布,编号+度数一行、规格+日期一行,更整齐易读。
- 去掉价签底部多余的米色页脚,一维条码与二维码放大填满纸面,扫码更清晰。
## [1.1.13] - 2026-08-27
### 改进
- 扫码出库免按回车:扫码枪扫完商品二维码后自动加入明细,无需再手动敲一下回车。
### 修复
- 修复热敏打印机(得力等 TSPL 标签机)打印商品价签时字体重叠、且底部多出一行酒行名称的问题;现在热敏打印效果与屏幕预览完全一致,不再失真。
## [1.1.12] - 2026-08-27
### 改进
- 商品价签(标签)改版为两栏布局:左侧品名 / 编号 / 度数规格 / 生产日期 / 一维条码,右侧二维码占满整个高度,扫码更清晰;字段间距与二维码大小均已优化。
- 设置 →「打印模板 · 商品价签」的「预览」现可直接查看真实标签样式(此前为占位提示)。
### 修复
- 修复从「商品详情」和「基础数据」页打印标签时,生产日期不显示的问题(从入库 / 出库单打印此前已正常)。
- 修复 Windows / macOS 版应用内更新时,下载进度条一直卡在 0% 的问题;现在能正常显示下载进度。
## [1.1.11] - 2026-08-26
### 修复
- 修复 Windows 版因构建流程缺陷未包含「扫码出库」功能的问题;升级后 Windows 用户即可使用扫码枪扫码出库(macOS / 网页 / 安卓版此前已正常)。
## [1.1.10] - 2026-08-25
### 新功能
+6 -8
View File
@@ -1,22 +1,20 @@
/// 集中管理应用配置,通过 --dart-define=KEY=value 注入环境变量
///
/// 开发默认值http://localhost:8080
/// 生产部署示例flutter run \
/// --dart-define=BASE_URL=http://192.168.1.100:8080 \
/// --dart-define=SHOP_NAME=强朋友名酒行 \
/// --dart-define=SHOP_ADDRESS=贵州省贵阳市云岩区中华北路88号 \
/// --dart-define=SHOP_PHONE=0851-12345678
/// 默认值 = 线上服务(jiu.51yanmei.com):本地 flutter run 不带 define 也连生产。
/// 需连本地后端时显式覆盖flutter run \
/// --dart-define=BASE_URL=http://localhost:8080 \
/// --dart-define=PUBLIC_URL=http://localhost:8081
class AppConfig {
const AppConfig._();
static const _baseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: 'http://localhost:8080',
defaultValue: 'https://jiu.51yanmei.com',
);
static const _publicUrl = String.fromEnvironment(
'PUBLIC_URL',
defaultValue: 'http://localhost:8081',
defaultValue: 'https://jiu.51yanmei.com',
);
static const shopName = String.fromEnvironment(
+12 -3
View File
@@ -26,13 +26,22 @@ Future<void> downloadAndInstall(
String url, void Function(double) onProgress) async {
final tmp = await getTemporaryDirectory();
final dio = Dio();
// 关键:显式拒绝 gzipAccept-Encoding: identity)。
// nginx 会对 application/octet-stream(.exe) 做即时 gzip,压缩响应走 chunked 传输、
// 不带 Content-Length,导致 onReceiveProgress 的 total 恒为 -1、进度卡在 0
//macOS 的 .zip 属 application/zip 不被 gzip,故一直正常)。
// identity 让服务端回传原始字节 + Content-Lengthtotal 才有效。
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]);
+259 -262
View File
@@ -3,7 +3,7 @@ import 'dart:convert' show ascii;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ffi';
import 'package:charset_converter/charset_converter.dart';
import 'package:barcode/barcode.dart' as bc;
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
show
@@ -14,8 +14,7 @@ import 'package:win32/win32.dart'
EndPagePrinter,
EndDocPrinter,
ClosePrinter,
DOC_INFO_1,
WideCharToMultiByte;
DOC_INFO_1;
import 'package:flutter/foundation.dart'
show kIsWeb, debugPrint, visibleForTesting;
import 'package:flutter/services.dart';
@@ -48,8 +47,8 @@ String _fileTs() {
// ── 设计色彩 token ──────────────────────────────────────────────────────────
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 _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 footer text
const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 抬头反白店名
const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 编号 / 三字段小字
const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
@@ -155,28 +154,48 @@ 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(
fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
textAlign: center ? ui.TextAlign.center : ui.TextAlign.left,
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(
fontFamily: 'NotoSansSC',
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
maxLines: 1,
@@ -196,140 +215,144 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
return size;
}
/// 组装标签左列名下两行副字段(居中排布):
/// 行1 = 编号 + 度数(型号) 行2 = 规格(版本) + 生产日期
/// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
/// 各字段为空则跳过(该行可只剩一个字段或整行消失);日期截断到 10 字符。四路渲染共用。
List<String> _labelSubFields(
String code, String? series, String? spec, String? productionDate) {
final date = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10
? productionDate.substring(0, 10)
: productionDate)
: '';
final line1 = [
if (code.isNotEmpty) code,
if ((series ?? '').isNotEmpty) series!,
].join(' ');
final line2 = [
if ((spec ?? '').isNotEmpty) spec!,
if (date.isNotEmpty) date,
].join(' ');
return [
if (line1.isNotEmpty) line1,
if (line2.isNotEmpty) line2,
];
}
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
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,
bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
}) async {
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const scale = 2; // 2× 超采样,内部以 640×320 绘制
const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
const sw = w * scale, sh = h * scale;
final recorder = ui.PictureRecorder();
final canvas =
ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
// 纸面固定配色(对齐设计原型 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 编号 / 三字段
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFFFFFF)); // ds-ignore: 打印画布纸面固定色
ui.Paint()..color = cWhite);
// 右侧二维码(解码后端 PNG 直接画上)
const qrS = 124.0;
const qrTop = 8.0;
const qrLeft = w - qrS - 4; // 192
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),
ui.Paint());
} catch (e) {
debugPrint('[label] QR 解码失败: $e');
// ── 深蓝店名头 ──────────────────────────────────────────────
const headerH = 24.0;
canvas.drawRect(
ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy);
_drawText(canvas, shop, 10, 4, 14, w - 20, bold: true, color: cCream);
// ── 主体两列布局(无页脚:主体直抵纸底,2026-08-27 删掉底部米色尺寸条)────────
const bodyTop = headerH; // 24
const double bodyBot = 160.0; // 直抵纸底 h(原到页脚顶 145),二维码/条码顺势放大
const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间
const double colGap = 6.0; // 左列与二维码列间距(原 10,收小)
// 右列:二维码略放大,占主体近满高(正方形),2026-08-28 用户口径「二维码调大一点」
const double qrPad = 1.0;
const double qrSize = (bodyBot - bodyTop) - 2 * qrPad; // 134
const double qrTop = bodyTop + qrPad; // 25
const double qrLeft = w - padH - qrSize; // 178
if (drawQr) {
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, 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; // 172
const double leftW = leftRight - leftX; // 164
// 字号固定:只有商品名按宽度自适应,其余行均固定,杜绝自动放大
const double kShopSize = 17.0; // 酒行名,粗体
const double kSmallSize = 13.0; // 编号 / 度数 / 日期,统一小字
// 商品名:仅按宽度适配,上限 26px,下限 14px
// 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库
const double barH = 40.0;
const double barBottom = bodyBot - padV; // 154
const double barTop = barBottom - barH; // 114
if (drawBarcode) _drawBarcode(canvas, code, leftX, barTop, leftW, barH);
// 左列文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体水平居中、
// 竖直均分。2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
const double infoTop = bodyTop + padV; // 30
const double infoBot = barTop - 4; // 110
const double infoH = infoBot - infoTop; // 80
const double kSmallSize = 14.0; // 编号+度数 / 规格+日期(2026-08-28 调大一号 12→14
final double nameSize =
_fitFont(name, textMaxW, 1000, true, 26.0).clamp(14.0, 26.0);
final codeText = code.isNotEmpty ? '编号:$code' : '';
_fitFont(name, leftW, 1000, true, 18.0).clamp(12.0, 18.0);
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),
if (name.isNotEmpty) (name, nameSize, true), // 品名粗体
for (final s in subs) (s, kSmallSize, false), // 编号+度数 / 规格+日期
];
// 文字区域:整体略低于二维码顶部,底部延伸至扫码溯源文字下方
const double kTextTop = 16.0; // 比二维码顶部(8)低一些,整体下移
const double kTextBot = 150.0; // 延伸至底部,减少留白
const double kLineH = 1.35; // 行高倍数(字号 × 行高 = 该行占用高度)
const double kLineH = 1.3; // 行高倍数
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;
final double rawGap = n > 0 ? (infoH - totalTextH) / (n + 1) : 2.0;
final double gap = rawGap < 1.0 ? 1.0 : rawGap;
double yy = infoTop + gap;
for (final r in rows) {
_drawText(canvas, r.$1, lx, yy, r.$2, textMaxW, bold: r.$3);
_drawText(canvas, r.$1, leftX, yy, r.$2, leftW,
bold: r.$3, color: r.$3 ? cInk : cCode, center: true);
yy += r.$2 * kLineH + gap;
}
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640
}
/// UTF-8 → GBK 字节:
/// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件;
/// macOS 走 charset_converterCoreFoundation 原生支持 GBK)。
Uint8List _encodeGbk(String text) {
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 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 命令扫描不可靠)。
/// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与
/// 预览画布 100% 一致——彻底消除内置点阵字(TSS16/24)与预览 TrueType 度量不一致
/// 导致的字体重叠 / 失真。文字/抬头走光栅;条码(Code128)与二维码仍走原生
/// BARCODE / BITMAP 命令保证扫码可靠(光栅化后细条/密码易糊)。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
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,17 +361,27 @@ 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
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const wBytes = (w + 7) >> 3; // 40 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;
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
final qrRgba = qrBd!.buffer.asUint8List();
// ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)─────────
// 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化:
// 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。
// 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。
final img = await _renderLabelBitmap(
shop: shop,
name: name,
code: code,
subs: subs,
qrBytes: qrBytes,
drawBarcode: false,
drawQr: false,
);
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
final iw = img.width; // 12804× 超采样)
const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
const int subTotal = ss * ss; // 16 子像素/墨点
final buf = BytesBuilder();
buf.add(ascii.encode(
@@ -356,10 +389,44 @@ Future<bool> _printFlatLabelThermal({
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
));
// QR BITMAP 命令
// 整幅文字位图(mode 0 覆写整版):深蓝抬头→黑底反白,其余白底黑字,
// 均由亮度阈值自然还原,无需 REVERSE;bit 0=黑点,默认 0xFF=白。
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 ink = 0; // 该墨点内"是墨"的子像素数
for (int dy = 0; dy < ss; dy++) {
for (int dx = 0; dx < ss; dx++) {
final i = ((y * ss + dy) * iw + (x * ss + dx)) * 4;
if (rgba[i + 3] < 128) continue; // 透明子像素当白
final lum =
0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
if (lum < 128) ink++; // 暗且不透明 = 墨
}
}
// 覆盖率投票:≥40% 子像素是墨即打黑点。阈值偏低让细笔画不断(热敏易掉墨),
// 且不引入盒式平均的灰边模糊。
if (ink * 5 >= subTotal * 2) row[x >> 3] &= ~(0x80 >> (x & 7));
}
buf.add(row);
}
buf.add(ascii.encode('\r\n'));
// ── 右列二维码(原生 BITMAP,避免光栅降采样糊码):占主体整高、右对齐 ──
const qrW = 130, qrH = 130; // 2026-08-28 放大,对齐画布 QR134
const qrX = 320 - 8 - qrW; // 182
const bodyTop = 24, bodyBot = 160; // 无页脚:主体直抵纸底
const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 27
const qrWBytes = (qrW + 7) >> 3; // 13
final qrCodec = await ui.instantiateImageCodec(qrBytes,
targetWidth: qrW, targetHeight: qrH);
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,'));
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++) {
final i = (yy * qrW + xx) * 4;
final a = qrRgba[i + 3];
@@ -372,46 +439,23 @@ 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', '扫码溯源');
// 左侧文字(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' : '';
// (文本, 字体, 字符高度点数)
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));
// 在 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;
int yPos = kTextTop + gapSize;
for (final r in rows) {
await _addTextCmd(buf, kTextX, yPos, r.$2, r.$1);
yPos += r.$3 + gapSize;
// ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),narrow 自适应铺满居中 ──
const leftX = 8, leftRight = qrX - 6, leftW = leftRight - leftX; // 8..188, 180
const kBarH = 34;
const kBarY = bodyBot - 8 - kBarH; // 118
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'));
}
buf.add(ascii.encode('PRINT 1,1\r\n'));
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);
return true;
}
@@ -439,42 +483,24 @@ Future<void> printProductLabelImpl({
const labelW = 40.0 * PdfPageFormat.mm;
const labelH = 20.0 * PdfPageFormat.mm;
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(code, 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 +508,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,96 +526,76 @@ 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),
// 商品编号(度数上方)
// 文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体居中。
// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text(name,
textAlign: pw.TextAlign.center,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font,
fontSize: nameFs,
fontWeight: pw.FontWeight.bold,
color: _ink,
letterSpacing: 0.3)),
for (final s in subs) ...[
pw.SizedBox(height: 2),
pw.Text(s,
textAlign: pw.TextAlign.center,
maxLines: 1,
overflow: pw.TextOverflow.clip,
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 ───────────────────────────────────────────────────────────
pw.Container(
height: footerH,
color: _cream,
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(footerLeft,
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font, fontSize: 3, color: _footnote)),
),
pw.Text(genTime,
style: pw.TextStyle(
font: font, fontSize: 3, color: _footnote)),
],
),
),
],
),
));
@@ -613,21 +619,12 @@ 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.code, label.series, label.spec, label.productionDate),
qrBytes: label.qrBytes!,
);
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
+56 -66
View File
@@ -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,35 @@ 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)
: '';
// 商品名过长时缩小字号(适当缩小,配合两行折行
: '';
// 左列名下两行副字段(居中):行1=编号+度数、行2=规格+日期(口径与预览、PDF、TSPL 一致
// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
final line1 = [
if (code.isNotEmpty) code,
if (seriesVal.isNotEmpty) seriesVal,
].join(' ');
final line2 = [
if (specVal.isNotEmpty) specVal,
if (dateVal.isNotEmpty) dateVal,
].join(' ');
final subs = <String>[
if (line1.isNotEmpty) line1,
if (line2.isNotEmpty) line2,
];
final subLines = subs.map((s) => '<div class="sub">$s</div>').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,77 +73,55 @@ 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; align-items: center; gap: 0.6mm; 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; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.specs {
display: flex; flex-direction: column; gap: 0.5mm;
.sub {
font-size: 5pt; color: #333; line-height: 1.15; text-align: center;
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.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; }
.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; }
/* ── Footer:联系方式 3mm ── */
.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;
}
/* 右:二维码占主体整高(正方形)*/
.qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; }
.qr img { width: 100%; height: 100%; object-fit: contain; display: block; }
</style>
</head>
<body>
@@ -139,21 +132,18 @@ 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</div>
$subLines
</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>
<script>
if (document.fonts && document.fonts.ready) {
@@ -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);
@@ -208,43 +208,43 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
if (v != null && mounted) _applyStatusFilter(v);
}
// ── 工具栏选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移 ──
Widget _multiChip(String label, List<String> options, Set<String> selected,
ValueChanged<Set<String>> onChanged) {
// ── 工具栏选 chip(规格/系列/仓库,2026-08-28 从多选改单选,用户口径 ──
// 状态仍存 Set<String>(沿用后端 setSpec/setSeries 列表口径 + 仓库客户端 contains),
// 单选即「至多一项」:选中 → {v},× → {}。
Widget _singleChip(String label, List<String> options, Set<String> selected,
ValueChanged<Set<String>> onChanged,
{String? searchHint}) {
return Builder(
builder: (chipCtx) => DsChip(
label: label,
value: selected.isEmpty
? null
: selected.length == 1
? selected.first
: '${selected.length}',
onTap: () => _openMultiFilter(chipCtx,
options: options, selected: selected, onChanged: onChanged),
value: selected.isEmpty ? null : selected.first,
onTap: () => _openSingleFilter(chipCtx,
options: options,
selected: selected,
onChanged: onChanged,
searchHint: searchHint),
onClear: () => onChanged({}),
),
);
}
// ── 选菜单(规格/系列/仓库chip 锚定) ──
void _openMultiFilter(
// ── 选菜单(规格/系列可搜索、仓库普通chip 锚定) ──
Future<void> _openSingleFilter(
BuildContext anchorContext, {
required List<String> options,
required Set<String> selected,
required ValueChanged<Set<String>> onChanged,
}) {
var cur = Set.of(selected);
showDsMultiMenu<String>(
String? searchHint, // 非空 → 可搜索单选下拉(规格/系列长名录)
}) async {
final v = await showDsMenu<String>(
anchorContext,
itemsBuilder: () => [
searchHint: searchHint,
items: [
for (final o in options)
DsMenuItem(value: o, label: o, selected: cur.contains(o)),
DsMenuItem(value: o, label: o, selected: selected.contains(o)),
],
onToggle: (v) {
cur.contains(v) ? cur.remove(v) : cur.add(v);
onChanged(Set.of(cur));
},
);
if (v != null && mounted) onChanged({v});
}
// ── 窄屏筛选 sheet(原型移动无列头漏斗 → 详搜钮开底部 sheet)──
@@ -937,21 +937,21 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
children: [
SizedBox(width: 260, child: searchField),
const SizedBox(width: AppDims.sp2),
_multiChip('规格', specOptions, _filterSpec, (v) {
_singleChip('规格', specOptions, _filterSpec, (v) {
setState(() => _filterSpec = v);
ref
.read(inventoryListProvider.notifier)
.setSpec(v.toList());
}),
}, searchHint: '搜索规格…'),
const SizedBox(width: AppDims.sp2),
_multiChip('系列', seriesOptions, _filterSeries, (v) {
_singleChip('系列', seriesOptions, _filterSeries, (v) {
setState(() => _filterSeries = v);
ref
.read(inventoryListProvider.notifier)
.setSeries(v.toList());
}),
}, searchHint: '搜索系列…'),
const SizedBox(width: AppDims.sp2),
_multiChip('仓库', warehouseOptions, _filterWarehouse,
_singleChip('仓库', warehouseOptions, _filterWarehouse,
(v) => setState(() => _filterWarehouse = v)),
const SizedBox(width: AppDims.sp2),
Builder(
@@ -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 ?? '',
@@ -185,6 +185,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Map<String, _PickerItem> _codeIndex = {};
String _lastScanCode = '';
int _lastScanMs = 0;
// 扫码枪=HID键盘极快连打字符后停顿;停顿 120ms 无新输入即自动触发,免手动回车。
Timer? _scanDebounce;
static const _fields = ['qty', 'sale'];
@@ -265,6 +267,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
_remarkCtrl.dispose();
_partnerFocus.dispose();
_warehouseFocus.dispose();
_scanDebounce?.cancel();
_scanCtrl.dispose();
_scanFocus.dispose();
for (final item in _items) {
@@ -354,6 +357,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
/// 扫码枪回车触发。解析编码→本地整仓索引命中(已加载项)→未命中再走服务端
/// 按编码精确查(覆盖库存超本地加载上限 1000 的大仓)→加行/累加数量。
Future<void> _onScan(String raw) async {
_scanDebounce?.cancel();
// 立即清空并保持聚焦,承接下一次扫码
_scanCtrl.clear();
_scanFocus.requestFocus();
@@ -697,6 +701,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
controller: _scanCtrl,
focusNode: _scanFocus,
hintText: '扫码枪扫码…',
onChanged: (v) {
// 扫码枪整串打完会停顿,停顿 120ms 视为一次完整扫入 → 自动触发。
// 带回车后缀的枪仍走 onSubmitted_onScan 内 300ms 同码去重防重复入单。
_scanDebounce?.cancel();
if (v.trim().isEmpty) return;
_scanDebounce = Timer(
const Duration(milliseconds: 120), () => _onScan(v));
},
onSubmitted: _onScan,
suffix: Icon(LucideIcons.scanLine,
size: 16, color: context.tokens.muted),
+243 -17
View File
@@ -6,6 +6,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/theme/app_tokens.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
@@ -25,28 +26,41 @@ class DsMenuItem<T> {
}
/// 单选菜单:点击项返回其 value 并关闭;点外部关闭返回 null。
///
/// [searchHint] 非空 → 升级为「可搜索单选」下拉(镜像原型 openSearchMenu / .combo-pop
/// 照 stock-out 客户筛选写法):顶部持久搜索框 + 边打边过滤 + 命中高亮 + 底部结果统计,
/// 点选即返回并关闭。宽度锚定触发器宽(`max(触发器宽, 220)` 固定,对齐原型 openSearchMenu
/// `w = max(r.width, 220)`)、列表 264 封顶,供规格/系列等长名录单选筛选复用。
Future<T?> showDsMenu<T>(
BuildContext anchorContext, {
required List<DsMenuItem<T>> items,
double minWidth = 168,
String? searchHint,
}) {
final rect = _anchorRect(anchorContext);
return Navigator.of(anchorContext).push<T>(_DsMenuRoute<T>(
anchorRect: rect,
minWidth: minWidth,
builder: (ctx) => _DsMenuPanel(
children: [
for (final it in items)
_DsMenuItemTile(
item: it,
onTap: () => Navigator.of(ctx).pop(it.value),
minWidth: searchHint != null ? math.max(minWidth, 220) : minWidth,
search: searchHint != null,
builder: (ctx) => searchHint != null
? _DsSearchSinglePanel<T>(
items: items,
hint: searchHint,
onPick: (v) => Navigator.of(ctx).pop(v),
)
: _DsMenuPanel(
children: [
for (final it in items)
_DsMenuItemTile(
item: it,
onTap: () => Navigator.of(ctx).pop(it.value),
),
],
),
],
),
));
}
/// 多选菜单(列设置 / 多选筛选):点击项切换选中、菜单保持打开;点外部关闭。
/// 多选菜单(列设置):点击项切换选中、菜单保持打开;点外部关闭。
/// [itemsBuilder] 每次切换后重建以刷新 ✓ 态;[onToggle] 通知外部改状态。
Future<void> showDsMultiMenu<T>(
BuildContext anchorContext, {
@@ -89,10 +103,12 @@ Rect _anchorRect(BuildContext anchorContext) {
class _DsMenuRoute<T> extends PopupRoute<T> {
final Rect anchorRect;
final double minWidth;
final bool search;
final WidgetBuilder builder;
_DsMenuRoute({
required this.anchorRect,
required this.minWidth,
this.search = false,
required this.builder,
});
@@ -109,7 +125,7 @@ class _DsMenuRoute<T> extends PopupRoute<T> {
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return CustomSingleChildLayout(
delegate: _DsMenuLayout(anchorRect, minWidth),
delegate: _DsMenuLayout(anchorRect, minWidth, search),
child: builder(context),
);
}
@@ -118,15 +134,23 @@ class _DsMenuRoute<T> extends PopupRoute<T> {
class _DsMenuLayout extends SingleChildLayoutDelegate {
final Rect anchor;
final double minWidth;
_DsMenuLayout(this.anchor, this.minWidth);
final bool search;
_DsMenuLayout(this.anchor, this.minWidth, this.search);
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final maxW = math.max(minWidth, constraints.maxWidth - 16);
final avail = constraints.maxWidth - 16;
final maxH = math.max(0.0, constraints.maxHeight - 16);
if (search) {
// 可搜索下拉:宽度锚定触发器宽(对齐原型 openSearchMenu `w = max(r.width, 220)`),
// tight 固定,避免 Expanded 搜索框撑满视口。触发器宽 → 弹层宽;窄 pill → 220。
final w = math.min(math.max(anchor.width, minWidth), avail);
return BoxConstraints(minWidth: w, maxWidth: w, maxHeight: maxH);
}
return BoxConstraints(
minWidth: math.min(math.max(anchor.width, minWidth), maxW),
maxWidth: maxW,
maxHeight: math.max(0, constraints.maxHeight - 16),
minWidth: math.min(math.max(anchor.width, minWidth), avail),
maxWidth: avail,
maxHeight: maxH,
);
}
@@ -144,7 +168,9 @@ class _DsMenuLayout extends SingleChildLayoutDelegate {
@override
bool shouldRelayout(_DsMenuLayout old) =>
old.anchor != anchor || old.minWidth != minWidth;
old.anchor != anchor ||
old.minWidth != minWidth ||
old.search != search;
}
/// .menusurface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。
@@ -224,3 +250,203 @@ class _DsMenuItemTile extends StatelessWidget {
);
}
}
/// 可搜索单选面板(镜像原型 .combo-pop + openSearchMenu,照 stock-out 客户筛选):
/// 顶部 .cp-search 持久搜索框 → .cp-list 列表(命中子串高亮、选中项尾部 ✓、点选即返回并关闭)
/// → .cp-foot 结果统计(匹配 X 条 · 共 N 条)。供工具栏规格/系列等长名录单选筛选复用。
class _DsSearchSinglePanel<T> extends StatefulWidget {
final List<DsMenuItem<T>> items;
final ValueChanged<T> onPick;
final String hint;
const _DsSearchSinglePanel({
required this.items,
required this.onPick,
required this.hint,
});
@override
State<_DsSearchSinglePanel<T>> createState() =>
_DsSearchSinglePanelState<T>();
}
class _DsSearchSinglePanelState<T> extends State<_DsSearchSinglePanel<T>> {
final _ctrl = TextEditingController();
final _focus = FocusNode();
String _kw = '';
@override
void initState() {
super.initState();
WidgetsBinding.instance
.addPostFrameCallback((_) => _focus.requestFocus());
}
@override
void dispose() {
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final all = widget.items;
final q = _kw.trim().toLowerCase();
final hits =
q.isEmpty ? all : all.where((it) => it.label.toLowerCase().contains(q)).toList();
return Material(
color: Colors.transparent,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
boxShadow: [
BoxShadow(
color: t.shadow, offset: const Offset(0, 4), blurRadius: 14),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// .cp-search
Container(
padding: const EdgeInsets.fromLTRB(11, 9, 11, 9),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
Icon(LucideIcons.search, size: 14, color: t.faint),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _ctrl,
focusNode: _focus,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
cursorColor: t.primary,
onChanged: (v) => setState(() => _kw = v),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: widget.hint,
hintStyle: TextStyle(
fontSize: AppDims.fsBody, color: t.faint),
),
),
),
],
),
),
// .cp-listoverflow automax-height:264 → 超出滚动、foot 常驻可见;空 → .cp-empty
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 264),
child: hits.isEmpty
? Padding(
padding: const EdgeInsets.all(20),
child: Text('无匹配结果',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
)
: ListView.builder(
padding: const EdgeInsets.all(5),
shrinkWrap: true,
itemCount: hits.length,
itemBuilder: (c, i) => _item(t, hits[i]),
),
),
// .cp-foot
Container(
padding: const EdgeInsets.fromLTRB(12, 7, 12, 7),
decoration: BoxDecoration(
color: t.bg,
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: _foot(t, hits.length, all.length, q.isNotEmpty),
),
],
),
),
);
}
// .cp-item:点选即回调 onPick(其内 Navigator.pop 关闭菜单)→ 单选。
Widget _item(AppTokens t, DsMenuItem<T> it) {
final fg = it.selected ? t.primary : t.text;
return InkWell(
onTap: () => widget.onPick(it.value),
hoverColor: t.rowHover,
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Expanded(child: _highlight(t, it.label, fg, it.selected)),
if (it.selected) ...[
const SizedBox(width: 9),
Icon(LucideIcons.check, size: 15, color: t.primary),
],
],
),
),
);
}
// 命中子串高亮(.hlprimary fw700
Widget _highlight(AppTokens t, String name, Color fg, bool sel) {
final base = TextStyle(
fontSize: AppDims.fsBody,
color: fg,
fontWeight: sel ? FontWeight.w600 : FontWeight.w400);
final kw = _kw.trim();
final i =
kw.isEmpty ? -1 : name.toLowerCase().indexOf(kw.toLowerCase());
if (i < 0) {
return Text(name,
maxLines: 1, overflow: TextOverflow.ellipsis, style: base);
}
return RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(style: base, children: [
TextSpan(text: name.substring(0, i)),
TextSpan(
text: name.substring(i, i + kw.length),
style: base.copyWith(
color: t.primary, fontWeight: FontWeight.w700)),
TextSpan(text: name.substring(i + kw.length)),
]),
);
}
// .cp-footq 空 → 共 N 条;有关键字 → 匹配 X 条 · 共 N 条。数字 mono 加粗。
Widget _foot(AppTokens t, int hits, int total, bool filtered) {
final base = TextStyle(fontSize: AppDims.fsXs, color: t.muted);
final b = base.copyWith(
color: t.text,
fontWeight: FontWeight.w600,
fontFamily: 'JetBrainsMono');
return RichText(
text: TextSpan(style: base, children: [
if (filtered) ...[
const TextSpan(text: '匹配 '),
TextSpan(text: '$hits', style: b),
const TextSpan(text: ' 条 · 共 '),
TextSpan(text: '$total', style: b),
const TextSpan(text: ''),
] else ...[
const TextSpan(text: ''),
TextSpan(text: '$total', style: b),
const TextSpan(text: ''),
],
]),
);
}
}
@@ -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
View File
@@ -26,7 +26,7 @@ packages:
source: hosted
version: "2.13.1"
barcode:
dependency: transitive
dependency: "direct main"
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
+1
View File
@@ -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
View File
@@ -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`(与官网时间线同源),接口不可达回退只显当前版本。
+44 -2
View File
@@ -60,6 +60,22 @@
.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 抬头 / 墨黑正文),不随 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; align-items:center; gap:5px;}
.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-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-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-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 +112,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 +153,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">飞天茅台</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 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,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAAABHNCSVQICAgIfAhkiAAAAAFzUkdCAK7OHOkAAAk0SURBVHic7d2xbtNXHMXxk8oREqqUSJHiR6hASHFHJpzdOy/ATp4AZWZiI6/QuQ9AOnXJYCYvDB3JhAMtqJEQXf4sAan3uP0599jfj5Tt6urm+n/aP9Ivx5L0hZ+Vf85VZ2qeZVq497vCOznv4HOM/fnBvGwAHSHAQDACDAQjwEAwAgwEI8BAMAIMBCPAQDACDAQbGWuXkl4XnqUXR5L2i/aeGHs/NPf+sWitJH00Pvu5ubeDZ/A7ehgb7Mmr0LHBmXGOmbn3E/P3rLoTnkFGKTfWn0VrJemNuR5rQoCBYAQYCEaAgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWDOLLTrxTD7e9vmkk5u+xCDvWGet8WupB1J143rx8Y55pKOjfWPJZ0ae/dy3xv/DFYGeCLpUeH+ia5W+EOJu43rLo09l+Zc8WnoZ7nxzyCv0EAwAgwEI8BAMAIMBCPAQDACDAQjwEAwAgwEI8BAsMpJLHzLqVw9kPTAWO9Uxe6bI4YLY21lrSxuIMDr5czDziT9WnSOyVBd2up4iypdo/AK3S+3+tVdjw1AgIFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgBBgIRoCBYIxSrpdTc7owq1+dGeRtqZXdeAR4vdya06r5422pld14vEIDwQgwEIwAA8EIMBCMAAPBCDAQjAADwQgwEIwAA8EqJ7F6qRft5Rwyq1+dtS63VvbQWFt5blcvn33ZOSoDzDxsv9xa2Q+FZ6m08c8gr9Dr5VS/9lQT+9lY29O5Nx4BBoIRYCAYAQaCEWAgGAEGghFgIBgBBoIRYCAYAQaCOaOUR+b4XSpnRti1NzRCtq51VFbWnhlr3XM7eAZvcAK8L2m62nkwuBrusXWto7Ky9q2knxrXuud28AzewCs0EIwAA8EIMBCMAAPBCDAQjAADwQgwEIwAA8EIMBBsJOm32z5EMLcu1KlcPZA0M0rinOrXyr3dWtleql+Bf3Uh6UvRz/tO9r647UveJrxCr1dl5apT/Vq5N7Wya0SAgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgO0Y74dcZ19ZJmxNjznUy1KK2cvZ2OfWs7p28lHTZuPZY0tNhPLHFHUl/N679JOmZpDeN68fGuZfmZ1N533tGS+Z8eK5aVZ7ber6r5med+s9p4d6u89A7eWesdSplq1Xed+WddPGc8AoNBCPAQDACDAQjwEAwAgwEI8BAMAIMBCPAQDACDARzvuBbkl4b3zDfuu7rWqfedmxMq7ijfc7aA0kPjPVO5apbz7pbuHcl9yzOM+iMUi7MSbmFsdYdpXSe7+bxrrnx7fLVnDG2yrHBmTkiNyvc+y9jbU/Vr07VbuUzGDnO67xCL83/q24Dt0LVWe/ufV24dyXnLDyDN/BvYCAYAQaCEWAgGAEGghFgIBgBBoIRYCAYAQaCEWAg2MiYbLk3jL31UCv7UtJp41p3csepCz2U9MH4AuyxcY75UC3byq1+dVRX7VZ9lo6xuf+ZpLeNa907ue+c5darMVPnUIvvpCfbcCeVNb7uT/PevEIDwQgwEIwAA8EIMBCMAAPBCDAQjAADwQgwEIwAA8Eqa2WdasyH5jkqa1F7qZXdN8YXZY5SuqN9lRWqvZTUudXGTmWt+5w4FcHN411upWfl+J1Tz1qpsla2p9G+XsYdU5VVBKfWyvZSi1pZK4vNUVYRzL+BgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgO8MIXos/JP1s7D0xRi+PJT0dxsNaXBZWejozrgtJvzSu1TCO2jrN1tMs9OOhVrjFfKgUbuVU1lbu7d6JU5vsfpZnTgVx63zmuXEAV09zv87elXfSE2eu3b2TXvbuaT68+dy8QgPBCDAQjAADwQgwEIwAA8EIMBCMAAPBCDAQjAADwUZGlaZTt+qqrPSsHKV078QZL10W3rk72ufUyrpndtYvzAmoQ/MsjsqKYOe+sUaVY4MOd3S1l1pZ99zvC0cpKyuCm++bV2hsss+Fe3dREUyAgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgoy1qVqzg1pzuGbWy9yRdFNbhHjeulTmvPBnqXFs59axj80vmf5f0vHGtW23cXPu6wrmb9x5JemRsjP/myvhjBpnD+Etz76r/cO+bz5Rz5ktz/XPz93xmnsVZ6/6eTXiFBoIRYCAYAQaCEWAgGAEGghFgIBgBBoIRYCAYAQaCjYy1S0mvC8/SiyNzasbhjCS6dbj3jL0PhlbF1r3HxnTQQ+McMutZnbUyx0Urz+3WJjtjl13UnPbkVeidXBRWqL7rpJ51Zu697OTcZXiF3hxd1JyuwDm3+zu2/mHCKrq4bwIMBCPAQDACDAQjwEAwAgwEI8BAMAIMBCPAQDACDARzZqFdL4aK0dvmVr+mOjFmuN0KVaee1Z3hfizptHHtoaQPxhd33zHmincl7Ui6blzv1Mq6rOxUzf2eF86hOj/uuVNnoR1T8w6nhWepfE4qZ7i7uBNeoYFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgBBgIRoCBYJWjlPjWxBh3XJo1tM7elRWqLud3PJD0wFj/0ahCdkdAx8Y0lru388XujFLeUDlK6dxJT/fdRYXqCrWyTwrPUnnf7xmlxP+piwrVFc7xpugc1Vr/WIN/AwPJCDAQjAADwQgwEIwAA8EIMBCMAAPBCDAQjAADwZiFXi+n+nVsjlO+VHs9a2X1a2WN73yoxG3lnNu9k/tGZe0nSc+MybCzxnVS6Gxu6iy0I7X6taeq3V4qa8uywys0EIwAA8EIMBCMAAPBCDAQjAADwQgwEIwAA8EIMBCscpTSqQut1Ms5tCXVrwtzKmws6bJxrVu120tlrfsMWusTR+Qq9VIr6/70Uv3qjoBWjiQ6eqqsbcYr9Obopfo1VWRlLQEGghFgIBgBBoIRYCAYAQaCEWAgGAEGghFgIBgBBoI5s9BHw5jhppsU7r1nVJHuStqRdN24fmycYyLphbF+T9JV49qFWf3qzEK7VbuV53bmld37Pmnd3wnwfnF16Ta4Mv6Y4au7jetaQ6DhDI+M9Uvz3FUzy9PQc7v33XxmXqGBYAQYCEaAgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWD/ANlOLkkLzBmKAAAAAElFTkSuQmCC" /></div>
</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 +247,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'); }
+13 -8
View File
@@ -126,17 +126,17 @@ const ITEMS=[
const STATUS=['全部','在售','库存','已售'];
// 成本价/总价列 = 成本口径,仅管理员可见(非管理员两列隐藏、列设置菜单不出现,2026-07-06);原型=管理员形态
const COLS=[{key:'product',label:'商品',fixed:true},{key:'spec',label:'规格'},{key:'series',label:'系列'},{key:'prodDate',label:'生产日期',sort:true},{key:'qty',label:'库存',num:true,sort:true},{key:'cost',label:'成本价',num:true,sort:true},{key:'price',label:'总价',num:true,sort:true},{key:'batch',label:'批次号'},{key:'inDate',label:'入库时间',sort:true},{key:'supplier',label:'供应商'},{key:'status',label:'状态'},{key:'act',label:'操作',fixed:true}];
const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set(),sortBy:null,sortAsc:true,spec:new Set(),series:new Set(),wh:new Set()};
const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set(),sortBy:null,sortAsc:true,spec:'',series:'',wh:''};
function setActive(k){ document.querySelectorAll('.nav[data-k]').forEach(e=>e.classList.toggle('active',e.dataset.k===k)); }
function navTo(k){ setActive(k); const inv=document.querySelector('[data-view="inventory"]'), ph=document.querySelector('[data-view="placeholder"]'); if(k==='inventory'){inv.classList.add('active');ph.classList.remove('active');return;} inv.classList.remove('active');ph.classList.add('active'); const nav=NAVS.find(n=>n.k===k); document.getElementById('phTitle').textContent=nav.label; document.getElementById('phIcon').innerHTML=navSvg(nav.d); }
function visibleCols(){ return COLS.filter(c=>!state.hidden.has(c.key)); }
function filtered(){ const rows=ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.spec.size&&!state.spec.has(it.spec))return false; if(state.series.size&&!state.series.has(it.series))return false; if(state.wh.size&&!state.wh.has(it.supplier))return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q)&&!it.code.toLowerCase().includes(q))return false;} return true; }); if(state.sortBy&&SORT_VAL[state.sortBy]){const v=SORT_VAL[state.sortBy];rows.sort((a,b)=>{const x=v(a),y=v(b);return (x<y?-1:x>y?1:0)*(state.sortAsc?1:-1);});} return rows; }
function filtered(){ const rows=ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.spec&&it.spec!==state.spec)return false; if(state.series&&it.series!==state.series)return false; if(state.wh&&it.supplier!==state.wh)return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q)&&!it.code.toLowerCase().includes(q))return false;} return true; }); if(state.sortBy&&SORT_VAL[state.sortBy]){const v=SORT_VAL[state.sortBy];rows.sort((a,b)=>{const x=v(a),y=v(b);return (x<y?-1:x>y?1:0)*(state.sortAsc?1:-1);});} return rows; }
function render(){
document.getElementById('statusVal').textContent=state.status==='全部'?'':state.status;
// 选摘要(镜像 DsChip value):1 项=值本身,多项=「N 项」
[['spec','chipSpec','specVal'],['series','chipSeries','seriesVal'],['wh','chipWh','whVal']].forEach(([k,cid,vid])=>{ const s=state[k]; document.getElementById(vid).textContent=!s.size?'':(s.size===1?[...s][0]:`${s.size} 项`); document.getElementById(cid).classList.toggle('on',!!s.size); });
// 选摘要(镜像 DsChip value):选中值本身,未选为空
[['spec','chipSpec','specVal'],['series','chipSeries','seriesVal'],['wh','chipWh','whVal']].forEach(([k,cid,vid])=>{ const s=state[k]; document.getElementById(vid).textContent=s||''; document.getElementById(cid).classList.toggle('on',!!s); });
document.getElementById('chipStatus').classList.toggle('on',state.status!=='全部');
const cols=visibleCols();
document.getElementById('thead').innerHTML='<tr>'+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `<th class="${cls}"><span class="fh ${on?'filtered':''}" onclick="openFilter('${c.filter}',this)">${c.label} <svg class="funnel" viewBox="0 0 24 24"><use href="#i-ic22"/></svg></span></th>`;} if(c.sort){const on=state.sortBy===c.key; const ic=!on?'i-chevron-down':(state.sortAsc?'i-ic51':'i-chevron-down'); return `<th class="${cls}"><span class="sh ${on?'on':''}" onclick="toggleSort('${c.key}')">${c.label} <svg class="sort-ic" viewBox="0 0 24 24"><use href="#${ic}"/></svg></span></th>`;} return `<th class="${cls}">${c.label}</th>`; }).join('')+'</tr>';
@@ -155,14 +155,19 @@ function filterStatus(s){ state.status=s; state.page=1; navTo('inventory'); rend
// 列头排序:无→升→降→无 循环(真实实现为服务端全局排序)
function toggleSort(k){ if(state.sortBy!==k){state.sortBy=k;state.sortAsc=true;} else if(state.sortAsc){state.sortAsc=false;} else {state.sortBy=null;state.sortAsc=true;} state.page=1; render(); }
const SORT_VAL={qty:it=>it.qty,cost:it=>parseFloat(String(it.cost).replace(/[¥,]/g,''))||0,price:it=>parseFloat(String(it.price).replace(/[¥,]/g,''))||0,prodDate:it=>it.prodDate||'',inDate:it=>it.inDate||''};
function clearFilters(){ state.q='';state.status='全部';state.spec.clear();state.series.clear();state.wh.clear();state.page=1; document.getElementById('searchInput').value=''; render(); }
function clearFilters(){ state.q='';state.status='全部';state.spec='';state.series='';state.wh='';state.page=1; document.getElementById('searchInput').value=''; render(); }
function openFilter(kind,t){ openMenu(t,STATUS.map(v=>({v,label:v,sel:v===state.status})),v=>{ state.status=v; state.page=1; closeMenus(); render(); }); }
// 维度筛选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移):多选,点选即生效、菜单保持打开(镜像 showDsMultiMenu
// 维度筛选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移):均为单选(2026-08-28 用户口径)。
// 规格/系列名录长 → 可搜索单选下拉(openSearchMenu,照 stock-out 客户筛选写法);
// 仓库名录短 → 普通单选下拉(openMenu,无搜索框)。
const DIM_KEY={spec:'spec',series:'series',wh:'supplier'};
function openDimFilter(dim,t){ const key=DIM_KEY[dim]; const opts=[...new Set(ITEMS.map(it=>it[key]).filter(Boolean))]; openMenu(t,opts.map(v=>({v,label:v,sel:state[dim].has(v)})),v=>{ state[dim].has(v)?state[dim].delete(v):state[dim].add(v); state.page=1; render(); openDimFilter(dim,t); }); }
const DIM_PH={spec:'搜索规格…',series:'搜索系列…'};
function openDimFilter(dim,t){ const key=DIM_KEY[dim]; const opts=[...new Set(ITEMS.map(it=>it[key]).filter(Boolean))];
if(dim==='wh'){ openMenu(t,opts.map(v=>({v,label:v,sel:v===state.wh})),v=>{ state.wh=v; state.page=1; closeMenus(); render(); }); return; }
openSearchMenu(t,opts.map(v=>({v,label:v,sel:v===state[dim]})),v=>{ state[dim]=v; state.page=1; closeMenus(); render(); },{placeholder:DIM_PH[dim]}); }
// chip 尾部 × 单清(镜像 DsChip onClear:只清本维度,不展开菜单)
function clearDim(dim,e){ e.stopPropagation(); state[dim].clear(); state.page=1; render(); }
function clearDim(dim,e){ e.stopPropagation(); state[dim]=''; state.page=1; render(); }
function clearStatus(e){ e.stopPropagation(); state.status='全部'; state.page=1; render(); }
document.getElementById('chipStatus').onclick=e=>openFilter('status',e.currentTarget);
const COL_KEY='jiu-inv-hidden';
+1 -1
View File
@@ -56,7 +56,7 @@
// 可搜索菜单:视觉承袭 .combo-pop(顶部 .cp-search 显式搜索框 + 边打边过滤 + 结果统计),
// 供列表工具栏「供应商 / 客户」等长名录筛选复用(复用 atoms.css .combo-pop 族,零新增样式)。
// items: [{ v, label, sel, small? }]opts: { placeholder, minW, cls }。
// items: [{ v, label, sel, small? }]opts: { placeholder, minW, cls }。单选:点选后由 onPick 内自行 closeMenus。
function openSearchMenu(t, items, onPick, opts) {
closeMenus();
opts = opts || {};
+19 -4
View File
@@ -102,11 +102,26 @@ echo "==> compile-ios: built ${IPA}"
# TestFlight 是 App Store 独立分发渠道,上传本身易抖(重复 build 号 / ASC 临时错误);
# 其失败【不应阻断】web/mac/win/android 的整体发版(见 CLAUDE.md「iOS ... 不阻塞」)。
# IPA 已产出,上传失败仅告警,可稍后用 altool/Transporter 手动补传同一 IPA。
echo "==> compile-ios: uploading to TestFlight"
if xcrun altool --upload-app -f "$IPA" -t ios \
# altool 上传是【静默】的:上传 IPA 到 Apple 期间数分钟无任何输出。网络到 Apple
# 抖动/变慢时会长时间挂住 → 超过 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}" \
--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"
else
echo "!! compile-ios: TestFlight 上传失败(build ${BUILD}),不阻断发版;IPA=${IPA} 已产出,可稍后手动补传。" >&2
echo "!! compile-ios: TestFlight 上传失败或超时(build ${BUILD}),不阻断发版;IPA=${IPA} 已产出,可稍后手动补传。" >&2
fi
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
+81 -10
View File
@@ -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"
+26
View File
@@ -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
+36 -7
View File
@@ -50,6 +50,8 @@ PYEOF
}
# create_release <tag> <body_json_string> — sets global RELEASE_ID on success.
# 幂等:若该 tag 的 Release 已存在(重跑发版时 Forgejo 回 409),不再硬失败,
# 改为取回既有 Release 复用其 id 继续(配合 upload_asset 的覆盖上传,重跑安全)。
create_release() {
local tag="$1" body="$2" http_code resp
echo "==> release: creating Forgejo Release ${tag}"
@@ -60,6 +62,15 @@ create_release() {
-d "{\"tag_name\":\"${tag}\",\"name\":\"Release ${tag}\",\"body\":${body},\"draft\":false,\"prerelease\":false}")
resp=$(cat /tmp/release_resp.json)
echo "==> release: API response (HTTP ${http_code}): ${resp}"
if [ "$http_code" = "409" ]; then
# Release 已存在(重跑)→ 幂等复用:按 tag 取回既有 Release。
echo "==> release: 该 tag 的 Release 已存在,改为复用(幂等重跑)"
http_code=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${tag}")
resp=$(cat /tmp/release_resp.json)
echo "==> release: 既有 Release 查询 (HTTP ${http_code}): ${resp}"
fi
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "==> release: FAILED — HTTP ${http_code}" >&2
return 1
@@ -69,8 +80,18 @@ create_release() {
}
# upload_asset <file> — requires RELEASE_ID.
# 幂等:若同名 asset 已存在(重跑),先删旧再传新,避免 Forgejo 409 重名冲突。
upload_asset() {
local file="$1" code
local file="$1" code name existing_id
name=$(basename "$file")
existing_id=$(curl -ks -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
| python3 -c "import sys,json; a=json.load(sys.stdin); print(next((str(x['id']) for x in a if x.get('name')==sys.argv[1]), ''))" "$name" 2>/dev/null || true)
if [ -n "$existing_id" ]; then
echo "==> release: asset ${name} 已存在(id=${existing_id}),先删旧再重传"
curl -ks -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets/${existing_id}" >/dev/null || true
fi
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
@@ -94,15 +115,23 @@ ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
rel = json.load(open('/tmp/release-info.json'))
token = os.environ['FORGEJO_TOKEN']
url = os.environ['FORGEJO_URL']
repo = os.environ['GITEA_REPOSITORY']
rid = rel['id']
# 用每个 asset 的 browser_download_url 拉真正的二进制。
# 注意:Forgejo 的 /releases/{id}/assets/{asset_id} API 端点返回的是 JSON
# 元数据(几百字节)而非文件本身——曾导致下载到 245B 的坏 web.tar.gz、
# 部署时 tar 报 "Unrecognized archive format"。browser_download_url 是
# http,会 301 到 httpsurllib 默认跟随重定向;带上 token 以防私有仓 401。
for a in rel.get('assets', []):
u = f"{url}/api/v1/repos/{repo}/releases/{rid}/assets/{a['id']}"
req = urllib.request.Request(u, headers={'Authorization': f'token {token}'})
dl = a.get('browser_download_url')
if not dl:
raise SystemExit(f"asset {a.get('name')} 缺 browser_download_url")
req = urllib.request.Request(dl, headers={'Authorization': f'token {token}'})
with urllib.request.urlopen(req, context=ctx) as r, open(f"dist/{a['name']}", 'wb') as o:
o.write(r.read())
print('Downloaded:', a['name'])
got = os.path.getsize(f"dist/{a['name']}")
exp = a.get('size', 0)
print(f"Downloaded: {a['name']} ({got} bytes)")
if exp and got != exp:
raise SystemExit(f"下载大小不符: {a['name']} 期望 {exp} 实得 {got}")
PYEOF
}
+6 -1
View File
@@ -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)
+5 -1
View File
@@ -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)