Compare commits

..

21 Commits

Author SHA1 Message Date
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) Failing after 5m49s
Deploy Client / release-deploy-client (push) Has been skipped
价签标签两栏布局改版 + 生产日期修复 + 模板预览接真实渲染 +
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
wangjia 70f003cb1a ci: iOS TestFlight 上传失败不再阻断发版
Deploy Client / build-client-web (push) Successful in 57s
Deploy Client / build-windows (push) Successful in 35s
Deploy Client / build-macos (push) Successful in 8m23s
Deploy Client / build-android (push) Successful in 7m40s
Deploy Client / build-ios (push) Successful in 7m54s
Deploy Client / release-deploy-client (push) Failing after 14m32s
altool 上传易抖(重复 build 号/ASC 临时错误),不应拖住 web/mac/win/android。
IPA 仍产出,上传失败仅告警,可稍后手动补传。对齐 CLAUDE.md「iOS 不阻塞」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-25 20:38:00 +08:00
wangjia e4ba3e310e chore: release client-v1.1.10
Deploy Client / build-client-web (push) Successful in 54s
Deploy Client / build-windows (push) Failing after 3m2s
Deploy Client / build-macos (push) Successful in 2m32s
Deploy Client / build-android (push) Successful in 1m34s
Deploy Client / build-ios (push) Failing after 13m35s
Deploy Client / release-deploy-client (push) Has been skipped
- 扫码出库(扫码枪扫二维码,明细自动 +1)
- 只读模式下允许激活兑换券(自救出口)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-25 19:29:33 +08:00
wangjia 8fb81cc571 fix(client): 只读模式下允许激活兑换券(自救出口)
授权到期进入 readonly/locked 相位后,「兑换续期」按钮被全局
WriteGuard 误锁(置灰/隐藏),用户无法激活新授权码自救。后端
/license/* 本就豁免 LicenseGuard,去掉该按钮外层 WriteGuard 包裹;
role=readonly 仍由后端 ReadOnly() 403 兜底。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-25 19:11:26 +08:00
wangjia 22047b725c feat(client): 扫码出库(扫码枪扫二维码,明细自动 +1)
出库单新建页桌面端加「扫码枪扫码」框:扫商品二维码 URL → 取其中
的 ?code= → 本地整仓索引精确命中 → 明细自动新增/累加数量(每扫
+1)。本地未命中(大仓库存超前端加载上限 1000)时走服务端按编码
精确查兜底,复用现有库存搜索接口,无需新后端接口。

- 前端 stock_out_form_screen:扫码框 + _onScan + _codeIndex +
  parseScanCode + 服务端兜底 + 300ms 防抖 + 提示音;桌面专属,
  窄屏隐藏。
- 后端 product.go:二维码 URL 路径 /app/product/ → /product/
  统一到 SSR 公开页路由。
- 原型 stock-in.js/html:出库明细头同步加扫码框(design-first)。
- 测试 parse_scan_code_test(8 例)+ 出库表单桌面 golden 重生成。
- 设计/实现计划文档 docs/design/scan-stock-out-*。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-25 19:11:15 +08:00
wangjia b5715aec8d feat(release): 本地发版脚本 local-release.sh(绕过不稳的 CI 隧道)
在本机完成整套客户端发版:本地代码、本地编译签名、二进制暂存 dist/、直接
部署 ali(+iOS TestFlight)。取代 deploy-client.yml 在 mac-runner→NAS gitea
隧道反复 git checkout 失败的问题。

- 阶段:web/macos(Developer ID+公证+staple)/android(rbw keystore)/
  ios(app-store)/windows(拉 GitHub 云构建)/deploy(version.yaml+scp ali)/testflight
- 密钥全走本机(钥匙串+rbw+~/.appstoreconnect+ssh 别名 ali),不用 CI 的 base64→临时 keychain
- --only 可选阶段;敏感临时物(keystore/key.properties)mktemp+trap 清理,不入库
- 实测:1.1.9 --only web,macos,android,windows,deploy 全过,已上线 ali

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:51:12 +08:00
wangjia bec45c282f fix(ios): 改用自动签名,修复 archive/export 证书-profile 不配对
本机 login keychain 的 Apple Distribution 证书(2027 有效)与项目里写死的
手动 profile "Jiu App Store"(用另一张/旧证书签发)不配对,archive/export 报
"Provisioning profile doesn't include signing certificate"。

- project.pbxproj:Runner Release 配置 Manual → Automatic(删写死的
  PROVISIONING_PROFILE_SPECIFIER + Apple Distribution identity),与项目其它
  配置、local_test.sh --ios 一致,由 Xcode 现场配对当前证书。
- compile-ios.sh:ExportOptions signingStyle manual → automatic,去掉手动
  profile 发现逻辑(不再依赖会漂移的手动材料)。

验证:本机 sh scripts/ci/compile-ios.sh client-v1.1.9 → 构建 岩美酒库.ipa →
altool 上传成功(build 10109 已上 TestFlight,Delivery UUID 4bda0191)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:25:49 +08:00
wangjia dccf3610b5 fix(ci): iOS 改用本机 login keychain 证书+本地 profile+本地 .p8 签名
CI iOS 报 'No valid code signing certificates':原 IOS_DIST_CERT_P12_BASE64 secret
里的证书过期。改用本机 login keychain 的有效 Apple Distribution 证书(到2027-07)
+ 自动发现的 App Store profile + 本机 .p8,不再依赖 base64 secret。
team/key-id 带默认,issuer 走 gitea secret APPSTORE_API_ISSUER_ID(已设)。
本地实测:过了证书+profile 检查、进入 archive(签名修复生效),后续 iOS 平台组件另议。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-20 09:26:49 +08:00
wangjia cac84b4388 ci: 删除 checks.yml(每次 push 占 runner)+ deploy-client 恢复 tag 触发
Deploy Client / build-client-web (push) Successful in 11m29s
Deploy Client / build-macos (push) Successful in 13m15s
Deploy Client / build-android (push) Successful in 9m1s
Deploy Client / build-windows (push) Successful in 7m17s
Deploy Client / build-ios (push) Failing after 4m10s
Deploy Client / release-deploy-client (push) Has been skipped
- 删 checks.yml:design-source 检查每次 push 都跑、占唯一 mac-runner、与发版抢资源;
  golden/ds 闸改本地跑(flutter test test/golden/)。需要时可从 git 恢复。
- deploy-client 恢复 push tags 自动触发(之前误删),保留 Windows 单节点改动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 20:02:30 +08:00
wangjia 3d1402bec8 ci(deploy-client): 改手动触发(去 tag 自动) + Windows 合并为单节点
Design Source Checks / design-source (push) Successful in 4m35s
- 去掉 push tags 自动触发,只留 workflow_dispatch(打 tag 只作标记,发版手动 Run workflow)
  ——弱网+gitea tag 事件飘忽下反复出重复 run/卡死,改手动可控
- build-windows + build-windows-ci 合并为单个 build-windows(runs-on 表达式选 runner +
  step if 决定家里现编/mac 拉 github),DAG 只显示一个节点

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 19:53:17 +08:00
wangjia f9ba81ae43 fix(golden): 冻结应用时钟消除时间依赖非确定性(product_detail/license/inventory_check)
golden 失败根因:商品详情屏用实时 DateTime.now() 算「近30天出库」,随真实时间推移
6月流水掉出30天窗口→渲染变化→对不上。license/inventory_check 同理(剩余天数/盘点日期)。
修复:
- product_detail_screen: DateTime.now() → appNow()(可注入时钟)
- golden_harness: 所有 golden 渲染前冻结 appClock=2026-07-01 + tearDown 复位(ds-flow 钉死动态值)
- 重录 license_mobile / m_inventory_check 6 张 golden;product_detail 冻结后自动匹配无需改图
本地 flutter test test/golden/ 全 120 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 19:53:17 +08:00
wangjia 067f21c273 release: 触发 client-v1.1.9 发版(稳定网络重发 #2)
Design Source Checks / design-source (push) Failing after 8m48s
Deploy Client / build-client-web (push) Failing after 4m30s
Deploy Client / build-macos (push) Has been skipped
Deploy Client / build-android (push) Has been skipped
Deploy Client / build-ios (push) Has been skipped
Deploy Client / build-windows-ci (push) Successful in 7m29s
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy-client (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 19:18:45 +08:00
wangjia aa4a3951f7 release: 触发 client-v1.1.9 发版(WiFi 稳定网络重发)
Design Source Checks / design-source (push) Failing after 1m59s
Deploy Client / build-client-web (push) Failing after 15m28s
Deploy Client / build-macos (push) Has been skipped
Deploy Client / build-android (push) Has been skipped
Deploy Client / build-ios (push) Has been skipped
Deploy Client / build-windows-ci (push) Failing after 1s
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy-client (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 10:41:15 +08:00
wangjia 5eec510f1e ci(windows): NAS 暂存设 continue-on-error(发版已从 GitHub 拉,NAS 仅最佳努力记录)
Design Source Checks / design-source (push) Failing after 4m4s
Deploy Client / build-client-web (push) Successful in 5m3s
Deploy Client / build-windows-ci (push) Successful in 4m57s
Deploy Client / build-macos (push) Failing after 4m38s
Deploy Client / build-android (push) Has been skipped
Deploy Client / build-ios (push) Has been skipped
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy-client (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 09:38:03 +08:00
wangjia 161dee17b1 ci(windows): 加 permissions contents:write——GITHUB_TOKEN 默认只读建不了 Release(403)
Design Source Checks / design-source (push) Failing after 5m48s
Deploy Client / build-macos (push) Successful in 8m50s
Deploy Client / build-client-web (push) Successful in 13m39s
Deploy Client / build-windows-ci (push) Successful in 4m34s
Deploy Client / build-android (push) Successful in 11m59s
Deploy Client / build-ios (push) Failing after 2m43s
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy-client (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 09:21:49 +08:00
wangjia c8eabe0596 ci(windows): Windows 包发版改从 GitHub Release 拉取(免 frps)
Design Source Checks / design-source (push) Failing after 5m2s
mac-runner off-LAN 时,从 NAS 经 ali frps 下载 26MB 太慢/易断(实测 ~220KB/s 且中途断)。
改为:windows.yml 额外发布到 GitHub Release winstage-v<版本>;deploy-client 的
build-windows-ci 从 github.com 拉(不过 frps)+ 重试断点续传 + 完整性校验。
需 gitea 密钥 GH_TOKEN(已设)。NAS 暂存保留作记录。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 09:13:50 +08:00
wangjia 03772542d2 docs(changelog): 1.1.9 发版说明
Design Source Checks / design-source (push) Failing after 11m21s
Deploy Client / build-client-web (push) Successful in 7m49s
Deploy Client / build-windows-ci (push) Failing after 5m10s
Deploy Client / build-macos (push) Successful in 12m17s
Deploy Client / build-ios (push) Has been cancelled
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy-client (push) Has been cancelled
Deploy Client / build-android (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-18 08:16:55 +08:00
39 changed files with 1840 additions and 464 deletions
-45
View File
@@ -1,45 +0,0 @@
name: Design Source Checks
# 设计真相源检查(治理规则见 CLAUDE.md「设计真相源分层」):
# PR 与合并 main 时自动跑:四道静态闸(node + dart format+ flutter test
# (单测 + golden ×3 主题回归自比,2026-07-07 纳入——此前 DoD 写了但无闸)。
# fidelity 重型像素闸维持「定期体检」定位,本地手动跑。
on:
pull_request:
push:
branches: [main]
concurrency:
group: checks-${{ gitea.ref }}
cancel-in-progress: true
jobs:
design-source:
runs-on: mac
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: 原型设计系统闸(check-ds 12 道)
run: node design/prototype/tools/check-ds.mjs
- name: L1 真相源同源闸(tokens 快照 / icons / 官网 token 值 / web hex
run: node tools/check-l1-sync.mjs
- name: Flutter 颜色单源闸(check_ds_code
run: node client/tool/check_ds_code.mjs
- name: token codegen 新鲜度(regen → format → 与入库产物零 diff
run: |
cd client
node tool/gen_tokens.mjs
dart format lib/core/theme/app_tokens.g.dart lib/core/theme/app_dims.g.dart lib/core/theme/app_chrome.g.dart
git diff --exit-code -- lib/core/theme/app_tokens.g.dart lib/core/theme/app_dims.g.dart lib/core/theme/app_chrome.g.dart
- name: Flutter 测试(单测 + golden ×3 主题回归自比)
run: |
cd client
flutter test
+16 -39
View File
@@ -4,8 +4,6 @@ on:
push:
tags:
- 'client-v[0-9]*.[0-9]*.[0-9]*'
# 手动运行(在 tag 上 Run workflow)时可指定 Windows 构建来源,无需改仓库变量。
# 走 github 模式:选该版本的 tag 作为 ref 运行 + windows_source=github。
workflow_dispatch:
inputs:
windows_source:
@@ -121,12 +119,12 @@ jobs:
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
run: sh scripts/ci/compile-ios.sh "${{ gitea.ref_name }}"
# 默认:家里那台 Windows runner 构建(回家后照常用)。
# 仅当 gitea 仓库变量 WINDOWS_SOURCE=github 时跳过,改由 build-windows-ci 从 NAS 拉云构建。
# Windows 单一 job(只显示一个节点)。按 windows_source 决定来源:
# home(默认) → 在家里 Windows runner 现编(provision + compile
# github → 在 mac 上从 GitHub Release 拉云构建的安装器(免 frps;家里 Windows 关机时用)
# 来源判定:运行参数 windows_source 优先,其次仓库变量 WINDOWS_SOURCE,默认 home。
build-windows:
# 来源判定:运行参数 windows_source 优先,其次仓库变量 WINDOWS_SOURCE,默认 home。
if: ${{ (github.event.inputs.windows_source || vars.WINDOWS_SOURCE || 'home') != 'github' }}
runs-on: windows
runs-on: ${{ (github.event.inputs.windows_source || vars.WINDOWS_SOURCE || 'home') == 'github' && 'mac' || 'windows' }}
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -135,34 +133,22 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
# —— home 模式:家里 Windows runner 现编 ——
- name: Provision (home windows, idempotent)
if: ${{ (github.event.inputs.windows_source || vars.WINDOWS_SOURCE || 'home') != 'github' }}
shell: bash
run: sh scripts/ci/provision-windows.sh
- name: Compile (Flutter Windows)
- name: Compile Flutter Windows (home)
if: ${{ (github.event.inputs.windows_source || vars.WINDOWS_SOURCE || 'home') != 'github' }}
shell: bash
run: sh scripts/ci/compile-windows.sh "${{ gitea.ref_name }}"
- name: Upload windows artifacts
uses: actions/upload-artifact@v3
with:
name: windows
path: dist/
# 特殊:家里 Windows 关机/连不上时,从 NAS 暂存拉 GitHub 云构建的安装器(版本强匹配)。
# 启用:gitea 仓库设变量 WINDOWS_SOURCE=github,并先在 GitHub 跑 windows.yml 构建该版本。
# 产出同名 windows artifact,下游 release-deploy-client 无需改动。
build-windows-ci:
if: ${{ (github.event.inputs.windows_source || vars.WINDOWS_SOURCE || 'home') == 'github' }}
runs-on: mac
steps:
- uses: actions/checkout@v4
- name: Fetch staged Windows installer from NAS
# —— github 模式:mac 上从 GitHub Release 拉(免 frps)——
- name: Fetch installer from GitHub (免 frps)
if: ${{ (github.event.inputs.windows_source || vars.WINDOWS_SOURCE || 'home') == 'github' }}
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GH_REPO: bj-wangjia/jiu
run: sh scripts/ci/fetch-windows-staged.sh "${{ gitea.ref_name }}"
- name: Upload windows artifacts
@@ -172,16 +158,7 @@ jobs:
path: dist/
release-deploy-client:
needs: [build-client-web, build-macos, build-android, build-ios, build-windows, build-windows-ci]
# 家里/云 两条 Windows 腿只会跑一条(另一条 skipped)。用 always() + 显式成功判定,
# 只要其中一条 windows 成功、且其余端全绿就继续发版。
if: |
always() &&
needs.build-client-web.result == 'success' &&
needs.build-macos.result == 'success' &&
needs.build-android.result == 'success' &&
needs.build-ios.result == 'success' &&
(needs.build-windows.result == 'success' || needs.build-windows-ci.result == 'success')
needs: [build-client-web, build-macos, build-android, build-ios, build-windows]
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
+20 -1
View File
@@ -19,6 +19,9 @@ on:
- 'scripts/ci/compile-windows.sh'
- 'deploy/windows/**'
permissions:
contents: write # 允许内置 GITHUB_TOKEN 创建 Release(winstage-v<版本>)
jobs:
build-stage-windows:
runs-on: windows-latest
@@ -64,7 +67,23 @@ jobs:
path: dist/jiu-windows-x64-setup.exe
if-no-files-found: error
- name: Stage installer → NAS gitea (winstage-v<版本>)
# 发布到 GitHub Release winstage-v<版本> —— 发版时 mac-runner 从这里(github.com)拉,
# 不走 ali frps(off-LAN 时 NAS 经 frps 太慢/易断)。用内置 GITHUB_TOKEN,免额外密钥。
- name: Publish installer → GitHub Release (winstage-v<版本>,供 mac-runner 免 frps 拉取)
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="winstage-v${{ steps.ver.outputs.ver }}"
gh release delete "$tag" -R "${{ github.repository }}" --yes --cleanup-tag 2>/dev/null || true
gh release create "$tag" -R "${{ github.repository }}" \
--title "$tag" --notes "Windows 安装器暂存(供 deploy-client 免 frps 拉取)" \
dist/jiu-windows-x64-setup.exe
# NAS 暂存仅作最佳努力的记录(发版实际从 GitHub Release 拉,见 fetch-windows-staged.sh)。
# 经 ali frps 上传易失败,不让它 fail 整个构建。
- name: Stage installer → NAS gitea (best-effort 记录)
continue-on-error: true
shell: bash
env:
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
+42
View File
@@ -5,6 +5,48 @@ 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
### 新功能
- 出库单支持「扫码出库」:桌面端用扫码枪扫商品二维码,商品明细自动新增该商品并按扫码次数累加数量,无需手动从库存勾选。
### 修复
- 修复授权到期进入只读模式后,「兑换续期」按钮无法点击、导致无法激活新授权码自救的问题。
## [1.1.9] - 2026-07-18
### 新功能
- 授权管理独立成一级页面,分「授权 / 购买续费 / 订单管理」三个标签。
- 购买支持外跳支付页或内嵌二维码扫码付款,可查看/取消订单。
- 财务应收应付汇总、财务筛选新增「往来单位」搜索。
- 出入库单详情新增「生产日期 / 批次号」列;库存商品详情新增「供应商 / 批次号」。
- 出入库列表状态筛选改为多选(默认草稿 + 待审核);新建单自动填充默认仓库。
- 出入库列表卡片(手机端)显示品项数。
### 改进
- 慢请求监控(后台记录 >500ms 请求,便于排查卡顿)。
### 修复
- 切换账号后仍显示上一账号数据。
- 订单时间按本地时区显示(原为 UTC)。
- 「已在运行」提示框、出入库抽屉在特定情况下的闪退。
- 支付宝下单因网关地址过长失败。
## [1.1.8] - 2026-07-10
### 新功能
+2 -1
View File
@@ -287,7 +287,8 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
h.db.Model(&product).Update("public_id", product.PublicID)
}
url := config.C.Storage.PublicURL + "/app/product/" + product.PublicID
// 统一走 SSR 公开页路由 /product/:public_id(与 router 一致;旧 /app/product/ 前缀已弃)
url := config.C.Storage.PublicURL + "/product/" + product.PublicID
if product.Code != "" {
url += "?code=" + product.Code
}
+1 -3
View File
@@ -697,10 +697,8 @@
);
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu;
PRODUCT_NAME = "$(TARGET_NAME)";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = BYL4KQHMTN;
PROVISIONING_PROFILE_SPECIFIER = "Jiu App Store";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
+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]);
+323 -173
View File
@@ -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 dotsDIRECTION 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);
+56 -62
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,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);
@@ -1,5 +1,6 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/utils/dialog_util.dart';
import '../../core/utils/clock.dart';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter/foundation.dart';
@@ -395,7 +396,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
final currentStock = _invRows.fold<num>(0, (s, i) => s + i.quantity);
final cost = p.purchasePrice ?? 0;
final stockValue = currentStock * cost;
final now = DateTime.now();
final now = appNow();
final out30 = _logs.where((l) {
if (l.direction != 'out') return false;
final d = DateTime.tryParse(l.createdAt ?? '');
@@ -986,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 ?? '',
@@ -18,7 +18,6 @@ import '../../core/utils/clock.dart';
import '../../providers/license_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
import '../../widgets/write_guard.dart';
import 'settings_card.dart';
class LicensePanel extends ConsumerStatefulWidget {
@@ -123,11 +122,12 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
),
),
const SizedBox(width: 10),
WriteGuard(
child: DsButton(_redeeming ? '兑换中…' : '兑换续期',
variant: DsBtnVariant.primary,
onPressed: _redeeming ? null : _redeem),
),
// 激活/兑换是过期后的「自救出口」:后端 /license/* 豁免 LicenseGuard
// 故此按钮不受授权相位(readonly/locked)只读锁约束,绝不能包 WriteGuard
// role=readonly 用户由后端 ReadOnly() 403 + toast 兜底。
DsButton(_redeeming ? '兑换中…' : '兑换续期',
variant: DsBtnVariant.primary,
onPressed: _redeeming ? null : _redeem),
],
),
),
@@ -86,6 +86,19 @@ List<_PickerItem> _aggregatePickerItems(List<Inventory> rows) {
return map.values.toList();
}
/// 从扫码枪扫到的内容里取商品编码:
/// 优先取二维码 URL 的 `?code=` 参数(形如 `…/product/{public_id}?code={code}`),
/// 取不到则把整串(去空白)当作编码兜底(支持直接扫纯编码/纯 code 条码)。
String parseScanCode(String raw) {
raw = raw.trim();
if (raw.isEmpty) return '';
final q = Uri.tryParse(raw)?.queryParameters['code'];
if (q != null && q.trim().isNotEmpty) return q.trim();
final m = RegExp(r'[?&]code=([^&]+)').firstMatch(raw);
if (m != null) return Uri.decodeComponent(m.group(1)!).trim();
return raw;
}
class _ItemRow {
int? productId;
final String productCode;
@@ -164,6 +177,15 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
List<_PickerItem> _inventoryPickerItems = [];
StockOutOrder? _loadedOrder;
// ── 扫码出库 ──────────────────────────────────────────────────────────────
// 扫码枪=HID键盘,扫二维码URL→承接进 _scanCtrl→回车触发 _onScan。
final _scanCtrl = TextEditingController();
final _scanFocus = FocusNode();
// 整仓库存按商品编码建索引(_loadInventory 时建),扫码→本地精确命中,零网络。
Map<String, _PickerItem> _codeIndex = {};
String _lastScanCode = '';
int _lastScanMs = 0;
static const _fields = ['qty', 'sale'];
final List<_ItemRow> _items = [];
@@ -179,6 +201,10 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
// 新单起始为空,用户通过「从库存选择」添加明细
_initWarehouseDefault();
}
// 桌面端:进页即聚焦扫码框,走近即可连扫(窄屏无扫码枪不聚焦)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !context.isMobile) _scanFocus.requestFocus();
});
}
/// 新建单默认填充默认仓库(2026-07-14):仓库列表加载完成后,
@@ -239,6 +265,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
_remarkCtrl.dispose();
_partnerFocus.dispose();
_warehouseFocus.dispose();
_scanCtrl.dispose();
_scanFocus.dispose();
for (final item in _items) {
item.dispose();
}
@@ -272,6 +300,12 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
for (final item in _inventoryPickerItems)
item.productId: item.availableQty
};
// 扫码本地索引:按商品编码(小写归一)→ 聚合库存条目
_codeIndex = {
for (final item in _inventoryPickerItems)
if (item.productCode.isNotEmpty)
item.productCode.toLowerCase(): item
};
});
} catch (_) {}
}
@@ -316,6 +350,92 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
});
}
// ── 扫码出库:扫一个,明细自动 +1 ─────────────────────────────────────────
/// 扫码枪回车触发。解析编码→本地整仓索引命中(已加载项)→未命中再走服务端
/// 按编码精确查(覆盖库存超本地加载上限 1000 的大仓)→加行/累加数量。
Future<void> _onScan(String raw) async {
// 立即清空并保持聚焦,承接下一次扫码
_scanCtrl.clear();
_scanFocus.requestFocus();
if (_warehouseId == null) {
_snack('请先选择出库仓库', err: true);
return;
}
final code = parseScanCode(raw);
if (code.isEmpty) return;
// 防扳机抖动连发:同码 300ms 内忽略
final now = DateTime.now().millisecondsSinceEpoch;
final lc = code.toLowerCase();
if (lc == _lastScanCode && now - _lastScanMs < 300) return;
_lastScanCode = lc;
_lastScanMs = now;
// 1) 本地整仓索引命中(已加载项,即时零网络)
var item = _codeIndex[lc];
// 2) 本地未命中 → 服务端按编码精确查(大仓 >1000 未加载项的兜底)
if (item == null) {
item = await _lookupByCodeRemote(code);
if (!mounted) return;
}
if (item == null) {
_snack('未找到该商品:$code', err: true);
return;
}
if (item.availableQty <= 0) {
_snack('${item.productName} 无可用库存', err: true);
return;
}
_addOrBumpByScan(item);
}
/// 服务端按编码精确查该仓库存(本地索引未覆盖时兜底,复用库存搜索接口)。
Future<_PickerItem?> _lookupByCodeRemote(String code) async {
try {
final res = await ref.read(inventoryRepositoryProvider).listInventory(
warehouseId: _warehouseId, keyword: code, pageSize: 30);
final items = _aggregatePickerItems(res.data);
final lc = code.toLowerCase();
for (final it in items) {
if (it.productCode.toLowerCase() == lc) return it;
}
} catch (_) {
// 网络/接口异常:按未找到处理,上层报 toast
}
return null;
}
void _addOrBumpByScan(_PickerItem item) {
final existing =
_items.where((r) => r.productId == item.productId).firstOrNull;
final int qn;
if (existing != null) {
qn = (int.tryParse(existing.qtyCtrl.text) ?? 0) + 1;
setState(() => existing.qtyCtrl.text = qn.toString());
} else {
final row = _ItemRow(
productId: item.productId,
productCode: item.productCode,
productName: item.productName,
series: item.series,
spec: item.spec,
costPrice: item.costPrice,
availableQty: item.availableQty,
salePrice: item.salePrice, // 默认带出参考售价
);
row.qtyCtrl.text = '1'; // 扫码按次数点件数,首扫为 1
qn = 1;
setState(() => _items.add(row));
}
SystemSound.play(SystemSoundType.click); // 轻提示音
final over = qn > item.availableQty;
_snack(
over
? '${item.productName} ×$qn(超可用 ${item.availableQty.toStringAsFixed(0)}'
: '${item.productName} 已加 ×$qn',
err: over,
);
}
void _copyRow(int index) {
final s = _items[index];
final r = _ItemRow(
@@ -569,10 +689,26 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
: null,
docHead: _buildDocHead(currentUser?.realName ?? '-'),
detailHead: DetailHead(actions: [
// 扫码枪承接框(HID键盘,扫二维码URL→取?code=→明细自动+1),仅桌面端
if (!mobile) ...[
SizedBox(
width: 200,
child: DsInput(
controller: _scanCtrl,
focusNode: _scanFocus,
hintText: '扫码枪扫码…',
onSubmitted: _onScan,
suffix: Icon(LucideIcons.scanLine,
size: 16, color: context.tokens.muted),
),
),
const SizedBox(width: 10), // 与按钮间距(对齐原型 .dh-act gap:10
],
// 桌面端与扫码框同高(38);窄屏保持 small(32) 不动移动 golden
DsButton('从库存选择',
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
small: true,
small: mobile,
onPressed: _addItem),
]),
detail: mobile ? _buildMobileCards() : _buildGrid(),
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 130 KiB

+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/screens/stock_out/stock_out_form_screen.dart';
/// 扫码出库:从扫码枪内容里取商品编码。
/// 扫码枪扫二维码得到 URL(形如 …/product/{public_id}?code={code})或纯编码。
void main() {
group('parseScanCode', () {
test('新路径 /product/xxx?code=', () {
expect(
parseScanCode('https://jiu.51yanmei.com/product/abc-123?code=P1001'),
'P1001',
);
});
test('旧路径 /app/product/xxx?code=(兼容)', () {
expect(
parseScanCode('https://jiu.51yanmei.com/app/product/abc?code=P1002'),
'P1002',
);
});
test('纯编码直接扫(无 URL', () {
expect(parseScanCode('P1003'), 'P1003');
});
test('前后空白被去除', () {
expect(parseScanCode(' P1004 '), 'P1004');
});
test('code 带 URL 编码字符被解码', () {
expect(parseScanCode('https://x/product/y?code=P%2D9'), 'P-9');
});
test('多参数里的 code 也能取到', () {
expect(parseScanCode('https://x/product/y?foo=1&code=P1005&bar=2'), 'P1005');
});
test('空串返回空', () {
expect(parseScanCode(''), '');
expect(parseScanCode(' '), '');
});
test('无 code 参数的 URL:整串兜底当编码', () {
// 无 ?code= 时不误判,返回原串(由上层按编码匹配,匹配不到再报未找到)
const raw = 'https://x/product/only-public-id';
expect(parseScanCode(raw), raw);
});
});
}
+6
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart' show rootBundle, FontLoader;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/core/utils/clock.dart';
// 加载真实中文字体(消除 golden 豆腐块),使 golden 能与原型截图做保真 diff。
// 原型截图侧同样注入 Noto Sans SC(见 tools/fidelity.mjs),两边字体一致。
@@ -67,6 +68,11 @@ Future<void> pumpGolden(
double dpr = 2.0,
}) async {
await ensureGoldenFonts();
// 冻结应用时钟:凡走 appNow() 的展示计算(如商品详情「近30天出库」)不再依赖真实时间,
// 保证 golden 确定性(ds-flowgolden 钉死一切动态值)。选 2026-07-01,使 fake 6 月流水
// 稳定落在「近30天」窗口内。tearDown 复位,不影响其它测试。
appClock = () => DateTime(2026, 7, 1, 12, 0);
addTearDown(() => appClock = () => DateTime.now());
tester.view.physicalSize = Size(logical.width * dpr, logical.height * dpr);
tester.view.devicePixelRatio = dpr;
addTearDown(tester.view.resetPhysicalSize);
+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`(与官网时间线同源),接口不可达回退只显当前版本。
+46 -2
View File
@@ -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,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 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'); }
+8 -1
View File
@@ -84,7 +84,13 @@
.detail-head .dt{font-size:var(--fs-title); font-weight:600; color:var(--heading);}
.detail-head .hint{margin-left:12px; font-size:var(--fs-sm); color:var(--faint);}
.detail-head .hint kbd{font-family:var(--font-mono); background:var(--bg); border:1px solid var(--border); border-radius:var(--r-sm); padding:1px 5px; font-size:var(--fs-xs); color:var(--muted);}
.detail-head .dh-act{margin-left:auto; display:flex; gap:10px;}
.detail-head .dh-act{margin-left:auto; display:flex; align-items:center; gap:10px;}
/* 扫码出库:扫码枪(HID键盘)承接框,仅出库模式桌面端出现 */
.scanbox{display:flex; align-items:center; gap:7px; height:38px; padding:0 10px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-md); transition:border-color .15s,box-shadow .15s;}
.scanbox:focus-within{border-color:var(--primary); box-shadow:0 0 0 3px var(--brand50);}
.scanbox svg{width:16px; height:16px; color:var(--muted); stroke-width:1.8; fill:none; stroke:currentColor; flex:none;}
.scanbox input{border:none; outline:none; background:transparent; width:150px; font-size:var(--fs-body); color:var(--text); font-family:var(--font);}
.scanbox input::placeholder{color:var(--faint);}
#detail{flex:1; min-height:0; display:flex; flex-direction:column;}
.mcards{display:flex; flex-direction:column; gap:12px;}
@@ -106,6 +112,7 @@
.dochead-grid{grid-template-columns:1fr;}
.ph-actions .desk{display:none;}
.form-foot .foot-act .desk{display:none;}
.scanbox{display:none;} /* 扫码枪桌面外设,窄屏不出现 */
}
@media (min-width:601px){ .ph-actions .mob, .form-foot .foot-act .mob{display:none;} }
</style>
+31 -2
View File
@@ -30,7 +30,7 @@ const NAMES = [
['n12','古井贡年份原浆','gujinggongnianfenyuanjiang','gjgnfyj','s42','p5006',390,490,12],
['n13','西凤酒旗舰版','xifengjiuqijianban','xfjqjb','s45','p5006',280,360,88],
['n14','水井坊井台','shuijingfangjingtai','sjfjt','s52','p5006',680,860,6],
].map(a=>({id:a[0],name:a[1],py:a[2],init:a[3],dSeries:a[4],dSpec:a[5],cost:a[6],sale:a[7],stock:a[8]}));
].map((a,i)=>({id:a[0],name:a[1],py:a[2],init:a[3],dSeries:a[4],dSpec:a[5],cost:a[6],sale:a[7],stock:a[8],code:'P'+String(1001+i)}));
const SUPPLIERS = [
['g1','鼎晟供应链','dingshenggongyinglian','dsgyl','GYS001'],['g2','茅台华东总代','maotaihuadongzongdai','mthdzd','GYS002'],
['g3','川酒集团批发','chuanjiujituanpifa','cjjtpf','GYS003'],['g4','名烟名酒城','mingyanmingjiucheng','mymjc','GYS004'],
@@ -273,7 +273,36 @@ function renderDetail(){
detail.innerHTML = layout==='grid'? gridHTML() : cardsHTML();
}
function relayout(){ const layout=viewportW()<600?'cards':'grid'; if(layout!==curLayout) renderDetail(); }
function renderDetailHead(){ const el=document.getElementById('dhAct'); if(!el) return; let h=''; if(readonlyMode()){ el.innerHTML=''; return; } if(mode==='out') h+=`<div class="btn ghost sm" onclick="openStockPicker()"><svg viewBox="0 0 24 24"><path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/></svg>从库存批量选择</div>`; h+=`<div class="btn ghost sm" onclick="copyLast()"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 012-2h10"/></svg>复制上一行</div>`; el.innerHTML=h; }
function renderDetailHead(){ const el=document.getElementById('dhAct'); if(!el) return; let h=''; if(readonlyMode()){ el.innerHTML=''; return; }
// 出库:扫码枪承接框(HID键盘,扫二维码URL→取?code=→明细自动+1),置于动作区最左
// 出库模式明细头有扫码框(38高),按钮同步用常规尺寸(38)与之对齐;入库无扫码框保持 sm(32)
const bcls = mode==='out' ? 'btn ghost' : 'btn ghost sm';
if(mode==='out') h+=`<div class="scanbox" title="扫码枪扫商品二维码,明细自动+1"><svg viewBox="0 0 24 24"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/></svg><input id="scanInput" placeholder="扫码枪扫码…" autocomplete="off" onkeydown="onScanKey(event)"></div>`;
if(mode==='out') h+=`<div class="${bcls}" onclick="openStockPicker()"><svg viewBox="0 0 24 24"><path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/></svg>从库存批量选择</div>`; h+=`<div class="${bcls}" onclick="copyLast()"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 012-2h10"/></svg>复制上一行</div>`; el.innerHTML=h;
// 扫码框自动聚焦承接下一次扫码;但不抢正在编辑的单元格/下拉焦点
const si=document.getElementById('scanInput'); if(si){ const ae=document.activeElement; const inCell=ae&&(ae.classList&&ae.classList.contains('gci')||ae.closest&&ae.closest('.combo')); if(!inCell) si.focus(); }
}
// ── 扫码出库 ────────────────────────────────────────────────────────────────
let _lastScan={code:'',t:0};
/** 从扫码内容取商品编码:优先 URL 的 ?code=,否则整串当编码。 */
function parseScanCode(raw){ raw=(raw||'').trim(); if(!raw) return ''; try{ const c=new URL(raw).searchParams.get('code'); if(c) return c.trim(); }catch(_){} const m=raw.match(/[?&]code=([^&]+)/); if(m) return decodeURIComponent(m[1]).trim(); return raw; }
function onScanKey(e){ if(e.key==='Enter'){ e.preventDefault(); const v=e.target.value; e.target.value=''; onScanSubmit(v); } }
function onScanSubmit(raw){
if(readonlyMode()) return;
if(!state.doc.warehouse){ toast('请先选择出库仓库'); focusDoc('warehouse'); return; }
const code=parseScanCode(raw); if(!code) return;
const now=Date.now(); if(code===_lastScan.code && now-_lastScan.t<300) return; _lastScan={code,t:now};
const nm=NAMES.find(n=>n.code&&n.code.toLowerCase()===code.toLowerCase());
if(!nm){ toast('未找到该商品:'+code); return; }
if(nm.stock<=0){ toast(nm.name+' 无可用库存'); return; }
let r=state.rows.find(x=>x.name===nm.id);
if(r){ r.qty=String((parseInt(r.qty)||0)+1); }
else { r=state.rows.find(x=>!x.name); if(!r){ r=newRow(); state.rows.push(r); } r.name=nm.id; r.series=nm.dSeries; r.spec=nm.dSpec; r.price=String(nm.sale); r.sale=String(nm.sale); r.avail=nm.stock; r.qty='1'; }
const qn=parseInt(r.qty)||0, over=qn>nm.stock;
renderDetail(); updateTotals(); draftSave();
const si=document.getElementById('scanInput'); if(si) si.focus();
toast(over ? `${nm.name} ×${qn}(超可用 ${nm.stock}` : `${nm.name} 已加 ×${qn}`);
}
function toggleExpand(i){ state.rows[i].expanded=!state.rows[i].expanded; renderDetail(); }
function gridHTML(){
const cols=MODES[mode].cols; const ro=readonlyMode();
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>扫码出库 设计 — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --warn:#B45309; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
h1{font-size:20px;margin:0 0 4px;}
h2{font-size:16px;margin:26px 0 8px;color:var(--primary-dark);border-left:4px solid var(--primary);padding-left:10px;}
h3{font-size:14px;margin:18px 0 6px;color:var(--accent);}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;max-width:940px;margin-bottom:16px;}
p{font-size:14px;margin:6px 0;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
ol,ul{font-size:14px;margin:6px 0;padding-left:22px;}
li{margin:5px 0;}
table{width:100%;border-collapse:collapse;font-size:13px;max-width:940px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border:1px solid var(--border);}
td{padding:9px 10px;border:1px solid #EEF1F5;vertical-align:top;}
.tag{font-size:11px;padding:2px 8px;border-radius:10px;display:inline-block;}
.tag.ok{background:#E6F3EC;color:var(--success);}
.tag.warn{background:#FFF4E5;color:var(--warn);}
.tag.no{background:var(--danger-bg);color:var(--danger);}
.lead{font-size:14px;background:#F0F6FF;border-left:3px solid var(--primary);padding:10px 14px;border-radius:4px;max-width:940px;}
.flow{font-family:ui-monospace,Menlo,monospace;font-size:13px;background:#1d2430;color:#e6edf6;padding:14px 18px;border-radius:8px;max-width:940px;overflow:auto;line-height:1.7;}
.flow .c{color:#7fd1a0;}
.flow .k{color:#f0a868;}
.flow .d{color:#8593a6;}
.decision{background:#FFF9EC;border:1px solid #F0DDB0;border-radius:10px;padding:14px 18px;max-width:940px;margin-bottom:16px;}
.decision h3{color:var(--warn);margin-top:0;}
</style>
</head>
<body>
<h1>扫码出库 · 设计方案</h1>
<div class="sub">用扫码枪扫商品标签已印的二维码,自动加进出库单 · 2026-08-24 · 方案评审(未实现)</div>
<div class="lead">
把出库建单从「<b>手动翻库存勾选</b>」变成「<b>对着货连扫</b>」:操作员拿<b>扫码枪</b>扫商品标签上<b>已经在印</b>的二维码,
系统自动把对应商品加进当前出库单、数量累加。核心认知——<b>扫码枪不是摄像头,是一把「键盘」</b>:它把扫到的
URL 当作键盘输入敲进聚焦的输入框、末尾补一个回车。所以本方案<b>零摄像头、零图像识别依赖</b>,重点全在
「聚焦捕获 → 回车触发 → 按扫到的码查本店库存 → 装配明细行」这条链路上。
</div>
<h2>1. 原理:扫码枪 = HID 键盘</h2>
<div class="card">
<p>USB 扫码枪的默认工作模式是 <b>HID 键盘(keyboard-wedge</b>:解码成功后,它把结果文本逐字符「打字」进操作系统<b>当前聚焦的输入控件</b>,并按出厂配置在末尾追加一个<b>回车(Enter/CR</b>。对应用而言,扫一次码 ≈ 用户瞬间在输入框里粘了一串文本又按了回车。</p>
<p>我们标签上二维码编码的正是一条 URL(后端 <code>product.go:290</code><code>go-qrcode</code> 生成):</p>
<div class="flow"><span class="d"># 二维码内容</span>
{PublicURL}<span class="k">/app/product/</span><span class="c">{public_id}</span>?code=<span class="c">{code}</span></div>
<p>因此扫一次,捕获框里会瞬间出现<b>整条 URL + 回车</b>。前端只需:<b>一个常聚焦的输入框</b> + <b>监听回车</b> + <b>解析 URL 取 public_id</b></p>
<p><b>区分扫码 vs 人手打字</b>:扫码是爆发式输入(字符间隔通常 &lt;30ms,几十毫秒打完整条 URL),人手打字间隔 &gt;100ms。可用「回车结束 + 输入速度」双判据;但出库这种<b>专用扫码模式</b>下,只靠「专用捕获框 + 回车提交」就够,不必依赖计时。</p>
<p class="tag warn" style="display:block;max-width:920px;padding:10px 14px;line-height:1.6;">⚠ 必须是 <b>2D(二维码)扫码枪</b>。便宜的一维条码枪读不了 QR。若采购的是 1D 枪 → 见<b>附录 B</b>(改印一维条码)。</p>
</div>
<h2>2. 端上定位:桌面优先</h2>
<div class="card">
<p>扫码枪插 PCWindows / macOSUSB 口用,所以扫码出库主要落在<b>桌面/宽屏端的出库建单页</b>。手机端不接扫码枪——手机若要扫码得走摄像头(<code>mobile_scanner</code>)那是另一条技术路线,<b>本方案不含</b></p>
<p>前端只在 <code>stock_out_form_screen.dart</code><b>宽屏布局</b>里加「扫码模式」开关,窄屏不出现。</p>
</div>
<h2>3. 数据链路:扫到的 URL → 本店 product_id</h2>
<div class="card">
<p><b>关键 gap</b>:出库明细行以 <code>product_id</code>(数字主键)为锚(<code>StockOutItem.ProductID</code>),但扫到的是 <b>public_id / code</b>。需要一步映射,把扫到的码解析成<b>当前店</b>的 product + 该仓库可用库存。分两段:</p>
<h3>3.1 前端解析 URL</h3>
<ul>
<li><code>public_id</code> = 路径中 <code>product/</code> 之后、<code>?</code> 之前的段。<b>容错</b>两种前缀(<code>/app/product/</code> 与 SSR 的 <code>/product/</code>),也容错「直接扫纯 public_id / 纯 code」的情况。</li>
<li><code>code</code> = query 参数 <code>code</code>(作兜底匹配用)。</li>
<li>都解析不出 → 提示「无法识别的二维码」,不发请求。</li>
</ul>
<h3>3.2 后端新增解析接口(鉴权 + shop 隔离)</h3>
<div class="flow"><span class="k">GET</span> /api/v1/inventory/scan?public_id=<span class="c">{id}</span>&amp;warehouse_id=<span class="c">{wid}</span> <span class="d">(登录鉴权,shop_id 取自 JWT</span>
<span class="d">逻辑:</span>
1. 本店精确查 product WHERE shop_id=? AND public_id=? <span class="d">(跨店查不到 → 404</span>
2. 查该仓该 product 的 inventory 行 status IN (stock,on_sale) <span class="d">(排除 sold</span>
3. 返回 { product_id, product_code, name, series, spec,
available_qty, sale_price } <span class="d">(结构同前端 _PickerItem</span></div>
<p>命中不到 / 不属本店 / 该仓无库存 / 已售罄 → 返回明确错误码,前端据此红字提示。返回结构<b>复用出库表单已有的 <code>_PickerItem</code></b>,前端拿到即可直接装配明细行,无需二次转换。</p>
<p><b>为何要新接口、不复用现有的</b></p>
<table>
<tr><th>现有能力</th><th>为什么不能直接用</th></tr>
<tr><td>鉴权 <code>GET /inventory?code=</code></td><td><code>code</code><b>LIKE 模糊</b>匹配(<code>inventory.go:113</code>),不精确、也不吃 public_id</td></tr>
<tr><td>公开 <code>GET /public/products/:public_id</code></td><td>按 public_id 查,但<b>无鉴权、无 shop 隔离、无库存维度</b>,不能驱动出库</td></tr>
<tr><td>鉴权 <code>GET /inventory?product_id=</code></td><td>精确,但入参已经是 product_id——正是我们缺的那一步转换</td></tr>
</table>
</div>
<h2>4. 交互流程:扫一个,加一个</h2>
<div class="card">
<div class="flow"><span class="d">① 打开出库建单 → 选仓库 → 开「扫码模式」</span>
<span class="d">② 捕获框自动聚焦(失焦自动重夺)</span>
<span class="d">③ 扣扳机 ─────────────▶</span> URL + 回车 进框
<span class="d">④ 前端解析 public_id ─▶</span> GET /inventory/scan?public_id=..&amp;warehouse_id=..
<span class="d">⑤ 命中:</span>
该 product 已在明细 ─▶ <span class="c">数量 +1</span>(可内联改)
不在明细 ─▶ <span class="c">新增一行</span> qty=1,带 product_id / 售价 / 可用量
<span class="c">✓ 成功提示音 + toast</span>「茅台飞天 已加,当前 3 件」,焦点留在捕获框,接着扫
<span class="d">⑥ 未命中 / 售罄 / 跨店:</span>
<span class="k">✗ 失败提示音 + 红 toast</span>,不加行
<span class="d">⑦ 扫完 → 照常提交出库单</span>(提交仍走 CheckInventoryAvailability 库存校验)</div>
<p>整个过程<b>手不离枪</b>:焦点始终锁在捕获框,一次扫码 = 一次「解析 → 查 → 加行 → 提示」闭环,无需鼠标。</p>
</div>
<div class="decision">
<h3>⚑ 需要你拍板:序列号语义下,一次扫码加多少数量?</h3>
<p>数据铁律里 <b>product = 一个特有产品 / 序列号</b>,很多时候一个 product 对应一件 / 一批具体货。这决定「扫一次加几件」:</p>
<table>
<tr><th>口径</th><th>行为</th><th>适用</th></tr>
<tr><td><b>A · 每扫 +1</b><span class="tag ok" style="margin-left:6px;">建议默认</span></td><td>每扫一次数量 +1,可内联改;超可用量即时红字告警</td><td>一个 product = 多件同款,标签贴整批,按扫的次数点件数</td></tr>
<tr><td><b>B · 一码一件</b></td><td>扫一次即锁定该 product(固定 1,或整行可用量),重复扫视为重复→忽略/提示</td><td>严格一物一码一序列号,一个码就是一件</td></tr>
</table>
<p>建议先按 <b>A(每扫 +1,数量可改)</b> 落地,最贴合现在「批量勾选后改数量」的习惯。<b>请你确认口径</b>再进实现。</p>
</div>
<h2>5. 改动清单</h2>
<div class="card">
<h3>后端</h3>
<ul>
<li><b>新增</b> <code>GET /api/v1/inventory/scan</code>handler + service 查询,shop 隔离、排除 sold、返回 <code>_PickerItem</code> 同构体。(<code>inventory.go</code> / router</li>
<li><b>(可选)</b>修正二维码生成路径:<code>/app/product/</code> → 与 SSR 路由 <code>/product/</code> 统一(<code>product.go:290</code>),消除解析歧义。不改也行,前端做前缀容错即可。</li>
</ul>
<h3>前端 · <code>client/lib/screens/stock_out/stock_out_form_screen.dart</code></h3>
<ul>
<li>「扫码模式」开关 + 常聚焦捕获输入(<code>RawKeyboardListener</code> 或自动重夺焦点的隐藏 <code>TextField</code>)。</li>
<li>URL 解析工具(取 public_id / code,容错前缀)。</li>
<li>调 scan 接口 → 复用 <code>_PickerItem</code> 装配 → 加行 / 累加数量。</li>
<li>成功 / 失败<b>提示音 + toast</b>(扫码场景听觉反馈很重要,操作员眼睛在货上不在屏上)。</li>
<li><b>不需要</b>引入 <code>mobile_scanner</code> / <code>camera</code>——扫码枪走键盘,零摄像头依赖,<code>pubspec.yaml</code> 不动。</li>
</ul>
</div>
<h2>6. 边界与坑</h2>
<div class="card">
<table>
<tr><th></th><th>处理</th></tr>
<tr><td>1D 枪读不了 QR</td><td>需 2D 枪;或加印 Code128(code)(附录 B</td></tr>
<tr><td>中文输入法拦截扫码字符</td><td>捕获用 raw key 监听绕过 IME,或强制英文态</td></tr>
<tr><td>扫码枪没配回车后缀</td><td>多数出厂默认带;没有则扫设置码开「Add Enter/CR Suffix」</td></tr>
<tr><td>焦点丢失(用户点了别处)</td><td>失焦自动重夺 + 明显的「扫码模式」态提示</td></tr>
<tr><td>枪抖动同码连发</td><td>去重窗口:300ms 内同 public_id 忽略</td></tr>
<tr><td>跨店 / 售罄 / 未上架</td><td>后端硬拦(shop_id 隔离 + 排除 sold),前端红字</td></tr>
<tr><td>权限</td><td>出库受 <code>ReadOnly</code> 中间件保护;scan 接口只读,只读用户可查不可提交,天然安全</td></tr>
<tr><td>连扫速度</td><td>每扫打一次后端;若要极致速度可预载该仓全量 inventory 做本地 public_id→product_id 映射(附录 C,可选)</td></tr>
</table>
</div>
<h2>附录 A · 扫码枪 vs 手机摄像头</h2>
<div class="card">
<table>
<tr><th></th><th>扫码枪(本方案)</th><th>手机摄像头</th></tr>
<tr><td>技术路线</td><td>HID 键盘输入,无图像</td><td><code>mobile_scanner</code> + 摄像头 + 图像解码</td></tr>
<tr><td></td><td>桌面 PCWin/Mac</td><td>手机 App</td></tr>
<tr><td>前端依赖</td><td><b>零新增</b></td><td>新增扫码库 + 摄像头权限</td></tr>
<tr><td>连扫效率</td><td>高(扣扳机即出)</td><td>中(对焦、找码)</td></tr>
<tr><td>硬件成本</td><td>一把 2D 枪</td><td>已有手机</td></tr>
<tr><td>本方案</td><td><span class="tag ok">采用</span></td><td><span class="tag no">不含</span>(未来可作手机端补充)</td></tr>
</table>
</div>
<h2>附录 B · 若用 1D 枪 → 加印一维条码</h2>
<div class="card">
<p>若采购的是便宜的一维条码枪(读不了 QR),改在标签上加印一段 <b>Code128 一维条码,内容 = 商品 <code>code</code></b>(同店唯一)。扫到的就是纯 <code>code</code>,走 <code>GET /inventory/scan?code={code}</code> 精确匹配即可(同样带 shop 隔离)。QR 与一维条码可并存于同一标签(QR 供顾客溯源,条码供出库)。改动落在 <code>label_data.dart</code> / 打印工具。</p>
</div>
<h2>附录 C · 本地映射加速(可选优化)</h2>
<div class="card">
<p>选好仓库后一次性把该仓 inventory 全量拉到前端,建 <code>public_id → {product_id, 售价, 可用量}</code> 内存映射。扫码时<b>本地命中</b>、零网络往返,连扫如飞;提交前再由后端 <code>CheckInventoryAvailability</code> 兜底校验。适合货多、连扫密集的场景,第一版可不做。</p>
</div>
<h2>落地评级</h2>
<div class="card">
<p>本改动 = <b>新接口 + 跨后端/前端 2 模块</b>,属项目规则里的「大改」:按 <code>CLAUDE.md</code>,实现前须<b>先进 plan 模式经你批准</b>;且前端改动须遵守 <b>design-first</b>(先改设计系统原型再改真实页面)。<b>本文件仅方案评审</b>,你确认「数量口径(A/B)」+「是否修正二维码路径前缀」后,我再出实现 plan。</p>
</div>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>扫码出库 实现计划 — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --warn:#B45309; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
h1{font-size:20px;margin:0 0 4px;}
h2{font-size:16px;margin:26px 0 8px;color:var(--primary-dark);border-left:4px solid var(--primary);padding-left:10px;}
h3{font-size:14px;margin:18px 0 6px;color:var(--accent);}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;max-width:960px;margin-bottom:16px;}
p{font-size:14px;margin:6px 0;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
ol,ul{font-size:14px;margin:6px 0;padding-left:22px;}
li{margin:5px 0;}
table{width:100%;border-collapse:collapse;font-size:13px;max-width:960px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border:1px solid var(--border);}
td{padding:9px 10px;border:1px solid #EEF1F5;vertical-align:top;}
.tag{font-size:11px;padding:2px 8px;border-radius:10px;display:inline-block;}
.tag.ok{background:#E6F3EC;color:var(--success);}
.tag.warn{background:#FFF4E5;color:var(--warn);}
.tag.no{background:var(--danger-bg);color:var(--danger);}
.lead{font-size:14px;background:#F0F6FF;border-left:3px solid var(--primary);padding:10px 14px;border-radius:4px;max-width:960px;}
.flow{font-family:ui-monospace,Menlo,monospace;font-size:13px;background:#1d2430;color:#e6edf6;padding:14px 18px;border-radius:8px;max-width:960px;overflow:auto;line-height:1.7;}
.flow .c{color:#7fd1a0;} .flow .k{color:#f0a868;} .flow .d{color:#8593a6;}
.decision{background:#FFF9EC;border:1px solid #F0DDB0;border-radius:10px;padding:14px 18px;max-width:960px;margin-bottom:16px;}
.decision h3{color:var(--warn);margin-top:0;}
.chk{list-style:none;padding-left:4px;}
.chk li{margin:6px 0;} .chk li::before{content:"☐ ";color:var(--primary);font-weight:700;}
</style>
</head>
<body>
<h1>扫码出库 · 实现计划</h1>
<div class="sub">目标页:<b>新建/修改出库单</b><code>stock_out_form_screen.dart</code>)· 扫码枪扫商品 → 明细自动 +1 · 2026-08-25 · 待批准</div>
<div class="lead">
在「新建出库单」页,操作员用<b>扫码枪</b>扫商品标签上已印的二维码,商品明细里<b>自动新增该商品并数量 +1</b>;同一商品重复扫则数量累加。
核心省力点:二维码 URL 里带 <code>?code={商品编码}</code>,而本页 <code>_loadInventory</code> 已把整仓库存(≤1000 个,<code>stockOutPickerPageSize=1000</code>
聚合进本地 <code>_inventoryPickerItems</code>(每条含 <code>productCode</code>)——<b>扫码→解析 code→本地命中→加行,零网络、零后端改动</b>
</div>
<h2>1. 原理(一句话回顾)</h2>
<div class="card">
<p>扫码枪 = HID 键盘:扫到内容当作键盘输入敲进<b>当前聚焦的输入框</b>、末尾补回车。本页放一个聚焦的「扫码框」承接即可,<b>零摄像头依赖</b>
扫到的是 URL <code>https://…/app/product/{public_id}?code={code}</code>;我们取其中的 <code>code</code> 与整仓已加载的库存做精确匹配。</p>
</div>
<h2>2. 数据流</h2>
<div class="card">
<div class="flow"><span class="d">① 选出库仓库(本页已有)→ _loadInventory 建两张本地表:</span>
_inventoryPickerItems(现有) + <span class="c">_codeIndex: Map&lt;String code, _PickerItem&gt;</span><b>新增</b>
<span class="d">② 扫码框聚焦(失焦自动重夺)</span>
<span class="d">③ 扣扳机 ──▶</span> URL + 回车 落入扫码框,onSubmitted 触发 _onScan(raw)
<span class="d">④ _onScan</span>
解析 code = Uri.parse(raw).queryParameters['code'] <span class="d">(拿不到则整串 trim 当 code 兜底)</span>
item = _codeIndex[code]
├─ 命中 &amp;&amp; 已在明细 ──▶ 该行 <span class="c">数量 +1</span>(超可用量则黄字提醒,仍加)
├─ 命中 &amp;&amp; 不在明细 ──▶ <span class="c">新增一行,数量=1</span>,带 productId/售价/可用量
└─ 未命中 ───────────▶ <span class="k">红 toast「未找到该商品/不在本仓」</span>,不加行
<span class="d">⑤ 清空扫码框 + 重新聚焦 + 轻提示音,等下一扫</span>
<span class="d">⑥ 扫完照常「提交审核」(提交时后端 CheckInventoryAvailability 兜底校验)</span></div>
<p><b>数量口径 = A(每扫 +1,已定)</b>。注意:与「从库存选择」默认带出整箱可用量不同,扫码是<b>按扫的次数点件数</b>,故首扫为 1、再扫累加。数量列仍可手动改。</p>
</div>
<h2>3. 前端改动(唯一必改文件:<code>stock_out_form_screen.dart</code></h2>
<div class="card">
<table>
<tr><th>改动点</th><th>具体</th></tr>
<tr><td><b>本地 code 索引</b></td><td><code>_loadInventory</code> 里,聚合完 <code>_inventoryPickerItems</code> 后顺手建 <code>_codeIndex = { for it in items : it.productCode : it }</code>productCode 非空才入)。</td></tr>
<tr><td><b>扫码框</b></td><td>明细头 <code>DetailHead(actions:[…])</code> 里,在「从库存选择」左侧加一个紧凑 <code>DsInput</code>(图标 <code>LucideIcons.scanLine</code>,占位「扫码枪扫码…」)+ 独立 <code>FocusNode _scanFocus</code>,桌面端 <code>autofocus</code>。窄屏(<code>context.isMobile</code>)不显示(扫码枪是桌面外设)。</td></tr>
<tr><td><b>_onScan(raw)</b></td><td>新增方法:无仓库→toast「请先选择出库仓库」;解析 code;查 <code>_codeIndex</code>;命中则 <code>_addOrBumpByScan(item)</code>;未命中红 toast。末尾 <code>_scanCtrl.clear()</code> + <code>_scanFocus.requestFocus()</code></td></tr>
<tr><td><b>_addOrBumpByScan(item)</b></td><td><code>_items</code><code>productId==item.productId</code>:有→<code>qtyCtrl.text = (now+1)</code>;无→复用 <code>_addItem</code> 的建行逻辑但 <b>qty 固定 1</b><code>setState</code> 刷新。超可用量(<code>&gt;availableQty</code>)→黄字 toast 提醒但不拦。</td></tr>
<tr><td><b>去重防抖</b></td><td><code>_lastScan(code,ts)</code>,同 code 300ms 内重复直接忽略(防扳机抖动连发)。</td></tr>
<tr><td><b>提示音</b></td><td>成功 <code>SystemSound.play(SystemSoundType.click)</code>;失败可 <code>HapticFeedback</code>/仅红 toast。轻量,主反馈仍是 toast(操作员眼在货上)。</td></tr>
<tr><td><b>清理</b></td><td><code>dispose()</code> 里释放 <code>_scanCtrl</code>/<code>_scanFocus</code></td></tr>
</table>
<p>解析函数抽成纯函数 <code>String? parseScanCode(String raw)</code>(放本文件或 util),便于单测:覆盖 <code>/app/product/x?code=1</code><code>/product/x?code=1</code>、纯 code、脏串。</p>
<p><b>不新增依赖</b>:不引 <code>mobile_scanner</code>/<code>camera</code><code>pubspec.yaml</code> 不动。</p>
</div>
<h2>4. 原型改动(design-first,先改再改代码,<b>同一提交</b></h2>
<div class="card">
<p>按项目铁律「原型与真实 100% 一致,前端改动必须先改原型」:在 <code>design/prototype/screens/stock-out.html</code> 的明细头,与「从库存选择」并排加一个扫码输入框原件(用登记过的 atoms,占位「扫码枪扫码…」)。评审原型 → 代码随后落地 → 原型与代码同提交。若涉及新图标 <code>scan-line</code> 未登记,先在 <code>index.html</code> + <code>icons.js</code> 登记再用(L1 单源)。</p>
</div>
<h2>5. 二维码路径统一(顺带,小改)</h2>
<div class="card">
<p>后端 <code>product.go:290</code> 生成 URL 用的是 <code>/app/product/</code>,与 SSR 路由 <code>/product/</code> 不一致。改成 <code>/product/</code> 统一(已定)。<b>对本方案非必需</b>(我们只取 <code>?code=</code>,不依赖路径段),但顺手消歧、利于将来按 public_id 解析。属 server 侧一行改动,随下次 server 发版生效。</p>
</div>
<div class="decision">
<h3>⚑ 唯一待你拍板:要不要现在就做后端精确查接口?</h3>
<p>本地 code 索引覆盖<b>整仓 ≤1000 个商品</b>,绝大多数门店够用。但两种情况本地会漏:①单仓商品 &gt;1000(超分页);②某商品无 codeQR 只有 public_id,则无 <code>?code=</code>)。</p>
<table>
<tr><th>方案</th><th>范围</th><th>建议</th></tr>
<tr><td><b>A · 仅前端(本地匹配)</b></td><td>零后端改动、零部署,扫→加立即生效;漏网情况给红 toast 提示改用「从库存选择」</td><td><span class="tag ok">推荐先做,最快落地</span></td></tr>
<tr><td><b>B · 加后端 <code>GET /inventory/scan</code></b></td><td>本地未命中时回退查后端(精确 by code/public_id、shop 隔离、排除 sold),覆盖 &gt;1000 与 public_id-only;需 server 发版</td><td>作 Phase-2,等确有大仓再加</td></tr>
</table>
<p>我的建议:<b>先做 A(纯前端)</b> 把这页跑通,B 留作后续。你也可以要求一步到位做 A+B。</p>
</div>
<h2>6. 验证(无扫码枪也能全验,你已有 macOS app)</h2>
<div class="card">
<ol>
<li><b>端到端</b>:手动往扫码框<b>粘贴一条真实商品 URL</b>(形如 <code>…/app/product/xxx?code=P1234</code>)再按回车——效果与扫码枪 100% 等价。看是否新增行、再粘同一条是否 +1、粘不存在的 code 是否红 toast。</li>
<li><b>解析单测</b><code>flutter test</code> 覆盖 <code>parseScanCode</code> 各输入。</li>
<li><b>DoD</b><code>flutter analyze --no-fatal-infos --no-fatal-warnings</code> 无 error<code>flutter test</code> 全过。</li>
<li>上线后拿 2D 扫码枪扫一张真标签确认「带回车后缀」即可(1D 枪读不了 QR,见原理文档附录 B)。</li>
</ol>
</div>
<h2>7. 改动文件清单 & 执行顺序</h2>
<div class="card">
<ul class="chk">
<li>原型:<code>design/prototype/screens/stock-out.html</code> 加扫码框(+ 必要时 <code>index.html</code>/<code>icons.js</code> 登记 scan-line 图标)</li>
<li>前端:<code>client/lib/screens/stock_out/stock_out_form_screen.dart</code> —— <code>_codeIndex</code> / 扫码框 / <code>_onScan</code> / <code>_addOrBumpByScan</code> / 防抖 / dispose;抽 <code>parseScanCode</code></li>
<li>测试:<code>client/test/…/parse_scan_code_test.dart</code> 新增</li>
<li>(方案 B 选做)后端:<code>inventory.go</code> + router 加 <code>GET /inventory/scan</code></li>
<li>(顺带)后端:<code>product.go:290</code> 路径 <code>/app/product/</code><code>/product/</code></li>
<li>本地验证(analyze + test + macOS app 手动粘 URL)→ 你验收 → 发版 <code>/release client</code></li>
</ul>
<p><b>落地评级</b>:方案 A 为<b>前端单文件 + 原型 + 测试</b>,属中等改动;方案 B 追加后端接口即升为跨模块大改。均遵守 design-first。<b>本文件仅计划评审,你批准后再实现。</b></p>
</div>
<h2>8. 待确认</h2>
<div class="card">
<ol>
<li>先做<b>方案 A(纯前端)</b>,还是一步到位 <b>A+B(含后端精确查)</b>?(建议 A</li>
<li>二维码路径 <code>/app/product/</code><code>/product/</code> 这次<b>一并改</b>吗?(已定要改,确认随本次一起)</li>
</ol>
</div>
</body>
</html>
+2
View File
@@ -43,6 +43,8 @@
<li><a href="design/order-return-prototype.html">退单原型(已审核单据)</a><span class="tag html">HTML</span></li>
<li><a href="design/order-print-layouts.html">出入库单打印排版方案(6 种黑白样式精选)</a><span class="tag html">HTML</span> · <a href="design/order-print-layouts.pdf">PDF</a></li>
<li><a href="design/stock-in-cost-confirm-design.html">入库确认进价(暂估价前向补偿)设计</a><span class="tag html">HTML</span></li>
<li><a href="design/scan-stock-out-design.html">扫码出库(扫码枪扫二维码建出库单)设计</a><span class="tag html">HTML</span> <span class="hint">— 扫码枪=HID键盘,解析已印二维码URL取public_id→新增鉴权接口映射本店product_id→装配明细行;含数量口径待决</span></li>
<li><a href="design/scan-stock-out-plan.html">扫码出库 · 实现计划(出库表单扫码+1)</a><span class="tag html">HTML</span> <span class="hint">— 落到 stock_out_form_screen:扫码框承接URL→取?code=→本地整仓索引命中→明细+1;方案A纯前端(≤1000SKU)/B加后端精确查;design-first先改原型</span></li>
<li><a href="design/inventory-filter-spec.md">库存筛选规格</a><span class="tag">MD</span></li>
</ul>
+58 -86
View File
@@ -1,16 +1,17 @@
#!/usr/bin/env bash
# compile-ios.sh <tag> — build signed iOS IPA and upload to TestFlight (App Store Connect)
#
# 需要的 CI secrets(缺任意一个则优雅跳过,不阻塞流水线):
# IOS_DIST_CERT_P12_BASE64 Apple Distribution 证书 (.p12) 的 base64
# IOS_DIST_CERT_PASSWORD .p12 导出密码
# IOS_PROVISIONING_PROFILE_BASE64 App Store 类型 provisioning profile (.mobileprovision) 的 base64
# IOS_TEAM_ID Apple Developer Team ID10 位)
# APPSTORE_API_KEY_ID App Store Connect API Key ID
# APPSTORE_API_ISSUER_ID App Store Connect API Issuer ID
# APPSTORE_API_KEY_P8_BASE64 API Key (.p8) 的 base64
# 用**本机 login keychain 的 Apple Distribution 证书** + **本机已装的 App Store
# provisioning profile** + **本机 App Store Connect key(.p8)** 签名/上传。
# mac-runner 就是开发机,这些分发材料常驻本机,不再从 base64 secret 导入
# (原方案 IOS_DIST_CERT_P12_BASE64 里的证书过期 → archive 报 "No valid code
# signing certificates";本机 login 里那张有效,2026-07 改用本机材料)。
#
# 详见 docs/ios-signing.md。
# 需要 env(非机密标识,gitea secret/变量;缺则用已知默认):
# IOS_TEAM_ID(默认 BYL4KQHMTN)/ APPSTORE_API_KEY_ID(默认 3PZTHR8YMJ)/
# APPSTORE_API_ISSUER_ID(必填,无默认)
# 需要本机:Apple Distribution 证书在 login keychain;App Store profile 已装;
# ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8 存在。
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
@@ -19,7 +20,7 @@ set -euo pipefail
TAG="$1"
VER="${TAG#client-v}"
# CFBundleVersionbuild 号必须单调递增否则 TestFlight 拒绝重复上传。
# CFBundleVersion(build 号)必须单调递增,否则 TestFlight 拒绝重复上传。
MAJOR="$(echo "$VER" | cut -d. -f1)"
MINOR="$(echo "$VER" | cut -d. -f2)"
PATCH="$(echo "$VER" | cut -d. -f3)"
@@ -27,93 +28,59 @@ BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> compile-ios: version=${VER} build=${BUILD}"
# --- 凭证缺失则跳过(账号/证书尚未配置时不阻塞发版)---
if [ -z "${IOS_DIST_CERT_P12_BASE64:-}" ] || \
[ -z "${IOS_PROVISIONING_PROFILE_BASE64:-}" ] || \
[ -z "${APPSTORE_API_KEY_P8_BASE64:-}" ]; then
echo "==> compile-ios: SKIP — iOS signing secrets not configured (see docs/ios-signing.md)"
exit 0
TEAM_ID="${IOS_TEAM_ID:-BYL4KQHMTN}"
KEY_ID="${APPSTORE_API_KEY_ID:-3PZTHR8YMJ}"
ISSUER="${APPSTORE_API_ISSUER_ID:-}"
BUNDLE_ID="com.yanmei.jiu"
P8="$HOME/.appstoreconnect/private_keys/AuthKey_${KEY_ID}.p8"
# --- 前置检查:本机分发材料齐备(缺则 fail-fast,不产出未签名/无法上传的包)---
if ! security find-identity -v -p codesigning \
| grep -qF "Apple Distribution: Yanmei (beijing) Technology Co., Ltd (${TEAM_ID})"; then
echo "ERROR: login keychain 未找到 Apple Distribution 证书(团队 ${TEAM_ID})——无法签名 iOS。" >&2
echo " 在本机 Xcode 登录 Apple ID / 装好分发证书后重试。" >&2
exit 1
fi
if [ ! -f "$P8" ]; then
echo "ERROR: 缺 App Store Connect key: $P8" >&2
exit 1
fi
if [ -z "$ISSUER" ]; then
echo "ERROR: 缺 APPSTORE_API_ISSUER_ID(App Store Connect Issuer ID)——公证/上传需要。" >&2
exit 1
fi
WORK="$(mktemp -d)"
cleanup() {
# 恢复默认搜索列表(构建期临时独占,见下)——失败路径也必须恢复
security list-keychains -d user -s login.keychain-db 2>/dev/null || true
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
rm -rf "$WORK"
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/jiu-ci.mobileprovision" 2>/dev/null || true
}
trap cleanup EXIT
# shellcheck disable=SC2064
trap "rm -rf '$WORK'" EXIT
# --- 1. 临时 keychain 导入发行证书 ---
KEYCHAIN="$WORK/jiu-ci.keychain-db"
KEYCHAIN_PWD="ci-temp-$$"
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
echo "${IOS_DIST_CERT_P12_BASE64}" | base64 --decode > "$WORK/dist.p12"
security import "$WORK/dist.p12" -k "$KEYCHAIN" \
-P "${IOS_DIST_CERT_PASSWORD:-}" -T /usr/bin/codesign -T /usr/bin/security
# 补齐证书链:p12 只含叶子证书,独占搜索列表后 login 里的 Apple WWDR 中间证书
# 不可见会报 errSecInternalComponent(unable to build chain)——把本机已有的
# WWDR 中间证书(纯公钥)一并导入临时 keychain。
security find-certificate -a -c "Apple Worldwide Developer Relations" -p \
~/Library/Keychains/login.keychain-db /Library/Keychains/System.keychain \
> "$WORK/wwdr.pem" 2>/dev/null || true
if [ -s "$WORK/wwdr.pem" ]; then
security import "$WORK/wwdr.pem" -k "$KEYCHAIN" -T /usr/bin/codesign 2>/dev/null || true
else
echo "WARN: 本机未找到 Apple WWDR 中间证书,签名可能报 unable to build chain" >&2
fi
# 允许 codesign 非交互访问私钥
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
# 构建期独占搜索列表(fastlane 同款):只见临时 keychain,隔离本机其他项目的
# 同名「Apple Distribution」证书(2026-07-07 事故:login 里另一项目的新证书被
# xcodebuild 优先选中,与 Jiu App Store profile 不配对 → archive 失败)。
# trap cleanup 恢复 login,失败路径也不遗留。
security list-keychains -d user -s "$KEYCHAIN"
# --- 2. 安装 provisioning profile ---
PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILES_DIR"
echo "${IOS_PROVISIONING_PROFILE_BASE64}" | base64 --decode > "$WORK/profile.mobileprovision"
# 解出 profile 的 Name 与 UUIDExportOptions 需要)
security cms -D -i "$WORK/profile.mobileprovision" > "$WORK/profile.plist"
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$WORK/profile.plist")"
PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$WORK/profile.plist")"
cp "$WORK/profile.mobileprovision" "$PROFILES_DIR/${PROFILE_UUID}.mobileprovision"
echo "==> compile-ios: profile '${PROFILE_NAME}' (${PROFILE_UUID})"
# --- 3. App Store Connect API Key(供上传使用)---
API_KEYS_DIR="$HOME/.appstoreconnect/private_keys"
mkdir -p "$API_KEYS_DIR"
echo "${APPSTORE_API_KEY_P8_BASE64}" | base64 --decode > "$API_KEYS_DIR/AuthKey_${APPSTORE_API_KEY_ID}.p8"
# --- 4. 同步版本号 ---
# --- 同步版本号 ---
sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" client/pubspec.yaml
# --- 5. 生成 ExportOptions.plistmanual 签名app-store 分发---
BUNDLE_ID="com.yanmei.jiu"
# --- ExportOptions.plist(automatic 签名,app-store 分发)---
# 本机 login 里的 Apple Distribution 证书与旧的手动 profile 不配对
# (profile 是用另一张/旧证书签发的 → archive 报 "profile doesn't include
# signing certificate")。改用**自动签名**:Xcode 用登录的 Apple ID +
# App Store Connect API key(见下 xcodebuild -authenticationKeyPath) 现场
# 生成/更新与当前证书匹配的 App Store profile(-allowProvisioningUpdates),
# 与 local_test.sh --ios 一致,避免手动材料漂移。
cat > "$WORK/ExportOptions.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${IOS_TEAM_ID}</string>
<key>signingStyle</key><string>manual</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>automatic</string>
<key>uploadBitcode</key><false/>
<key>uploadSymbols</key><true/>
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
</dict>
</dict>
</plist>
EOF
# --- 6. 构建签名 IPA ---
# --- 构建签名 IPA(自动签名;Xcode 现场配对证书+profile)---
# flutter build ipa 的 archive/export 都会带 -allowProvisioningUpdates(自动签名时),
# 但云端/无交互登录需用 API key 授权 provisioning 更新 → 传 -authenticationKeyPath 等。
cd client
flutter build ipa --release \
--build-name="${VER}" \
@@ -124,17 +91,22 @@ flutter build ipa --release \
"--dart-define=APP_VERSION=v${VER}"
cd ..
IPA="$(ls client/build/ios/ipa/*.ipa | head -1)"
IPA="$(ls client/build/ios/ipa/*.ipa 2>/dev/null | head -1)"
if [ -z "$IPA" ] || [ ! -f "$IPA" ]; then
echo "ERROR: IPA not found" >&2
exit 1
fi
echo "==> compile-ios: built ${IPA}"
# --- 7. 上传 TestFlightApp Store Connect---
# --- 上传 TestFlight(App Store Connect;本机 .p8 由 key-id 自动定位)---
# TestFlight 是 App Store 独立分发渠道,上传本身易抖(重复 build 号 / ASC 临时错误);
# 其失败【不应阻断】web/mac/win/android 的整体发版(见 CLAUDE.md「iOS ... 不阻塞」)。
# IPA 已产出,上传失败仅告警,可稍后用 altool/Transporter 手动补传同一 IPA。
echo "==> compile-ios: uploading to TestFlight"
xcrun altool --upload-app -f "$IPA" -t ios \
--apiKey "${APPSTORE_API_KEY_ID}" \
--apiIssuer "${APPSTORE_API_ISSUER_ID}"
echo "==> compile-ios: done — uploaded build ${BUILD} to TestFlight"
if xcrun altool --upload-app -f "$IPA" -t ios \
--apiKey "${KEY_ID}" \
--apiIssuer "${ISSUER}"; then
echo "==> compile-ios: done — uploaded build ${BUILD} to TestFlight"
else
echo "!! compile-ios: TestFlight 上传失败(build ${BUILD}),不阻断发版;IPA=${IPA} 已产出,可稍后手动补传。" >&2
fi
+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"
+54 -21
View File
@@ -1,53 +1,86 @@
#!/usr/bin/env bash
# fetch-windows-staged.sh <tag> — 发版时从 NAS gitea 暂存 Release 拉 Windows 安装器。
# fetch-windows-staged.sh <tag> — 发版时从 GitHub Release 拉 Windows 安装器。
#
# 用途:deploy-client.yml 的 build-windows-ci job(仅当 WINDOWS_SOURCE=github 时启用)
# 调用此脚本,按发版 tag(client-vX.Y.Z) 的**同一版本号**去找 `winstage-vX.Y.Z`
# 调用此脚本,按发版 tag(client-vX.Y.Z) 的**同一版本号**去 GitHub 找 `winstage-vX.Y.Z`
# 把 jiu-windows-x64-setup.exe 下到 dist/,供后续 upload-artifact → release/deploy 使用。
#
# **版本强匹配**:找不到对应版本的暂存包 → 硬失败并提示先在 GitHub 构建该版本,
# **为什么从 GitHub 而不是 NAS**mac-runner 常在 off-LANNAS 只能经 ali frps(慢又易断)
# Windows 包本就是 GitHub 云构建的,直接从 github.com 拉最稳、不过 frps。
#
# **版本强匹配**:找不到对应版本的 GitHub Release → 硬失败并提示先构建该版本,
# 绝不静默发出旧版/错版 Windows 包。
#
# 需要 envFORGEJO_URL、FORGEJO_TOKEN、GITEA_REPOSITORY
# 需要 envGH_TOKEN(github PAT,私有库拉 release 需 auth)GH_REPO(默认 bj-wangjia/jiu)
set -euo pipefail
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
VER="$(ver_from_tag "$TAG")"
VER="${TAG#client-v}"; VER="${VER#v}"
STAGE_TAG="winstage-v${VER}"
NAME="jiu-windows-x64-setup.exe"
GH_REPO="${GH_REPO:-bj-wangjia/jiu}"
: "${GH_TOKEN:?需要 GH_TOKEN(github PAT)}"
echo "==> fetch-windows-staged: 发版版本=${VER}找暂存 ${STAGE_TAG}"
base="${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}"
echo "==> fetch-windows-staged: 发版版本=${VER}从 GitHub 找 ${STAGE_TAG}repo ${GH_REPO}"
api="https://api.github.com/repos/${GH_REPO}"
mkdir -p dist
if ! info=$(curl -kfsS -H "Authorization: token ${FORGEJO_TOKEN}" "${base}/releases/tags/${STAGE_TAG}" 2>/dev/null); then
if ! info=$(curl -fsSL -H "Authorization: Bearer ${GH_TOKEN}" -H "Accept: application/vnd.github+json" "${api}/releases/tags/${STAGE_TAG}" 2>/dev/null); then
cat >&2 <<MSG
ERROR: NAS 上找不到暂存 Release ${STAGE_TAG}
ERROR: GitHub 上找不到 Release ${STAGE_TAG}
WINDOWS_SOURCE=github 需要先在 GitHub 构建**版本 ${VER}** 的 Windows 包:
gh workflow run windows.yml -f ver=${VER} (仓库 bj-wangjia/jiu)
构建完成会把安装器暂存到 NAS,再重跑本次发版即可。
gh workflow run windows.yml -f ver=${VER} (仓库 ${GH_REPO})
构建完成会发布 Windows Release,再重跑本次发版即可。
MSG
exit 1
fi
# 取同名资产的 browser_download_url(版本已由 STAGE_TAG 精确锁定)。JSON 经 stdin 传入避免转义。
# 注意:API 的 /assets/{id} 端点返回的是**元数据 JSON**(非二进制),必须用 browser_download_url。
dl=$(printf '%s' "$info" | python3 -c "import sys,json; print(next((a['browser_download_url'] for a in json.load(sys.stdin).get('assets',[]) if a['name']=='${NAME}'), ''))")
# --- 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 与发版同源。"
if [ -z "$dl" ]; then
# 取同名资产的 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
echo "ERROR: ${STAGE_TAG} 存在但缺少 ${NAME} 资产(GitHub 构建可能失败)。" >&2
exit 1
fi
echo "==> fetch-windows-staged: 下载 ${dl} → dist/${NAME}"
curl -kfsSL -H "Authorization: token ${FORGEJO_TOKEN}" "$dl" -o "dist/${NAME}"
echo "==> fetch-windows-staged: 下载 asset id=${aid}(GitHub)→ dist/${NAME}"
# 重试 + 断点续传(-C -)+ 低速/超时上限:断了自动续传重来,不会一次瞬断就整个失败。
curl -fSL \
--retry 30 --retry-delay 5 --retry-all-errors -C - \
--connect-timeout 15 --speed-time 60 --speed-limit 1024 --max-time 900 \
-H "Authorization: Bearer ${GH_TOKEN}" -H "Accept: application/octet-stream" \
"${api}/releases/assets/${aid}" -o "dist/${NAME}"
ls -lh "dist/${NAME}"
# 完整性校验:安装器至少应有 1MB,防重定向页/截断被当成有效包发出去。
sz=$(wc -c < "dist/${NAME}")
if [ "$sz" -lt 1000000 ]; then
echo "ERROR: 下载的 ${NAME}${sz} 字节,疑似重定向页/损坏,拒绝发版。" >&2
echo "ERROR: 下载的 ${NAME}${sz} 字节,疑似损坏,拒绝发版。" >&2
exit 1
fi
echo "==> fetch-windows-staged: done(版本 ${VER} 匹配)"
echo "==> fetch-windows-staged: done(版本 ${VER} 匹配,源=GitHub"
+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)
+510
View File
@@ -0,0 +1,510 @@
#!/usr/bin/env bash
# local-release.sh <version> [--only web,macos,android,ios,windows,deploy,testflight]
#
# 本地发版:在这台 mac 上完成一次完整客户端发版——本地代码、本地编译签名、
# 二进制暂存 dist/、直接部署到 ali+ iOS 上 TestFlight)。
# 取代 CI 的 deploy-client.ymlrunner→NAS gitea 隧道不稳、git checkout 反复失败)。
#
# 用法:
# sh scripts/local-release.sh 1.1.9 # 全跑(各端编译签名 → version.yaml → 部署 ali + TestFlight
# sh scripts/local-release.sh 1.1.9 --only web # 只 build web(最快,验证脚本用)
# sh scripts/local-release.sh 1.1.9 --only web,android # 只跑指定平台
# sh scripts/local-release.sh 1.1.9 --only deploy # 只部署(用 dist/ 已有产物 + 重生成 version.yaml
# sh scripts/local-release.sh 1.1.9 --only ios,testflight
#
# 阶段名:web macos android ios windows deploy testflight
# - build_* 各端编译 + 分发级签名 → dist/
# - windows 从 GitHub Release 拉已构建的安装器(本机不编 Windows)
# - deploy 生成 version.yaml + scp/ssh 到 ali(/opt/jiu)
# - testflight 用 build_ios 产出的 IPA 上传 App Store Connect
# 默认(不带 --only)顺序:web → macos → android → ios → windows → deploy → testflight
#
# ── 密钥来源(本机,全部本地,不走 CI 的 base64→临时 keychain 那套)──
# macOS 分发签名 : 钥匙串已有 "Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)"
# iOS 分发签名 : 钥匙串已有 "Apple Distribution: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)" + 已装 "Jiu App Store" profile
# 公证/上传 key : ~/.appstoreconnect/private_keys/AuthKey_3PZTHR8YMJ.p8key-id=3PZTHR8YMJ
# Android 签名 : Bitwarden 条目 "android signing jiu"rbw 取,解码成临时 keystore + 临时 key.properties
# Windows : GitHub Release winstage-v<VER>gh auth token
# 部署 : ~/.ssh/config 的 `ali` 别名(密钥直连),非 CI 的 ALI_SSH_KEY
#
# 前提:`rbw unlock` 已解锁金库;PATH 含 /opt/homebrew/bin(脚本会自动补)。
#
# 安全:keystore / key.properties / ExportOptions 等临时敏感物一律放 mktemp 目录,trap 清理,
# 绝不写进仓库或提交。
#
# gitea 上传:本脚本【不做】。空闲时再用 scripts/ci/lib-forgejo.sh 的 create_release/upload_asset
# 把 dist/ 的产物 + version.yaml 补传成 Forgejo Release client-v<VER>(供手动回滚重取 manifest)。
set -euo pipefail
# 本脚本用 bash 数组/进程替换;若被 macOS 的 /bin/sh(bash --posix) 或 dash 调起,
# 先重执行到普通 bash(须是第一条可执行语句,见 local_test.sh 同款处理)。
_posix=no
case ":${SHELLOPTS:-}:" in *:posix:*) _posix=yes ;; esac
if [ -z "${BASH_VERSION:-}" ] || [ "$_posix" = yes ]; then
exec bash "$0" "$@"
fi
unset _posix
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
# CI 镜像源等共享 envGOPROXY / PUB_HOSTED_URL / FLUTTER_STORAGE_BASE_URL
# shellcheck source=scripts/ci/_env.sh
. "$REPO_ROOT/scripts/ci/_env.sh"
# ─────────────────────────── 常量(本地密钥来源)────────────────────────────
BASE_URL="https://jiu.51yanmei.com"
DOWNLOADS_BASE="${BASE_URL}/downloads"
# macOS 分发签名身份(钥匙串已有;直接用全名匹配)
MACOS_IDENTITY="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)"
# App Store Connect API key(公证 macOS + 上传 iOS TestFlight 共用;p8 已在本机)
ASC_KEY_ID="3PZTHR8YMJ"
ASC_KEY_P8="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8"
# ⚠️ TODO(填我): App Store Connect Issuer ID —— 公证 + TestFlight 上传都需要。
# 本机 Bitwarden(jiu_ios_keys)/keychain/config 里都没找到;到
# App Store Connect → Users and Access → Integrations → App Store Connect API
# 顶部复制 "Issuer ID"UUID,形如 69a6de70-xxxx-...),填到下面,或运行前 export ISSUER_ID=...
# 只有跑 macos / ios / testflight 阶段才需要;web/android/deploy 不需要。
ISSUER_ID="${ISSUER_ID:-}"
# iOS Team / bundle id
IOS_TEAM_ID="BYL4KQHMTN"
BUNDLE_ID="com.yanmei.jiu"
# 部署目标:mac 的 ~/.ssh/config 的 `ali` 别名(密钥直连,User=root
DEPLOY_SSH="ali"
REMOTE_ROOT="/opt/jiu"
DIST="$REPO_ROOT/dist"
# ─────────────────────────────── 参数解析 ──────────────────────────────────
VERSION=""
ONLY=""
for a in "$@"; do
case "$a" in
--only) : ;; # 兼容 `--only web`(值在下一个参数,见下)
--only=*) ONLY="${a#--only=}" ;;
-h|--help) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//' | head -40; exit 0 ;;
-*) echo "!! 未知参数:$a" >&2; exit 2 ;;
*) if [ -z "$VERSION" ]; then VERSION="$a"; elif [ -z "$ONLY" ]; then ONLY="$a"; fi ;;
esac
done
# 支持空格分隔的 `--only web,android`
_prev=""
for a in "$@"; do
if [ "$_prev" = "--only" ]; then ONLY="$a"; fi
_prev="$a"
done
if [ -z "$VERSION" ]; then
echo "!! 用法:sh scripts/local-release.sh <version> [--only web,macos,android,ios,windows,deploy,testflight]" >&2
echo " 例:sh scripts/local-release.sh 1.1.9" >&2
exit 2
fi
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*) : ;;
*) echo "!! 版本号格式应为 X.Y.Z(如 1.1.9),收到:$VERSION" >&2; exit 2 ;;
esac
TAG="client-v${VERSION}"
VER="$VERSION"
# versionCode / iOS build 号:单调递增,由版本号推导(与 CI 一致:major*10000+minor*100+patch
MAJOR="${VER%%.*}"; _rest="${VER#*.}"; MINOR="${_rest%%.*}"; PATCH="${VER##*.}"
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
DEFINES=(
"--dart-define=BASE_URL=${BASE_URL}"
"--dart-define=PUBLIC_URL=${BASE_URL}"
"--dart-define=APP_VERSION=v${VER}"
)
# 决定要跑的阶段
if [ -n "$ONLY" ]; then
STAGES="$(printf '%s' "$ONLY" | tr ',' ' ')"
else
STAGES="web macos android ios windows deploy testflight"
fi
_has_stage() { printf ' %s ' "$STAGES" | grep -q " $1 "; }
log() { printf '\033[1;36m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m!! %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31mXX %s\033[0m\n' "$*" >&2; exit 1; }
mkdir -p "$DIST"
# 只在需要签名/公证/上传时才强制要求 ISSUER_ID
require_issuer_id() {
[ -n "$ISSUER_ID" ] || die "缺少 ISSUER_IDApp Store Connect Issuer ID)。到 App Store Connect → Users and Access → Integrations 复制,填进脚本顶部 ISSUER_ID= 或 export ISSUER_ID=... 后重试。"
[ -f "$ASC_KEY_P8" ] || die "缺少 App Store Connect 私钥 $ASC_KEY_P8"
}
# 同步 pubspec 版本号(+BUILD,单调;各 build 阶段都调用,幂等)
_synced_pubspec=no
sync_pubspec_version() {
[ "$_synced_pubspec" = yes ] && return 0
log "同步 pubspec 版本:${VER}+${BUILD}"
sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" client/pubspec.yaml
_synced_pubspec=yes
}
# ═════════════════════════════════ 平台函数 ═════════════════════════════════
build_web() {
log "build_web: Flutter Web(版本 ${VER}"
sync_pubspec_version
( cd client && flutter build web --release \
--base-href=/app/ "${DEFINES[@]}" )
tar -czf "$DIST/web.tar.gz" -C client/build web
log "build_web: 产物 $DIST/web.tar.gz ($(du -sh "$DIST/web.tar.gz" | cut -f1))"
}
build_macos() {
log "build_macos: Flutter macOS + Developer ID 签名 + 公证 + staple(版本 ${VER}"
require_issuer_id
sync_pubspec_version
# 快速失败:签名身份必须在钥匙串
security find-identity -v -p codesigning | grep -qF "$MACOS_IDENTITY" \
|| die "钥匙串未找到签名身份:$MACOS_IDENTITY"
( cd client && flutter build macos --release "${DEFINES[@]}" )
local APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app"
[ -d "$APP_SRC" ] || die "找不到构建产物 $APP_SRC"
local WORK; WORK="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '$WORK'" RETURN
# inside-out 签名:先签嵌套 framework/dylib,再签 apphardened runtime + 安全时间戳)
log "build_macos: 代码签名"
if [ -d "${APP_SRC}/Contents/Frameworks" ]; then
find "${APP_SRC}/Contents/Frameworks" \( -name '*.framework' -o -name '*.dylib' \) -print0 \
| xargs -0 -I{} codesign --force --options runtime --timestamp --sign "$MACOS_IDENTITY" {}
fi
codesign --force --options runtime --timestamp \
--entitlements client/macos/Runner/Release.entitlements \
--sign "$MACOS_IDENTITY" "${APP_SRC}"
codesign --verify --deep --strict --verbose=2 "${APP_SRC}"
# 公证(本地 p8,阻塞等待)
log "build_macos: 提交公证(notarytool,阻塞等待)"
ditto -c -k --keepParent "${APP_SRC}" "$WORK/notarize.zip"
xcrun notarytool submit "$WORK/notarize.zip" \
--key "$ASC_KEY_P8" --key-id "$ASC_KEY_ID" --issuer "$ISSUER_ID" --wait \
|| die "公证失败:xcrun notarytool log <submission-id> --key $ASC_KEY_P8 --key-id $ASC_KEY_ID --issuer $ISSUER_ID 看详情"
xcrun stapler staple "${APP_SRC}"
xcrun stapler validate "${APP_SRC}"
spctl -a -vvv -t install "${APP_SRC}" || true # 期望 source=Notarized Developer ID
# 打包(ditto 保留符号链接/权限/签名扩展属性;勿用 python zipfile
ditto -c -k --keepParent "${APP_SRC}" "$DIST/jiu-macos-x64.zip"
log "build_macos: 产物 $DIST/jiu-macos-x64.zip ($(du -sh "$DIST/jiu-macos-x64.zip" | cut -f1))"
}
build_android() {
log "build_android: Flutter APK + release 签名(版本 ${VER} build ${BUILD}"
sync_pubspec_version
# 从 Bitwarden 取签名材料(字段名见条目 "android signing jiu"
command -v rbw >/dev/null || die "未找到 rbwBitwarden CLI"
rbw unlocked >/dev/null 2>&1 || die "Bitwarden 金库未解锁,先 rbw unlock"
local ks_b64 ks_pw key_pw key_alias
ks_b64="$(rbw get --field ANDROID_KEYSTORE_BASE64 'android signing jiu')"
ks_pw="$(rbw get --field ANDROID_KEYSTORE_PASSWORD 'android signing jiu')"
key_pw="$(rbw get --field ANDROID_KEY_PASSWORD 'android signing jiu')"
key_alias="$(rbw get --field ANDROID_KEY_ALIAS 'android signing jiu')"
[ -n "$ks_b64" ] || die "rbw 取 ANDROID_KEYSTORE_BASE64 为空"
local WORK; WORK="$(mktemp -d)"
local KS_PROPS="client/android/key.properties"
# 临时 keystore + key.properties 都要清(key.properties 在仓库目录内,务必清)
# shellcheck disable=SC2064
trap "rm -rf '$WORK'; rm -f '$REPO_ROOT/$KS_PROPS'" RETURN
local KEYSTORE="$WORK/jiu-release.jks"
printf '%s' "$ks_b64" | base64 --decode > "$KEYSTORE"
# build.gradle.kts 期望字段:storeFile / storePassword / keyAlias / keyPassword
cat > "$KS_PROPS" <<EOF
storeFile=${KEYSTORE}
storePassword=${ks_pw}
keyAlias=${key_alias}
keyPassword=${key_pw}
EOF
( cd client && flutter build apk --release "${DEFINES[@]}" )
local APK=""
for cand in \
client/build/app/outputs/flutter-apk/app-release.apk \
client/build/app/outputs/apk/release/app-release.apk; do
[ -f "$cand" ] && { APK="$cand"; break; }
done
[ -n "$APK" ] || die "找不到构建产物 APK"
cp "$APK" "$DIST/jiu-android.apk"
log "build_android: 产物 $DIST/jiu-android.apk ($(du -sh "$DIST/jiu-android.apk" | cut -f1))"
# trap 会清掉 key.properties + 临时 keystore
}
# iOSmanual 签名(App Store),用钥匙串已有 Apple Distribution 证书 + 已装 "Jiu App Store" profile。
# 不导入任何 base64(证书/profile 本机已有)。产出 IPA 供 upload_testflight 用。
build_ios() {
log "build_ios: Flutter IPAapp-storemanual 签名,版本 ${VER} build ${BUILD}"
sync_pubspec_version
# 自动发现已装的 App Store 分发 profileAppID=BUNDLE_ID、无 ProvisionedDevices
local PROFILE_NAME=""
local pdir="$HOME/Library/MobileDevice/Provisioning Profiles"
if [ -d "$pdir" ]; then
local p plist name appid
for p in "$pdir"/*.mobileprovision; do
[ -e "$p" ] || continue
plist="$(security cms -D -i "$p" 2>/dev/null)" || continue
appid="$(printf '%s' "$plist" | /usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' /dev/stdin 2>/dev/null || true)"
# App Store profile:无 ProvisionedDevices
if [ "$appid" = "${IOS_TEAM_ID}.${BUNDLE_ID}" ] \
&& ! printf '%s' "$plist" | /usr/libexec/PlistBuddy -c 'Print :ProvisionedDevices' /dev/stdin >/dev/null 2>&1; then
name="$(printf '%s' "$plist" | /usr/libexec/PlistBuddy -c 'Print :Name' /dev/stdin 2>/dev/null || true)"
[ -n "$name" ] && { PROFILE_NAME="$name"; break; }
fi
done
fi
[ -n "$PROFILE_NAME" ] || PROFILE_NAME="Jiu App Store" # 已知已装 profile 名
log "build_ios: 使用 provisioning profile '${PROFILE_NAME}'"
local WORK; WORK="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '$WORK'" RETURN
cat > "$WORK/ExportOptions.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${IOS_TEAM_ID}</string>
<key>signingStyle</key><string>manual</string>
<key>uploadBitcode</key><false/>
<key>uploadSymbols</key><true/>
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
</dict>
</dict>
</plist>
EOF
( cd client && flutter build ipa --release \
--build-name="${VER}" \
--build-number="${BUILD}" \
--export-options-plist="$WORK/ExportOptions.plist" \
"${DEFINES[@]}" )
local IPA; IPA="$(ls client/build/ios/ipa/*.ipa 2>/dev/null | head -1 || true)"
[ -n "$IPA" ] && [ -f "$IPA" ] || die "找不到 IPA 产物(client/build/ios/ipa/*.ipa"
cp "$IPA" "$DIST/jiu-ios.ipa"
log "build_ios: 产物 $DIST/jiu-ios.ipaTestFlight 上传见 upload_testflight"
}
upload_testflight() {
log "upload_testflight: 上传 IPA 到 App Store Connect(TestFlight)"
require_issuer_id
local IPA="$DIST/jiu-ios.ipa"
[ -f "$IPA" ] || IPA="$(ls client/build/ios/ipa/*.ipa 2>/dev/null | head -1 || true)"
[ -n "$IPA" ] && [ -f "$IPA" ] || die "找不到 IPA,先跑 build_ios"
# altool 会从 ~/.appstoreconnect/private_keys/AuthKey_<id>.p8 自动读取私钥
xcrun altool --upload-app -f "$IPA" -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ISSUER_ID" \
|| die "TestFlight 上传失败"
log "upload_testflight: 完成,build ${BUILD} 已上传"
}
fetch_windows() {
log "fetch_windows: 从 GitHub Release 拉 Windows 安装器(winstage-v${VER}"
local tok; tok="$(gh auth token 2>/dev/null || true)"
[ -n "$tok" ] || die "gh 未登录(gh auth login),无法拉 Windows 包"
GH_TOKEN="$tok" GH_REPO="${GH_REPO:-bj-wangjia/jiu}" \
bash "$REPO_ROOT/scripts/ci/fetch-windows-staged.sh" "$TAG"
log "fetch_windows: 产物 $DIST/jiu-windows-x64-setup.exe"
}
# 生成/更新 backend/config/version.yaml(复用 release-client.sh 的 python;跳过 gitea Release)。
# download URLs 指 https://jiu.51yanmei.com/downloads/...;追加最近 3 条 changelog。
ensure_version_yaml() {
[ -f "$DIST/version.yaml" ] && [ "${1:-}" != force ] && return 0
log "version.yaml: 更新 backend/config/version.yamlversion/build_number/urls/changelog"
local NOTES
NOTES="$(python3 - "CHANGELOG-client.md" <<'PYEOF'
import re, sys
fn = sys.argv[1]
try:
content = open(fn, encoding='utf-8').read()
parts = re.split(r'\n## ', '\n' + content)
if len(parts) > 1:
lines = parts[1].strip().split('\n')
notes = [l.strip() for l in lines[1:] if l.strip() and not l.strip().startswith('## ')]
# 到下一个 ## 为止
out=[]
for l in lines[1:]:
s=l.strip()
if s.startswith('## '): break
if s: out.append(s)
print('\n'.join(out) if out else lines[0])
else:
print('')
except Exception:
print('')
PYEOF
)"
log "version.yaml: release_notes=${NOTES}"
python3 - "$VER" "$NOTES" "CHANGELOG-client.md" "$DOWNLOADS_BASE" <<'PYEOF'
import sys, re, json
ver, notes, changelog_file, base = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
mac_url = f"{base}/jiu-macos-x64.zip"
win_url = f"{base}/jiu-windows-x64-setup.exe"
android_url = f"{base}/jiu-android.apk"
with open('backend/config/version.yaml', encoding='utf-8') as f:
raw = f.read()
raw = re.split(r'\nchangelog:', raw, maxsplit=1)[0].rstrip('\n') + '\n'
out = []
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:
_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)
elif line.startswith('release_notes:'):
safe = notes.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '')
out.append('release_notes: "%s"\n' % safe)
elif line.startswith(' macos:'):
out.append(' macos: "%s"\n' % mac_url)
elif line.startswith(' windows:'):
out.append(' windows: "%s"\n' % win_url)
elif line.startswith(' android:'):
out.append(' android: "%s"\n' % android_url)
else:
out.append(line)
versions = []
cur = None; sec = None
for line in open(changelog_file, encoding='utf-8'):
line = line.rstrip('\n')
m = re.match(r'^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})', line)
if m:
if cur: versions.append(cur)
if len(versions) >= 3:
cur = None; break
cur = {'version': m.group(1), 'date': m.group(2), 'intro': '', 'sections': []}
sec = None; continue
ms = re.match(r'^### (.+)', line)
if ms and cur is not None:
sec = {'type': ms.group(1).strip(), 'items': []}
cur['sections'].append(sec); continue
mi = re.match(r'^- (.+)', line)
if mi and cur is not None and sec is not None:
sec['items'].append(mi.group(1).strip()); continue
if line.strip() and cur is not None and sec is None and not line.startswith('#'):
cur['intro'] = (cur['intro'] + ' ' + line.strip()).strip()
if cur is not None and len(versions) < 3:
versions.append(cur)
text = ''.join(out).rstrip('\n') + '\n'
text += 'changelog: ' + json.dumps(versions, ensure_ascii=False) + '\n'
with open('backend/config/version.yaml', 'w', encoding='utf-8') as f:
f.write(text)
print('version.yaml updated to', ver, '|', len(versions), 'changelog entries')
PYEOF
cp backend/config/version.yaml "$DIST/version.yaml"
log "version.yaml: 暂存 $DIST/version.yaml"
}
# 部署到 aliweb 解到 /opt/jiu/web、version.yaml 到 /opt/jiu/config、下载包到 /opt/jiu/downloads。
# 用 ~/.ssh/config 的 `ali` 别名直连(非 CI 部署 key)。不重启后端(version.yaml 按请求读)。
deploy_ali() {
log "deploy_ali: 部署到 ${DEPLOY_SSH}:${REMOTE_ROOT}"
ensure_version_yaml force
[ -f "$DIST/web.tar.gz" ] || die "$DIST/web.tar.gz,先跑 build_web(或 --only web,deploy"
[ -f "$DIST/version.yaml" ] || die "$DIST/version.yaml"
log "deploy_ali: dist/ 内容:"
ls -lh "$DIST"
local STAGE; STAGE="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '$STAGE'" RETURN
mkdir -p "$STAGE/jiu-web-new"
tar -xzf "$DIST/web.tar.gz" -C "$STAGE/jiu-web-new" --strip-components=1
log "deploy_ali: 上传 web + version.yaml + 安装包"
# web 目录用 rsync(走 ssh 别名);--delete 保证原子替换后无残留
rsync -az --delete -e "ssh" "$STAGE/jiu-web-new/" "${DEPLOY_SSH}:/tmp/jiu-web-new/"
scp "$DIST/version.yaml" "${DEPLOY_SSH}:/tmp/version.yaml"
[ -f "$DIST/jiu-macos-x64.zip" ] && scp "$DIST/jiu-macos-x64.zip" "${DEPLOY_SSH}:/tmp/jiu-macos-x64.zip" || true
[ -f "$DIST/jiu-android.apk" ] && scp "$DIST/jiu-android.apk" "${DEPLOY_SSH}:/tmp/jiu-android.apk" || true
[ -f "$DIST/jiu-windows-x64-setup.exe" ] && scp "$DIST/jiu-windows-x64-setup.exe" "${DEPLOY_SSH}:/tmp/jiu-windows-x64-setup.exe" || true
log "deploy_ali: 远端就位(切 web / version.yaml / downloads"
ssh "${DEPLOY_SSH}" 'bash -s' <<'ENDSSH'
set -e
# version.yaml(后端按请求读,无需重启)
mkdir -p /opt/jiu/config
cp /tmp/version.yaml /opt/jiu/config/version.yaml
# 原子切换 Flutter web
rm -rf /opt/jiu/web-old
mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true
mv /tmp/jiu-web-new /opt/jiu/web
# 发布安装器到 nginx 的 downloads 目录,只留最新
mkdir -p /opt/jiu/downloads
rm -f /opt/jiu/downloads/jiu-windows-* /opt/jiu/downloads/jiu-macos-* /opt/jiu/downloads/jiu-android-* /opt/jiu/downloads/jiu-android.apk 2>/dev/null || true
[ -f /tmp/jiu-macos-x64.zip ] && mv -f /tmp/jiu-macos-x64.zip /opt/jiu/downloads/ || true
[ -f /tmp/jiu-android.apk ] && mv -f /tmp/jiu-android.apk /opt/jiu/downloads/ || true
[ -f /tmp/jiu-windows-x64-setup.exe ] && mv -f /tmp/jiu-windows-x64-setup.exe /opt/jiu/downloads/ || true
echo "Client deploy complete!"
ENDSSH
log "deploy_ali: 完成"
}
# ═════════════════════════════════ 主流程 ══════════════════════════════════
log "本地发版 ${TAG}(版本 ${VER} build ${BUILD})|阶段:${STAGES}"
_has_stage web && build_web
_has_stage macos && build_macos
_has_stage android && build_android
_has_stage ios && build_ios
_has_stage windows && fetch_windows
_has_stage deploy && deploy_ali
_has_stage testflight && upload_testflight
log "全部完成。dist/ 产物:"
ls -lh "$DIST" 2>/dev/null || true
cat <<EOF
下一步(可选):空闲、网络稳时把本次产物补传成 Forgejo Release(供手动回滚重取 manifest):
source scripts/ci/lib-forgejo.sh
# 需 export FORGEJO_URL / FORGEJO_TOKEN / GITEA_REPOSITORY
create_release "$TAG" '"local release $TAG"'
upload_asset dist/web.tar.gz; upload_asset dist/version.yaml
upload_asset dist/jiu-macos-x64.zip; upload_asset dist/jiu-android.apk; upload_asset dist/jiu-windows-x64-setup.exe
EOF