Compare commits

...

16 Commits

Author SHA1 Message Date
wangjia 40d9aba657 chore: release client-v1.0.55 / site-v1.0.55 / server-v1.0.55
Deploy Server / release-deploy-server (push) Successful in 42s
Deploy Client / build-client-web (push) Successful in 40s
Deploy Site / release-deploy-site (push) Successful in 22s
Deploy Client / build-macos (push) Successful in 1m28s
Deploy Client / build-android (push) Successful in 1m7s
Deploy Client / build-ios (push) Successful in 2m44s
Deploy Client / build-windows (push) Successful in 1m51s
Deploy Client / release-deploy-client (push) Successful in 1m23s
三条流水线首个独立版本,统一从 1.0.55 起步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:27:53 +08:00
wangjia aa7099ba94 feat(devops): 发版拆分为 client/site/server 三条独立流水线
将单一 CI/CD 流水线拆成三条互不影响的发布流水线,各有 tag 前缀、独立版本
序列与独立 CHANGELOG:

- client(client-v*):Flutter 全平台 + version.yaml 自更新清单
- site(site-v*):web/ Eleventy 营销站,不含 web 版 app
- server(server-v*):backend Go 服务 + 共享基建 nginx/systemd

新增 3 个 workflow(deploy-client/site/server.yml)替换 deploy.yml;CI 脚本
按 part 拆分为 compile-/release-/deploy-{client,site,server}.sh,抽出公共函数
lib-forgejo.sh;compile-{macos,android,ios,windows}.sh 改去 client-v 前缀;
manual.yml 按前缀路由回滚。

跨流水线解耦:version.yaml 归 client,后端每请求实时读取(不重启、不触发
server 流水线);官网下载页的版本徽章/下载链接/更新日志时间线运行时经
/api/v1/public/release 动态拉取(API 不可达回退构建时静态内容)。为此一次性
扩展后端 changelog 字段(version.go/public.go)与 download.njk 动态渲染。

CHANGELOG.md 重命名为 CHANGELOG-client.md,新增 CHANGELOG-site/server.md;
重写 /release 命令为 /release <part> [version];同步更新 CLAUDE.md 与部署文档。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:21:28 +08:00
wangjia eca62ba2c3 fix(backend): 库存导入改用 shakinm/xlsReader,按唯一编号去重,入库日期取自 Excel
extrame/xls 误读真实 .xls(3264 行只读出 455 行、编号丢失、数字被当
日期序列),换用 shakinm/xlsReader 正确读取全部行。去重改为「有商品编号
时仅按编号匹配」,避免不同批次被 NSS 回退合并;Inventory.ProductCode
存行自身唯一编号。入库时间取 Excel 入库日期列写入 created_at。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:04:04 +08:00
wangjia 21700fa64c chore: release v1.0.54
Deploy / build-linux-web (push) Successful in 52s
Deploy / build-windows (push) Successful in 1m58s
Deploy / build-macos (push) Successful in 1m27s
Deploy / build-android (push) Successful in 1m32s
Deploy / build-ios (push) Successful in 2m51s
Deploy / release-deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 06:57:43 +08:00
wangjia 5cfd1795ec fix(backend): 库存导入用 Excel「入库日期」+ 同 key 增量更新/全同跳过
- 读取 Excel「入库日期」列(默认第12列,兼容入库日期/入库时间/入库日),
  显式写入库存 CreatedAt,不再被 GORM 落成导入当天;新增
  parseTimeWithFallback 兼容 yyyy-MM-dd / 含时分秒 / Excel 序列号
- 同 key(商品编码|仓库,回退 名+系列+规格|仓库):全字段一致则跳过,
  仅差异字段增量更新;库存流水仅在 quantity 实际变化时追加
- 响应新增 skipped 计数;多租户 shop_id 隔离不变

go build / vet / test 全过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:56:31 +08:00
wangjia f15692bbd5 refactor(client): 只读判断统一走 WriteGuard 控件
将各屏内联的 ref.watch(isReadonlyProvider) 只读判断统一改为
WriteGuard(child:) 或 WriteGuard.isReadonly(ref),全项目只读逻辑
单一入口(仅 write_guard.dart 内部保留 provider 读取)。行为不变。

121 测试通过,analyze 无 error。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:56:31 +08:00
wangjia 196901b6d3 feat(client): 只读角色隐藏所有写按钮(WriteGuard 统一控件 + 顶栏只读标识)
- 新增 isReadonlyProvider(role == 'readonly')+ WriteGuard 控件,统一隐藏写控件
- 入库/出库/库存/商品/基础数据/往来单位/财务/设置 各屏:只读时隐藏
  新增/编辑/删除/审核/提交/驳回/结清/盘点/导入/激活/用户管理 等写操作
- 顶栏显示「只读」标识,让只读用户明确当前权限
- 后端 middleware.ReadOnly() 仍兜底 403,UI 隐藏 + 后端拦截双保险

修正:此前误判为后端漏洞/部署 bug,实为前端写按钮未对只读隐藏。
后端经验证正确拦截只读写操作(线上 stock-in/product-options 均 403)。

121 测试通过,analyze 无 error。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:42:40 +08:00
wangjia 53465ed704 fix(client): Info.plist 补 NSLocationWhenInUseUsageDescription,消除 ITMS-90683
某依赖引用定位 API,Apple 要求声明用途字符串(即便 App 不主动用定位)。
补上后正式提交 App Store / Beta 审核不再被该警告卡住。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:46:32 +08:00
wangjia 5371d40f05 chore: 彻底停用项目 todo(删除 todo/ 目录 + CLAUDE.md 规则改为停用)
按用户要求停用 todo skill。todo/ 历史保留在 git 记录中可恢复。
CLAUDE.md 项目规则覆盖全局 todo 规则:本项目不再记 todo。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:43:12 +08:00
wangjia a63ee263ca chore: 清空项目 todo(按用户要求,历史保留在 git 记录中)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:38:30 +08:00
wangjia e8bdb19195 feat(client): 抽屉+设置页加退出登录入口;酒行信息显示微信号
- 左侧抽屉底部、系统设置页底部各加「退出登录」按钮(窄屏顶栏菜单
  不便时也能退出;登出后路由 redirect 自动跳 /login)
- 酒行信息 tab 补显示「微信号」行(模型/编辑弹窗早已支持 wechat_id,
  仅只读展示遗漏)

121 测试通过。todo #54 #55。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:26:17 +08:00
wangjia cd672e500f fix(client): iOS 顶栏避开状态栏,修复菜单按钮被遮挡退不出登录
顶栏 Container 原从 y=0 起绘,iOS 状态栏盖住左上角菜单按钮,窄屏无法
打开抽屉菜单/退出登录。顶栏高度加上 MediaQuery.padding.top,内容下移
一个状态栏高度,蓝色仍铺满到屏幕顶。桌面/Web 状态栏高度为 0 不受影响。

todo #53。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:20:07 +08:00
wangjia 018180de8c feat(client): 网络请求重试 + 离线提示可感知重试按钮
修复①请求级重试:新增 RetryInterceptor,网络层失败重试 3 次间隔递增
1s→2s→4s;GET 全重试,写操作仅在连接未建立时重试以防重复提交。
connectTimeout 5s→8s。

修复②连通性:启动首检改退避重试 4s→8s→12s,根治跨境冷启动假离线;
新增检测中状态 + retry();登录页/全局离线提示加 NetworkRetryButton,
重试中转圈、结果 SnackBar 反馈。

121 个测试通过,analyze 无 error。todo #52。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:42:23 +08:00
wangjia 3f3aa41121 feat(client): iOS Info.plist 声明加密豁免,免每次上传答出口合规
加 ITSAppUsesNonExemptEncryption=NO,App 仅用标准 HTTPS 属豁免加密。
以后上传 TestFlight/App Store 不再弹「App 加密文稿」手动问答。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:38:47 +08:00
wangjia 73dbd65ef5 fix(client): iOS Release 配置改手动签名,修复 CI archive 自动签名失败
archive 阶段原走 Automatic 签名,会去申请 Development profile 并失败
(Failed Registering Bundle Identifier / No profiles found)。改为 Manual
并指定 Team BYL4KQHMTN + Apple Distribution 证书 + Jiu App Store profile,
与 compile-ios.sh 的 app-store ExportOptions 一致。仅改 Release,
Debug 保持 Automatic,不影响本地开发。本机已用真实证书验证签名 IPA 构建成功。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:29:40 +08:00
wangjia 8749bbae72 ci: 新增 test-ios workflow 手动验证 iOS 签名上传链路
只跑 iOS 构建+上传 TestFlight,workflow_dispatch 触发,默认版本 v0.0.1
(build 号最低,不与正式发版冲突),用于验证 7 个签名 secret 配置正确。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:20:12 +08:00
66 changed files with 1746 additions and 4726 deletions
+46 -36
View File
@@ -1,17 +1,26 @@
---
description: 执行本地发版流程(build → test → CHANGELOG → commit → tag → push
argument-hint: <version> 如 1.0.22
description: 执行某一条流水线的本地发版(part ∈ client|site|server
argument-hint: <part> [version] 如 `client 1.0.55``server`
model: claude-sonnet-4-6
allowed-tools: Bash, Read, Edit, Write
---
执行本地发版流程。
执行三条独立流水线之一的本地发版流程。`$ARGUMENTS` 形如 `<part> [version]`
**确定版本号:**
- 如果 `$ARGUMENTS` 非空,使用它作为版本号(VERSION)
- 如果 `$ARGUMENTS` 为空,运行 `git describe --tags --abbrev=0` 获取最新 tag(如 `v1.0.20`),去掉 `v` 前缀后将 patch 号加 1(如 `1.0.21`),作为版本号(VERSION)。将自动确定的版本号告知用户。
- **part**(必填):`client` | `site` | `server`,分别对应 tag 前缀 `client-v*` / `site-v*` / `server-v*`、CHANGELOG 文件 `CHANGELOG-client.md` / `CHANGELOG-site.md` / `CHANGELOG-server.md`
- **version**(选填):如 `1.0.55`
后续所有步骤中,将确定好的版本号记为 VERSION
**三者互不影响**client 发版只动 app 产物与 `version.yaml`(官网下载页与后端 `/version` 自动同步,无需重建官网/重启后端);site 只重建营销站;server 只换后端二进制 + nginx
## 0. 解析参数
- 校验 part 合法(client|site|server),非法立即报错退出。
- 记 PART = part。
**确定版本号 VERSION**
- 给了 version:直接用。
- 没给:`git describe --tags --match "<PART>-v*" --abbrev=0` 取该前缀最新 tag(如 `client-v1.0.54`),去掉 `<PART>-v` 前缀后 patch +1(如 `1.0.55`)。若该前缀尚无任何 tag,则 VERSION=`1.0.0`。将自动确定的版本号告知用户。
- 记 TAG = `<PART>-v<VERSION>`CHANGELOG_FILE = `CHANGELOG-<PART>.md`
按以下步骤顺序执行,任意步骤失败立即停止并报告错误。
@@ -19,27 +28,31 @@ allowed-tools: Bash, Read, Edit, Write
检查是否有未提交的改动(`git status`)。如果有,列出文件并询问用户是否继续。
## 2. 本地构建
## 2. 本地构建 + 测试门禁(按 part
```bash
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go build ./...
```
## 3. 运行测试
- **server**
```bash
cd backend && go build ./... && go vet ./... && go test ./...
```
- **client**
```bash
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
```
- **site**
```bash
cd web && npm ci && npm run build
```
```bash
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go test ./...
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
```
## 3. 更新 CHANGELOG_FILE
## 4. 更新 CHANGELOG.md
检查 CHANGELOG.md 中是否已有 `## [VERSION]` 版本节。
检查 CHANGELOG_FILE 中是否已有 `## [VERSION]` 版本节。
- **已有**:直接使用,不修改。
- **没有**:读取 `git log` 从上一个 tag 到 HEAD 的提交记录,自动生成一个版本节插入文件顶部(位于已有版本节之前),格式如下
- **没有**:读取该 part 上一个 tag`<PART>-v*`)到 HEAD 的 `git log`,自动生成版本节插入文件顶部(位于已有版本节之前),格式:
```
## [VERSION] - <今天日期>
@@ -54,36 +67,33 @@ cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
- ...
```
只保留有内容的分类(新功能/改进/修复),根据 commit message 的类型(feat/fix/refactor 等)归类。
只保留有内容的分类,按 commit 类型(feat/fix/refactor 等)归类。
**CHANGELOG 撰写原则:**
- 站在**用户视角**总结,描述对使用体验的实际影响,而非技术实现
- **忽略**以下类型的提交(产品感知弱):
- 发版工具、CI/CD、部署脚本相关(chore: release、ci:、devops:
- 内部文档、开发规范、CLAUDE.md 更新(docs(claude):、docs(internal):
- 代码格式、lint、重构(style:、refactor: 纯内部)
- 依赖升级、版本号 bumpchore: bump、chore: upgrade
- **保留并重点描述**:feat、fix、perf 类,以及用户能感知到的 refactor(如界面改版、交互优化)
- 每条描述用一句话说清楚"用户能做什么"或"修了什么问题",不堆砌技术术语
- 站在**用户视角**总结,描述对使用体验的实际影响,而非技术实现
- **忽略**产品感知弱的提交:发版/CI/部署(chore: release、ci:、devops:)、内部文档/规范(docs(claude):、docs(internal):)、纯格式/重构(style:、refactor:)、依赖升级(chore: bump)。
- **保留并重点描述** feat / fix / perf,以及用户可感知的 refactor(界面改版、交互优化)。
- 每条一句话说清"用户能做什么"或"修了什么问题"。
- **范围按 part 过滤**client 只收 `client/` 相关;server 只收 `backend/` 相关;site 只收 `web/`(营销站)相关。
## 5. 提交代码
## 4. 提交代码
将所有改动(包括 CHANGELOG.md)用以下格式提交到 main
将所有改动( CHANGELOG_FILE提交到 main
```
chore: release vVERSION
chore: release <PART>-v<VERSION>
```
## 6. 打 tag
## 5. 打 tag
```bash
git tag vVERSION
git tag <PART>-v<VERSION>
```
## 7. 推送
## 6. 推送
```bash
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main vVERSION
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main <PART>-v<VERSION>
```
推送成功后提示用户:CI/CD 已触发,等待 Telegram 通知。
推送成功后提示用户:对应流水线(deploy-<PART>已触发,等待 Telegram 通知。
@@ -1,12 +1,16 @@
name: Deploy
name: Deploy Client
on:
push:
tags:
- 'v[0-9]*.[0-9]*.[0-9]*'
- 'client-v[0-9]*.[0-9]*.[0-9]*'
concurrency:
group: deploy-client
cancel-in-progress: false
jobs:
build-linux-web:
build-client-web:
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
@@ -19,19 +23,17 @@ jobs:
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: Compile (Linux backend + Flutter Web)
run: sh scripts/ci/compile.sh "${{ gitea.ref_name }}"
- name: Compile (Flutter Web)
run: sh scripts/ci/compile-client-web.sh "${{ gitea.ref_name }}"
- name: Upload linux-web artifacts
- name: Upload web artifacts
uses: actions/upload-artifact@v3
with:
name: linux-web
name: client-web
path: dist/
build-macos:
# Same physical runner as build-linux-web (label: mac, capacity 1). Run them
# serially via needs so the second job doesn't sit queued and time out.
needs: build-linux-web
needs: build-client-web
runs-on: mac
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -53,9 +55,6 @@ jobs:
path: dist/
build-android:
# Same physical mac runner (label: mac, capacity 1) — it already has the
# Android SDK. Serialize after build-macos via needs so it doesn't sit
# queued and time out.
needs: build-macos
runs-on: mac
env:
@@ -83,10 +82,6 @@ jobs:
path: dist/
build-ios:
# Same physical mac runner (label: mac, capacity 1) with Xcode. Serialize
# after build-android. Builds a signed IPA and uploads to TestFlight; if the
# iOS signing secrets are absent the script skips gracefully (exit 0), so this
# job is safe to keep before the Apple Developer account is configured.
needs: build-android
runs-on: mac
env:
@@ -134,8 +129,8 @@ jobs:
name: windows
path: dist/
release-deploy:
needs: [build-linux-web, build-macos, build-android, build-ios, build-windows]
release-deploy-client:
needs: [build-client-web, build-macos, build-android, build-ios, build-windows]
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
@@ -145,10 +140,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download linux-web artifacts
- name: Download web artifacts
uses: actions/download-artifact@v3
with:
name: linux-web
name: client-web
path: dist/
- name: Download macos artifacts
@@ -169,15 +164,15 @@ jobs:
name: windows
path: dist/
- name: Test
run: sh scripts/ci/test.sh
- name: Test (flutter analyze)
run: sh scripts/ci/test.sh client
- name: Release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release.sh "${{ gitea.ref_name }}"
run: sh scripts/ci/release-client.sh "${{ gitea.ref_name }}"
- name: Deploy
env:
@@ -187,7 +182,7 @@ jobs:
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}"
run: sh scripts/ci/deploy-client.sh "${{ gitea.ref_name }}"
- name: Notify
if: always()
+54
View File
@@ -0,0 +1,54 @@
name: Deploy Server
on:
push:
tags:
- 'server-v[0-9]*.[0-9]*.[0-9]*'
concurrency:
group: deploy-server
cancel-in-progress: false
jobs:
release-deploy-server:
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: Compile (Linux backend + configs)
run: sh scripts/ci/compile-backend.sh "${{ gitea.ref_name }}"
- name: Test (go test)
run: sh scripts/ci/test.sh server
- name: Release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-server.sh "${{ gitea.ref_name }}"
- name: Deploy
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy-server.sh "${{ gitea.ref_name }}"
- name: Notify
if: always()
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}"
+50
View File
@@ -0,0 +1,50 @@
name: Deploy Site
on:
push:
tags:
- 'site-v[0-9]*.[0-9]*.[0-9]*'
concurrency:
group: deploy-site
cancel-in-progress: false
jobs:
release-deploy-site:
runs-on: mac
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: Compile (Eleventy marketing site)
run: sh scripts/ci/compile-site.sh "${{ gitea.ref_name }}"
- name: Release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-site.sh "${{ gitea.ref_name }}"
- name: Deploy
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy-site.sh "${{ gitea.ref_name }}"
- name: Notify
if: always()
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}"
+23 -3
View File
@@ -1,10 +1,16 @@
name: Manual Deploy
# Re-deploy a previously released tag. The tag prefix selects which part:
# client-v* -> deploy-client.sh (web app + installers + version.yaml)
# site-v* -> deploy-site.sh (marketing site)
# server-v* -> deploy-server.sh (backend binary + nginx)
# Each deploy script downloads its assets from the matching Forgejo Release.
on:
workflow_dispatch:
inputs:
version:
description: 'Tag to deploy (e.g. v1.0.0)'
description: 'Prefixed tag to deploy (e.g. client-v1.0.55 / site-v1.0.0 / server-v1.0.0)'
required: true
type: string
@@ -17,7 +23,7 @@ jobs:
with:
ref: ${{ inputs.version }}
- name: Deploy
- name: Deploy (routed by tag prefix)
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
@@ -25,4 +31,18 @@ jobs:
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy.sh "${{ inputs.version }}"
run: |
TAG="${{ inputs.version }}"
case "$TAG" in
client-v*) sh scripts/ci/deploy-client.sh "$TAG" ;;
site-v*) sh scripts/ci/deploy-site.sh "$TAG" ;;
server-v*) sh scripts/ci/deploy-server.sh "$TAG" ;;
*) echo "Unknown tag prefix: $TAG (expected client-v* / site-v* / server-v*)" >&2; exit 1 ;;
esac
- name: Notify
if: always()
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ inputs.version }}" "${{ job.status }}"
+36
View File
@@ -0,0 +1,36 @@
name: Test iOS Build
# 手动触发,只跑 iOS 构建 + 上传 TestFlight,用于验证签名链路(证书/Profile/API Key
# 是否正常,无需打 tag 发版。默认版本 v0.0.1(build 号最低=1,不与正式发版冲突)。
on:
workflow_dispatch:
inputs:
version:
description: '测试版本号(build 号由此推导,默认 v0.0.1 不与正式版冲突)'
required: true
type: string
default: 'v0.0.1'
jobs:
build-ios:
runs-on: mac
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: Compile & upload to TestFlight (Flutter iOS)
env:
IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }}
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }}
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
run: sh scripts/ci/compile-ios.sh "${{ inputs.version }}"
+3
View File
@@ -59,3 +59,6 @@ backend/issue
# Claude 私有文件
.claude/commands/
.claude/worktrees/
# iOS 证书私钥(绝不入库)
.ios-certs/
+20
View File
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.55] - 2026-06-17
### 改进
- 客户端启用独立发版流水线(`client-v*`),版本迭代与官网、后端解耦,更新推送更及时
## [1.0.54] - 2026-06-17
### 新功能
- 只读账号自动隐藏所有写操作按钮(新增/编辑/删除/审核/提交/结清/导入等),顶栏显示「只读」标识,权限一目了然
- 左侧抽屉和系统设置页新增「退出登录」入口,窄屏也能方便退出
- 酒行信息新增「微信号」显示
### 改进
- 网络请求失败时自动重试(间隔递增),离线时提供带状态反馈的「重试」按钮
- iOS 顶栏避开状态栏,菜单按钮不再被时间/信号遮挡
### 修复
- 库存导入改用 Excel 中的「入库日期」作为入库时间(不再统一记为导入当天);相同数据重复导入时自动跳过,仅更新有变化的字段,避免重复与误覆盖
- 修复 iOS 上顶栏被状态栏遮挡导致无法打开菜单/退出登录的问题
## [1.0.53] - 2026-06-15
### 修复
+14
View File
@@ -0,0 +1,14 @@
# Changelog — Server(后端)
后端服务(`backend/``jiu-server`)的更新记录。tag 前缀 `server-v*`
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.55] - 2026-06-17
### 新功能
- 后端服务独立发版流水线启用(`server-v*`),与客户端、官网互不影响
### 修复
- 库存导入改用更稳定的 Excel 解析库,按唯一编号自动去重,入库时间取自表格中的「入库日期」
+15
View File
@@ -0,0 +1,15 @@
# Changelog — Site(官网)
营销宣传站(`web/`Eleventy)的更新记录。tag 前缀 `site-v*`
**不含** Web 版 appWeb 版 app 属于客户端,见 `CHANGELOG-client.md`)。
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.55] - 2026-06-17
### 新功能
- 官网独立发版流水线启用(`site-v*`),与客户端、后端互不影响
### 改进
- 下载页「更新日志」改为运行时从接口动态拉取,客户端发版后即时刷新、无需重建官网;接口不可达时回退到内置内容
+28 -32
View File
@@ -261,22 +261,36 @@ cd client && flutter test
### 发版流程
使用 `/release <version>` slash command
**发版拆成三条互不影响的流水线**,各有 tag 前缀、独立版本序列、独立 CHANGELOG
| part | 范围 | tag 前缀 | CHANGELOG | workflow |
|------|------|---------|-----------|----------|
| **client** | `client/` Flutter 全平台(Web→`/app`、macOS、Windows、Android、iOS+ 应用自更新清单 `version.yaml` | `client-v*` | `CHANGELOG-client.md` | `deploy-client.yml` |
| **site** | `web/` Eleventy 营销宣传站(→`/opt/jiu/marketing`nginx `/`)。**不含** Web 版 app | `site-v*` | `CHANGELOG-site.md` | `deploy-site.yml` |
| **server** | `backend/` Go 服务(`jiu-server`+ 共享基建 nginx-jiu.conf / jiu.service | `server-v*` | `CHANGELOG-server.md` | `deploy-server.yml` |
使用 `/release <part> [version]` slash command
```
/release 1.0.2
/release client 1.0.55 # 指定版本
/release server # 省略则自增 server-v* 最新 tag 的 patch
```
执行顺序:本地 build → test → 更新 CHANGELOG.md → git commit → tag → push main+tag。
CI/CDForgejo`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
执行顺序:本地 build → test(按 part→ 更新对应 CHANGELOG → git commit → tag `<part>-v<ver>` → push main+tag。
CI/CDForgejo)按 tag 前缀触发对应 workflow自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
**多平台构建矩阵**(脚本在 `scripts/ci/`):
- mac runner(容量 1)串行链:`build-linux-web`(后端+Web+官网)→ `build-macos` → `build-android` → `build-ios` → `release-deploy`
- windows runner 并行:`build-windows`Inno Setup 打包 setup.exe
- 分发:桌面(Win/macOS) 发布到下载页 `/downloads/` + 应用内更新;**Android** APK 同样挂 `/downloads/`,需 `ANDROID_*` 签名 secrets(见 `docs/android-signing.md`);**iOS** 走 TestFlight,需 `IOS_*` / `APPSTORE_*` secrets(见 `docs/ios-signing.md`
- Android/iOS 的 job 在对应 secrets 未配置时**优雅跳过**(exit 0,不阻塞发版);本地无 `client/android/key.properties` 时 APK 自动回退 debug 签名
**归属与解耦**(关键):
- `version.yaml` 归 **client**(写 version/build_number/release_notes/下载链接/changelog);nginx/systemd 归 **server**。
- 后端 `/version` 与 `/api/v1/public/release` **每请求实时读** `version.yaml`,无缓存——client 部署 version.yaml 后立即生效,**不重启后端、不触发 server 流水线**。
- 官网下载页运行时 `fetch('/api/v1/public/release')`:版本徽章、各平台下载链接、**更新日志时间线**全部动态刷新——client 发版**无需重建官网**。接口不可达时回退到构建时静态内容(`web/_data/changelog.js` `CHANGELOG-client.md`
**CHANGELOG 格式**Keep a Changelog):
**CI 脚本**`scripts/ci/`):`lib-forgejo.sh`(公共函数)+ `compile-{client-web,site,backend,macos,android,ios,windows}.sh` + `release-{client,site,server}.sh` + `deploy-{client,site,server}.sh`。`compile-{macos,android,ios,windows}.sh` 去 `client-v` 前缀取版本。
**client 多平台构建矩阵**mac runner(容量 1)串行链 `build-client-web → build-macos → build-android → build-ios`windows runner 并行 `build-windows`Inno Setup);`release-deploy-client` 收齐后发布。Android 需 `ANDROID_*`、iOS 走 TestFlight 需 `IOS_*`/`APPSTORE_*` secrets,未配置时该 job 优雅跳过(exit 0,不阻塞)。
**手动回滚**`manual.yml` 输入带前缀 tag(如 `client-v1.0.54`),按前缀路由到对应 `deploy-<part>.sh`,从 Forgejo Release 下载产物。
**CHANGELOG 格式**Keep a Changelog,三个文件同构):
```markdown
## [1.0.2] - YYYY-MM-DD
@@ -289,7 +303,7 @@ CI/CDForgejo`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译
### 修复
- ...
```
只保留有内容的分类。`/release` 会检查 CHANGELOG 是否已有该版本节,没有则从 git log 自动生成。
只保留有内容的分类。`/release` 会检查对应 CHANGELOG 是否已有该版本节,没有则从该 part 上一个 tag 的 git log 自动生成。
### 异常上报
@@ -310,28 +324,10 @@ CI/CDForgejo`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译
### 项目 TODO 管理
**唯一待办系统**:任务执行过程中如发现需要做的事项,**必须**用 `/todo add <描述>` 记入项目 todo 系统,**禁止**使用 TaskCreate/TodoWrite 等其它 todo 工具,保持单一真相源
**本项目已停用 todo。** 即便全局 `~/.claude/CLAUDE.md` 要求用 `/todo` 记录待办,**本项目一律不记 todo、不调用 `/todo`、不创建 `todo/` 数据**(此项目规则覆盖全局规则)。需要追踪的事项直接在对话里说明即可
- **真相源**`todo/todo.json`(结构化 JSON,含 id/标题/重要度/平台标签/创建时间/完成版本)
- **HTML**`todo/todo.html` 为生成物,每次写操作自动重渲染
- **重要度**`high`(阻断/紧急)/ `mid`(重要)/ `low`(一般/优化)三级
- **改动等级**`tier 1`(接口/schema/跨 2+ 模块)/ `tier 2`5+ 文件)/ `tier 3`(小改/bugfix);**一级改动须先进 plan 模式经用户批准,完成后同步更新设计文档**,详见 `/todo`
- **状态**`open`(待开始)→ `doing`(开发中)→ `done`(待验收)→ `accepted`(已验收)
- **完成即标记**:开发完成后 `/todo status <id> done`,验收通过后用户运行 `/todo done <id>` 记入版本
常用命令:
```
/todo — 查看 summary(按状态分组)
/todo add 修复... — 新增待办(Claude 自动推断级别/标签)
/todo status <id> doing — 标记开发中(Claude 开始处理时调用)
/todo status <id> done — 标记待验收(Claude 完成开发时调用)
/todo done <id> — 用户验收通过,自动记入版本号
/todo reject <id> <原因> — 用户拒绝验收,退回 open 并升为最高优先级
/todo reopen <id> — 重新开启(清空验收/拒绝记录)
/todo rm <id> — 删除
```
底层脚本:`node todo/todo.mjs <子命令> [参数]`
- **改动等级仍把控**:接口/schema/跨 2+ 模块的「大改」先进 plan 模式经用户批准,完成后同步更新设计文档;小 bugfix 直接做。
- **禁止**使用 TaskCreate/TodoWrite 等内置 todo 工具。
---
-71
View File
@@ -1,71 +0,0 @@
package main
import (
"fmt"
"strings"
xls "github.com/extrame/xls"
)
func safeXlsRow(sheet *xls.WorkSheet, r int) (row *xls.Row) {
defer func() { recover() }()
return sheet.Row(r)
}
func main() {
wb, err := xls.Open("/Users/wangjia/.claude/jobs/4f07558d/tmp/test_150rows.xls", "utf-8")
if err != nil {
fmt.Println("open error:", err)
return
}
sheet := wb.GetSheet(0)
if sheet == nil {
fmt.Println("no sheet")
return
}
fmt.Printf("MaxRow: %d\n", sheet.MaxRow)
numCols := 0
headerRow := sheet.Row(0)
fmt.Printf("Header LastCol: %d\n", headerRow.LastCol())
for c := 0; c < headerRow.LastCol(); c++ {
v := strings.TrimSpace(headerRow.Col(c))
if v != "" {
numCols = c + 1
}
}
if numCols == 0 {
numCols = 20
}
fmt.Printf("numCols: %d\n", numCols)
const maxRows = 100000
const maxEmpty = 5
emptyStreak := 0
rowCount := 0
for r := 0; r < maxRows; r++ {
row := safeXlsRow(sheet, r)
cells := make([]string, numCols)
isEmpty := true
if row != nil {
for c := 0; c < numCols; c++ {
cells[c] = strings.TrimSpace(row.Col(c))
if cells[c] != "" {
isEmpty = false
}
}
}
if isEmpty {
emptyStreak++
if emptyStreak >= maxEmpty {
fmt.Printf("Breaking at r=%d after %d empty streak\n", r, emptyStreak)
break
}
rowCount++
continue
}
emptyStreak = 0
rowCount++
}
fmt.Printf("Total rows read: %d\n", rowCount)
}
+7 -7
View File
@@ -3,12 +3,18 @@ module github.com/wangjia/jiu/backend
go 1.26.1
require (
github.com/disintegration/imaging v1.6.2
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/mozillazg/go-pinyin v0.21.0
github.com/shakinm/xlsReader v0.9.12
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/crypto v0.49.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
@@ -21,9 +27,6 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
github.com/extrame/xls v0.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -34,7 +37,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -42,9 +44,9 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/metakeule/fmtdate v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/go-pinyin v0.21.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
@@ -52,7 +54,6 @@ require (
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@@ -71,5 +72,4 @@ require (
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+6 -4
View File
@@ -13,10 +13,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
github.com/extrame/xls v0.0.1 h1:jI7L/o3z73TyyENPopsLS/Jlekm3nF1a/kF5hKBvy/k=
github.com/extrame/xls v0.0.1/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -68,6 +64,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/metakeule/fmtdate v1.1.2 h1:n9M7H9HfAqp+6OA98wXGMdcAr6omshSNVct65Bks1lQ=
github.com/metakeule/fmtdate v1.1.2/go.mod h1:2JyMFlKxeoGy1qS6obQukT0AL0Y4iNANQL8scbSdT4E=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -91,6 +89,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/shakinm/xlsReader v0.9.12 h1:F6GWYtCzfzQqdIuqZJ0MU3YJ7uwH1ofJtmTKyWmANQk=
github.com/shakinm/xlsReader v0.9.12/go.mod h1:ME9pqIGf+547L4aE4YTZzwmhsij+5K9dR+k84OO6WSs=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
@@ -147,8 +147,10 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+148 -81
View File
@@ -2,15 +2,13 @@ package handler
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/extrame/xls"
"github.com/shakinm/xlsReader/xls"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
@@ -496,6 +494,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
total int
imported int
updated int
skipped int
errors []string
}
var res importResult
@@ -549,10 +548,11 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
invByNSS[nssKey] = inv
}
lookupInv := func(productCode, name, series, spec string, whID uint64) *model.Inventory {
// 有商品编号时,仅按「编号|仓库」唯一匹配,绝不回退到名称匹配:
// 同一款酒的不同批次/年份是不同的商品编号,名称相同但属于独立库存记录,
// 回退名称匹配会把它们错误合并成一条(3264 行被压成 1134 行)。
if productCode != "" {
if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok {
return inv
}
return invByCode[fmt.Sprintf("%s|%d", productCode, whID)]
}
return invByNSS[fmt.Sprintf("%s|%s|%s|%d", name, series, spec, whID)]
}
@@ -560,6 +560,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
// Dynamic column detection from header row
colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
colStockInDate := 12
if len(rows) > 0 {
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
for j, h := range rows[0] {
@@ -580,8 +581,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
colPrice = j
case "生产日期", "生产年月":
colProductionDate = j
case "批次", "批次号":
case "批次", "批次号", "批次/编号/物流码":
colBatchNo = j
case "入库日期", "入库时间", "入库日":
colStockInDate = j
case "所在仓库", "仓库", "库位":
colWarehouse = j
case "供应商", "供应商名称":
@@ -620,6 +623,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
warehouseName := cell(row, colWarehouse)
supplierName := cell(row, colSupplier)
remark := cell(row, colRemark)
stockInDateStr := cell(row, colStockInDate)
qty, _ := strconv.ParseFloat(qtyStr, 64)
if qty <= 0 {
@@ -662,6 +666,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
productionDate = &d
}
// 解析入库日期:用 Excel 里的值作为库存记录的入库时间(CreatedAt)
// 解析失败或为空时回退到当前时间。
stockInTime, _ := parseTimeWithFallback(stockInDateStr, time.Now())
whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64
@@ -677,35 +685,93 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
if existing != nil {
updates := map[string]interface{}{
"quantity": qty,
"product_name": prod.Name,
"series": prod.Series,
"spec": prod.Spec,
"unit": prod.Unit,
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
// 去重/增量更新:逐字段对比 Excel 行与库内现有记录,
// 仅把「不一致」的字段放进 updates;全部一致则跳过不写库。
updates := map[string]interface{}{}
if existing.Quantity != qty {
updates["quantity"] = qty
}
if unitPricePtr != nil {
updates["unit_price"] = *unitPricePtr
if existing.ProductName != prod.Name {
updates["product_name"] = prod.Name
}
if productionDate != nil {
updates["production_date"] = productionDate
if existing.Series != prod.Series {
updates["series"] = prod.Series
}
if batchNo != "" {
if existing.Spec != prod.Spec {
updates["spec"] = prod.Spec
}
if existing.Unit != prod.Unit {
updates["unit"] = prod.Unit
}
if existing.WarehouseName != warehouseName {
updates["warehouse_name"] = warehouseName
}
if existing.SupplierName != supplierName {
updates["supplier_name"] = supplierName
}
if existing.Remark != remark {
updates["remark"] = remark
}
if existing.BatchNo != batchNo {
updates["batch_no"] = batchNo
}
// 单价:仅当 Excel 提供了非零单价且与现有不同才更新
if unitPricePtr != nil {
if existing.UnitPrice == nil || *existing.UnitPrice != *unitPricePtr {
updates["unit_price"] = *unitPricePtr
}
}
// 生产日期:仅当 Excel 提供了值且与现有不同才更新
if productionDate != nil {
if existing.ProductionDate == nil ||
!existing.ProductionDate.Time.Equal(productionDate.Time) {
updates["production_date"] = productionDate
}
}
// 入库时间(created_at):与现有不同才更新
if !existing.CreatedAt.Equal(stockInTime) {
updates["created_at"] = stockInTime
}
if len(updates) == 0 {
// 所有字段都一致,跳过(不写库、不写流水)
res.skipped++
continue
}
qtyBefore := existing.Quantity
if err := h.db.Model(existing).Updates(updates).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
continue
}
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty,
RefType: "import", RefID: 0,
})
// 仅在库存数量变化时才记一笔流水(用更新前的数量作为 QtyBefore)
if _, ok := updates["quantity"]; ok {
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: qtyBefore, QtyAfter: qty,
RefType: "import", RefID: 0,
})
}
// 同步缓存,避免同文件后续行重复比对到旧值
existing.Quantity = qty
existing.ProductName = prod.Name
existing.Series = prod.Series
existing.Spec = prod.Spec
existing.Unit = prod.Unit
existing.WarehouseName = warehouseName
existing.SupplierName = supplierName
existing.Remark = remark
existing.BatchNo = batchNo
if _, ok := updates["unit_price"]; ok {
existing.UnitPrice = unitPricePtr
}
if _, ok := updates["production_date"]; ok {
existing.ProductionDate = productionDate
}
if _, ok := updates["created_at"]; ok {
existing.CreatedAt = stockInTime
}
res.updated++
} else {
productIDCopy := prod.ID
@@ -715,7 +781,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
ProductID: &productIDCopy,
StockInItemID: nil,
Quantity: qty,
ProductCode: prod.Code,
ProductCode: productCode, // 行自身的商品编号(每条库存记录唯一),而非商品目录编号
ProductName: prod.Name,
Series: prod.Series,
Spec: prod.Spec,
@@ -726,6 +792,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
BatchNo: batchNo,
SupplierName: supplierName,
Remark: remark,
CreatedAt: stockInTime, // 用 Excel「入库日期」作为入库时间
}
if err := h.db.Create(&inv).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
@@ -756,13 +823,14 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader))
}
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d",
res.total, res.imported, res.updated, len(res.errors))
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d skipped=%d errors=%d",
res.total, res.imported, res.updated, res.skipped, len(res.errors))
c.JSON(http.StatusOK, gin.H{
"total": res.total,
"imported": res.imported,
"updated": res.updated,
"skipped": res.skipped,
"errors": res.errors,
})
}
@@ -789,65 +857,38 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) {
var rows [][]string
if isOLE {
// 老格式 BIFF extrame/xls 需要文件路径,写入临时文件
tmp, tmpErr := os.CreateTemp("", "import_*.xls")
if tmpErr != nil {
return nil, fmt.Errorf("cannot create temp file: %s", tmpErr.Error())
}
defer os.Remove(tmp.Name())
if _, cpErr := io.Copy(tmp, f); cpErr != nil {
tmp.Close()
return nil, fmt.Errorf("cannot write temp file: %s", cpErr.Error())
}
tmp.Close()
wb, xlErr := xls.Open(tmp.Name(), "utf-8")
// 老格式 BIFF (.xls)。注意:extrame/xls 对部分真实导出文件会错读单元格
// (字符串整列丢失、数字被当成日期序列号),导致 3000 行只解析出几百行。
// 改用 shakinm/xlsReader,可正确读取共享字符串表与数值。
wb, xlErr := xls.OpenReader(f)
if xlErr != nil {
return nil, fmt.Errorf("invalid xls file: %s", xlErr.Error())
}
sheet := wb.GetSheet(0)
if sheet == nil {
sheet, shErr := wb.GetSheet(0)
if shErr != nil || sheet == nil {
return nil, fmt.Errorf("no sheet found")
}
// LastCol() returns 0 for many data rows in extrame/xls; derive column
// count from the header row instead.
numCols := 0
headerRow := sheet.Row(0)
for c := 0; c < headerRow.LastCol(); c++ {
if strings.TrimSpace(headerRow.Col(c)) != "" {
numCols = c + 1
}
numRows := sheet.GetNumberRows()
if numRows < 1 {
return nil, fmt.Errorf("empty or invalid sheet")
}
// 列数以表头行为准
header, _ := sheet.GetRow(0)
numCols := len(header.GetCols())
if numCols == 0 {
numCols = 20
}
// sheet.MaxRow 依赖 DIMENSIONS 记录,旧软件导出的 XLS 该值可能偏小。
// 改为读到连续 5 行全空为止,最多 100000 行防止死循环。
const maxRows = 100000
const maxEmpty = 5
emptyStreak := 0
for r := 0; r < maxRows; r++ {
row := safeXlsRow(sheet, r)
for r := 0; r < numRows; r++ {
row, _ := sheet.GetRow(r)
cells := make([]string, numCols)
isEmpty := true
if row != nil {
for c := 0; c < numCols; c++ {
cells[c] = strings.TrimSpace(row.Col(c))
if cells[c] != "" {
isEmpty = false
for col := 0; col < numCols; col++ {
if cd, cErr := row.GetCol(col); cErr == nil && cd != nil {
cells[col] = strings.TrimSpace(cd.GetString())
}
}
}
if isEmpty {
emptyStreak++
if emptyStreak >= maxEmpty {
break
}
// 保留空行,让调用方自行跳过
rows = append(rows, cells)
continue
}
emptyStreak = 0
// 保留空行,让调用方自行跳过
rows = append(rows, cells)
}
} else {
@@ -922,6 +963,38 @@ func parseDate(s string) model.Date {
return model.Date{Time: t}
}
// parseTimeWithFallback 解析「入库日期」等列的时间值。
// 兼容:yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy/MM/dd、Excel 序列号。
// 解析失败或为空时返回 fallback(通常为 time.Now()),ok=false。
func parseTimeWithFallback(s string, fallback time.Time) (t time.Time, ok bool) {
s = strings.TrimSpace(s)
if s == "" {
return fallback, false
}
layouts := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02 15:04",
"2006-01-02",
"2006/01/02 15:04:05",
"2006/01/02",
}
for _, layout := range layouts {
if parsed, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return parsed, true
}
}
// Excel 序列号(自 1899-12-30 起的天数,可带小数表示时间)
if serial, err := strconv.ParseFloat(s, 64); err == nil && serial > 0 && serial < 100000 {
base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.Local)
days := int(serial)
frac := serial - float64(days)
parsed := base.AddDate(0, 0, days).Add(time.Duration(frac * 24 * float64(time.Hour)))
return parsed, true
}
return fallback, false
}
func parsePartnerType(raw string) string {
hasCust := strings.Contains(raw, "客户")
hasSupp := strings.Contains(raw, "供应商")
@@ -941,9 +1014,3 @@ func cell(row []string, idx int) string {
}
return strings.TrimSpace(row[idx])
}
// safeXlsRow 安全读取 XLS 行,捕获 extrame/xls 在超出行数时的 panic。
func safeXlsRow(sheet *xls.WorkSheet, r int) (row *xls.Row) {
defer func() { recover() }() //nolint:errcheck
return sheet.Row(r)
}
+2
View File
@@ -183,6 +183,7 @@ func (h *PublicHandler) GetRelease(c *gin.Context) {
"version": "1.0.0",
"release_notes": "",
"download_urls": gin.H{"web": "https://jiu.51yanmei.com/app"},
"changelog": []changelogEntry{},
})
return
}
@@ -190,6 +191,7 @@ func (h *PublicHandler) GetRelease(c *gin.Context) {
"version": cfg.Version,
"release_notes": cfg.ReleaseNotes,
"download_urls": cfg.DownloadURLs,
"changelog": changelogOrEmpty(cfg.Changelog),
})
}
+24
View File
@@ -17,6 +17,29 @@ type versionConfig struct {
ForceUpdate bool `yaml:"force_update"`
ReleaseNotes string `yaml:"release_notes"`
DownloadURLs map[string]string `yaml:"download_urls"`
// Changelog 由 client 发版脚本从 CHANGELOG-client.md 写入(最近 3 条),
// 供官网下载页运行时拉取渲染更新日志,无需重建官网。
Changelog []changelogEntry `yaml:"changelog"`
}
type changelogEntry struct {
Version string `yaml:"version" json:"version"`
Date string `yaml:"date" json:"date"`
Intro string `yaml:"intro" json:"intro"`
Sections []changelogSection `yaml:"sections" json:"sections"`
}
type changelogSection struct {
Type string `yaml:"type" json:"type"`
Items []string `yaml:"items" json:"items"`
}
// changelogOrEmpty 保证响应里 changelog 始终是数组(而非 null),方便前端遍历。
func changelogOrEmpty(c []changelogEntry) []changelogEntry {
if c == nil {
return []changelogEntry{}
}
return c
}
// GetVersion GET /version
@@ -33,6 +56,7 @@ func GetVersion(c *gin.Context) {
"force_update": cfg.ForceUpdate,
"release_notes": cfg.ReleaseNotes,
"download_urls": cfg.DownloadURLs,
"changelog": changelogOrEmpty(cfg.Changelog),
})
}
@@ -697,6 +697,10 @@
);
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu;
PRODUCT_NAME = "$(TARGET_NAME)";
CODE_SIGN_STYLE = Manual;
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";
+4
View File
@@ -2,12 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>用于拍摄商品照片</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于从相册选择商品照片</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>本应用不主动获取您的位置信息(部分系统组件依赖此声明)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
+16 -6
View File
@@ -5,13 +5,20 @@ import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
import 'retry_interceptor.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
final _publicDio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 15),
));
final _publicDio = _buildPublicDio();
Dio _buildPublicDio() {
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
));
dio.interceptors.add(RetryInterceptor(dio));
return dio;
}
final apiClientProvider = Provider<ApiClient>((ref) {
// 只监听登录/登出,不监听 token 内容变化。
@@ -52,13 +59,16 @@ class ApiClient {
}) {
_dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
));
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
_dio.interceptors.add(RetryInterceptor(_dio));
// 网络错误 + 401 拦截器
_dio.interceptors.add(
InterceptorsWrapper(
@@ -0,0 +1,63 @@
import 'package:dio/dio.dart';
/// 网络层错误自动重试拦截器。
///
/// 仅对「网络层」失败(连接超时 / 连接失败 / 读写超时)重试,**不**对带响应的
/// HTTP 错误(4xx/5xx 业务错误)重试。重试 3 次,间隔递增(1s → 2s → 4s)。
///
/// 安全策略(避免重复入库/出库等副作用):
/// - GET:幂等,所有网络错误都重试。
/// - POST/PUT/PATCH/DELETE:仅在「连接尚未建立」(connectTimeout / connectionError
/// 服务器还没收到请求)时重试;receiveTimeout/sendTimeout 时服务器可能已处理,
/// 不重试,避免重复提交。
class RetryInterceptor extends Interceptor {
final Dio dio;
final List<Duration> delays;
RetryInterceptor(
this.dio, {
this.delays = const [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 4),
],
});
static const _attemptKey = 'retry_attempt';
bool _retriable(DioException e) {
final method = (e.requestOptions.method).toUpperCase();
final isIdempotent = method == 'GET' || method == 'HEAD';
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.connectionError:
// 连接未建立 → 服务器未收到 → 任何方法都可安全重试
return true;
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
// 服务器可能已处理 → 仅幂等方法重试
return isIdempotent;
default:
return false;
}
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final attempt = (err.requestOptions.extra[_attemptKey] as int?) ?? 0;
if (_retriable(err) && attempt < delays.length) {
await Future.delayed(delays[attempt]);
err.requestOptions.extra[_attemptKey] = attempt + 1;
try {
final resp = await dio.fetch(err.requestOptions);
return handler.resolve(resp);
} on DioException catch (e) {
return handler.next(e);
} catch (_) {
return handler.next(err);
}
}
return handler.next(err);
}
}
+8
View File
@@ -126,3 +126,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);
/// 当前登录用户是否为只读角色(role == 'readonly')。
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
final isReadonlyProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'readonly';
});
+63 -10
View File
@@ -7,34 +7,55 @@ import '../core/config/app_config.dart';
/// 数据 provider 通过 watch 此值实现网络恢复后自动刷新。
final networkRecoveryCountProvider = StateProvider<int>((ref) => 0);
/// 是否正在进行一次「带重试的连通性检测」(供 UI 显示「重试中…」转圈)。
final connectivityCheckingProvider = StateProvider<bool>((ref) => false);
final connectivityProvider =
StateNotifierProvider<ConnectivityNotifier, bool>((ref) {
return ConnectivityNotifier(
onRecovered: () {
ref.read(networkRecoveryCountProvider.notifier).update((s) => s + 1);
},
onCheckingChanged: (checking) {
ref.read(connectivityCheckingProvider.notifier).state = checking;
},
);
});
class ConnectivityNotifier extends StateNotifier<bool> {
final void Function()? onRecovered;
final void Function(bool checking)? onCheckingChanged;
ConnectivityNotifier({this.onRecovered, bool skipInit = false}) : super(true) {
ConnectivityNotifier({
this.onRecovered,
this.onCheckingChanged,
bool skipInit = false,
}) : super(true) {
if (!skipInit) {
_check();
// 启动首检走「带重试」:跨境冷启动 DNS+TLS 握手较慢,单次 4s 易误判离线,
// 退避重试 4s→8s→12s 可显著降低首屏假离线。
_check(withRetry: true);
_startOnlineTimer();
}
}
Timer? _timer;
// 独立轻量 Dio短超时,无拦截器
// 独立轻量 Dio:无拦截器。连接预算用 Future.timeout 逐次控制,
// 故 BaseOptions 超时放宽到 15s 作为兜底。
final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 3),
receiveTimeout: const Duration(seconds: 3),
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
/// 在线时:每 30 秒检测一次
/// 每次尝试的超时预算,逐次拉长(越来越长)。
static const _budgets = <Duration>[
Duration(seconds: 4),
Duration(seconds: 8),
Duration(seconds: 12),
];
/// 在线时:每 30 秒轻量检测一次(单次,不重试)
void _startOnlineTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _check());
@@ -46,25 +67,57 @@ class ConnectivityNotifier extends StateNotifier<bool> {
_timer = Timer.periodic(const Duration(minutes: 1), (_) => _check());
}
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)。单次,不重试。
Future<void> forceCheck() => _check();
Future<void> _check() async {
/// 用户手动重试:带退避重试,并对外广播「检测中」状态,返回最终是否在线。
Future<bool> retry() async {
onCheckingChanged?.call(true);
try {
await _dio.get(AppConfig.healthUrl);
return await _check(withRetry: true);
} finally {
onCheckingChanged?.call(false);
}
}
/// 单次 ping /health[budget] 为本次超时预算。
Future<bool> _ping(Duration budget) async {
try {
await _dio.get(AppConfig.healthUrl).timeout(budget);
return true;
} catch (_) {
return false;
}
}
/// 连通性检测。[withRetry]=true 时按 [_budgets] 退避重试,间隔递增。
Future<bool> _check({bool withRetry = false}) async {
final budgets = withRetry ? _budgets : const [Duration(seconds: 4)];
var ok = false;
for (var i = 0; i < budgets.length; i++) {
ok = await _ping(budgets[i]);
if (ok) break;
// 最后一次失败后不再等待;中间失败按递增间隔退避(0.6s, 1.2s…)
if (i < budgets.length - 1) {
await Future.delayed(Duration(milliseconds: 600 * (i + 1)));
}
}
if (ok) {
if (!state) {
// 离线 → 在线:切回高频检测,广播恢复事件
state = true;
onRecovered?.call();
_startOnlineTimer();
}
} catch (_) {
} else {
if (state) {
// 在线 → 离线:切换为低频嗅探
state = false;
_startOfflineTimer();
}
}
return ok;
}
@override
@@ -11,6 +11,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/storage/login_history.dart';
import '../../providers/connectivity_provider.dart';
import '../../repositories/auth_repository.dart';
import '../../widgets/network_retry_button.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@@ -426,6 +427,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
fontSize: 13),
),
),
NetworkRetryButton(
foreground: Color(0xFF5D4037)),
],
),
),
@@ -11,6 +11,7 @@ import '../../widgets/page_scaffold.dart';
import '../../providers/connectivity_provider.dart';
import '../../core/utils/export_util.dart';
import '../../repositories/finance_repository.dart';
import '../../widgets/write_guard.dart';
class FinanceScreen extends ConsumerWidget {
const FinanceScreen({super.key});
@@ -160,7 +161,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)),
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
],
actions: canClose
actions: (canClose && !WriteGuard.isReadonly(ref))
? [
TextButton(
onPressed: () => _closeRecord(r),
@@ -304,7 +305,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
));
case 'actions':
if ((r.type == 'payable' || r.type == 'receivable') && r.status == 'open') {
if ((r.type == 'payable' || r.type == 'receivable') &&
r.status == 'open' &&
!WriteGuard.isReadonly(ref)) {
return DataCell(TextButton(
onPressed: () => _closeRecord(r),
child: const Text('结清',
@@ -405,13 +408,14 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
},
toolbar: Row(
children: [
if (addLabel != null)
if (addLabel != null && !WriteGuard.isReadonly(ref))
ElevatedButton.icon(
onPressed: _showAddDialog,
icon: const Icon(Icons.add, size: 16),
label: Text(addLabel),
),
if (addLabel != null) const SizedBox(width: 8),
if (addLabel != null && !WriteGuard.isReadonly(ref))
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
final tabName = widget.typeFilter.isEmpty
@@ -9,6 +9,7 @@ import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart';
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
@@ -292,7 +293,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
message: item.remark.isEmpty ? '' : item.remark,
waitDuration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => _editRemark(context, item),
onTap: WriteGuard.isReadonly(ref)
? null
: () => _editRemark(context, item),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -358,10 +361,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
MobileCardField('供应商', item.supplierName),
],
actions: [
TextButton(
onPressed: () => _editRemark(context, item),
child: const Text('备注', style: TextStyle(fontSize: 13)),
),
if (!WriteGuard.isReadonly(ref))
TextButton(
onPressed: () => _editRemark(context, item),
child: const Text('备注', style: TextStyle(fontSize: 13)),
),
if (item.productId != null)
TextButton(
onPressed: () => _printLabel(context, item),
@@ -551,12 +555,14 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ref.read(inventoryListProvider.notifier).setPageSize(s),
toolbar: Row(
children: [
OutlinedButton.icon(
onPressed: () => context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('发起盘点'),
),
const SizedBox(width: 8),
if (!WriteGuard.isReadonly(ref)) ...[
OutlinedButton.icon(
onPressed: () => context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('发起盘点'),
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '库存查询',
@@ -11,6 +11,7 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../widgets/write_guard.dart';
class PartnersScreen extends ConsumerStatefulWidget {
const PartnersScreen({super.key});
@@ -166,19 +167,21 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone),
if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address),
],
actions: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
actions: WriteGuard.isReadonly(ref)
? const []
: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
);
}
@@ -191,12 +194,14 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
mobileCards: partners.map(partnerCard).toList(),
toolbar: Row(
children: [
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: Text(isSupplier ? '新建' : '新建'),
),
const SizedBox(width: 8),
if (!WriteGuard.isReadonly(ref)) ...[
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: Text(isSupplier ? '新建' : '新建'),
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: isSupplier ? '供应商列表' : '客户列表',
@@ -280,22 +285,24 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
children: WriteGuard.isReadonly(ref)
? const []
: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
)),
],
))
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_config.dart';
import '../../core/utils/print_util.dart';
import '../../widgets/label_preview_dialog.dart';
import '../../widgets/write_guard.dart';
import '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../models/product_image.dart';
@@ -313,7 +314,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
onDelete: () => _deleteImage(img),
)),
if (p.images.length < 5)
_UploadButton(uploading: _uploading, onTap: _pickAndUpload),
WriteGuard(
child:
_UploadButton(uploading: _uploading, onTap: _pickAndUpload),
),
],
),
),
@@ -470,20 +474,24 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
fontSize: 14, fontWeight: FontWeight.w600)),
const Spacer(),
if (_descChanged)
_savingDesc
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: ElevatedButton(
onPressed: _saveDesc,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap),
child: const Text('保存', style: TextStyle(fontSize: 13)),
),
WriteGuard(
child: _savingDesc
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: ElevatedButton(
onPressed: _saveDesc,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
minimumSize: Size.zero,
tapTargetSize:
MaterialTapTargetSize.shrinkWrap),
child:
const Text('保存', style: TextStyle(fontSize: 13)),
),
),
],
),
const SizedBox(height: 12),
@@ -586,7 +594,8 @@ class _ImageThumbnail extends StatelessWidget {
Positioned(
top: 4,
right: 4,
child: GestureDetector(
child: WriteGuard(
child: GestureDetector(
onTap: onDelete,
child: Container(
width: 20,
@@ -599,6 +608,7 @@ class _ImageThumbnail extends StatelessWidget {
const Icon(Icons.close, size: 14, color: Colors.white),
),
),
),
),
],
),
@@ -10,6 +10,7 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../widgets/write_guard.dart';
class ProductsScreen extends ConsumerStatefulWidget {
const ProductsScreen({super.key});
@@ -356,12 +357,14 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
}) {
return Row(
children: [
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: const Text('新建'),
),
const SizedBox(width: 8),
if (!WriteGuard.isReadonly(ref)) ...[
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: const Text('新建'),
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: onExport,
icon: const Icon(Icons.download, size: 16),
@@ -416,33 +419,37 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
MobileCardField('单品数量', '$quantity'),
if (remark?.isNotEmpty == true) MobileCardField('备注', remark),
],
actions: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
actions: WriteGuard.isReadonly(ref)
? const []
: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
);
}
Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
),
],
return WriteGuard(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
),
],
),
);
}
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart';
import '../../core/config/app_info.dart';
import '../../core/responsive/responsive.dart';
@@ -78,6 +79,28 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
],
),
),
// 退出登录(常驻底部;登出后路由 redirect 自动跳 /login
const Divider(height: 1),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () =>
ref.read(authStateProvider.notifier).logout(),
icon: const Icon(Icons.logout, size: 18),
label: const Text('退出登录'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.danger,
side: const BorderSide(color: AppTheme.danger),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
),
),
],
),
);
@@ -154,6 +177,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
label: '负责人',
value:
shop.managerName.isNotEmpty ? shop.managerName : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '微信号',
value: shop.wechatId.isNotEmpty ? shop.wechatId : ''),
],
),
),
@@ -192,10 +219,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
ElevatedButton.icon(
onPressed: () => _showAddUserDialog(context),
icon: const Icon(Icons.person_add, size: 16),
label: const Text('新增用户'),
WriteGuard(
child: ElevatedButton.icon(
onPressed: () => _showAddUserDialog(context),
icon: const Icon(Icons.person_add, size: 16),
label: const Text('新增用户'),
),
),
],
),
@@ -266,7 +295,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
children: WriteGuard.isReadonly(ref)
? const []
: [
TextButton(
onPressed: () =>
_showEditUserDialog(context, u),
@@ -428,16 +459,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isActivating ? null : _activateLicense,
child: _isActivating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('激活'),
),
if (!WriteGuard.isReadonly(ref)) ...[
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isActivating ? null : _activateLicense,
child: _isActivating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('激活'),
),
],
],
),
],
@@ -588,11 +621,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12))),
DataCell(Text('${r.currentNo}')),
DataCell(TextButton(
onPressed: () =>
_showNumberRuleDialog(context, r),
child: const Text('编辑',
style: TextStyle(fontSize: 12)))),
DataCell(WriteGuard(
child: TextButton(
onPressed: () =>
_showNumberRuleDialog(context, r),
child: const Text('编辑',
style: TextStyle(fontSize: 12))))),
]))
.toList(),
),
@@ -771,6 +805,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
// ── 数据导入 Tab ──────────────────────────────────────────
Widget _buildImportTab() {
if (WriteGuard.isReadonly(ref)) {
return const Center(
child: Text('只读账号无导入权限',
style: TextStyle(color: AppTheme.textSecondary)),
);
}
final currentUser = ref.watch(authStateProvider).user;
final isSuperAdmin = currentUser?.role == 'superadmin';
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
+53 -9
View File
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import 'dart:async';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
@@ -14,6 +15,7 @@ import '../../providers/shop_provider.dart';
import '../../providers/update_provider.dart';
import '../../core/update/app_updater.dart';
import '../../providers/license_provider.dart';
import '../../widgets/network_retry_button.dart';
class AppShell extends ConsumerStatefulWidget {
final Widget child;
@@ -141,6 +143,19 @@ class _AppShellState extends ConsumerState<AppShell> {
}).toList(),
),
),
// 退出登录(抽屉底部常驻,窄屏顶栏菜单不便时也能退出)
const Divider(height: 1, color: Colors.white24),
ListTile(
leading: const Icon(Icons.logout, color: Colors.white70),
title: const Text('退出登录',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.w500)),
onTap: () {
Navigator.pop(context); // 关闭抽屉
ref.read(authStateProvider.notifier).logout();
context.go('/login');
},
),
],
),
),
@@ -154,6 +169,8 @@ class _AppShellState extends ConsumerState<AppShell> {
final isOnline = ref.watch(connectivityProvider);
final location = GoRouterState.of(context).matchedLocation;
final isMobile = context.isMobile;
// iOS/刘海屏状态栏高度:顶栏需下移此距离,避免菜单按钮被状态栏遮挡
final topInset = MediaQuery.of(context).padding.top;
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
final updateNotifier = ref.watch(updateProvider.notifier);
final updateInfo = ref.watch(updateProvider).valueOrNull;
@@ -168,11 +185,11 @@ class _AppShellState extends ConsumerState<AppShell> {
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
body: Column(
children: [
// Top Bar
// Top Bar(高度含状态栏内边距,蓝色铺满到屏幕顶,内容下移避开状态栏)
Container(
height: 56,
height: 56 + topInset,
color: AppTheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: EdgeInsets.only(top: topInset, left: 8, right: 8),
child: Row(
children: [
IconButton(
@@ -194,6 +211,30 @@ class _AppShellState extends ConsumerState<AppShell> {
),
const SizedBox(width: 4),
_ShopButton(user: user, version: appVersion),
if (WriteGuard.isReadonly(ref)) ...[
const SizedBox(width: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(4),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.visibility_outlined,
size: 13, color: Colors.white),
SizedBox(width: 4),
Text('只读',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600)),
],
),
),
],
const Spacer(),
if (user != null) ...[
// 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤
@@ -382,13 +423,16 @@ class _AppShellState extends ConsumerState<AppShell> {
Icon(Icons.wifi_off,
size: 16, color: Colors.white),
SizedBox(width: 8),
Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
Expanded(
child: Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
NetworkRetryButton(),
],
),
),
@@ -22,6 +22,7 @@ import '../../providers/product_provider.dart' show productRepositoryProvider;
import '../../repositories/product_repository.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart';
class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key});
@@ -280,7 +281,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
ref.read(stockInListProvider.notifier).setPageSize(s),
toolbar: Row(
children: [
if (showNewButton)
if (showNewButton && !WriteGuard.isReadonly(ref))
ElevatedButton.icon(
onPressed: () => context.go('/stock-in/new'),
icon: const Icon(Icons.add, size: 16),
@@ -344,6 +345,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
/// 操作按钮列表,表格与移动端卡片共用。
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
final readonly = WriteGuard.isReadonly(ref);
return [
TextButton(
onPressed: () => _showDetail(context, o.id),
@@ -390,13 +392,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
if (o.status == 'approved')
if (!readonly && o.status == 'approved')
TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
),
if (o.status == 'draft') ...[
if (!readonly && o.status == 'draft') ...[
TextButton(
onPressed: () => context.go('/stock-in/edit/${o.id}'),
child: const Text('修改',
@@ -413,7 +415,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
],
if (o.status == 'pending') ...[
if (!readonly && o.status == 'pending') ...[
TextButton(
key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o),
@@ -20,6 +20,7 @@ import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../widgets/write_guard.dart';
class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key});
@@ -286,7 +287,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ref.read(stockOutListProvider.notifier).setPageSize(s),
toolbar: Row(
children: [
if (showNewButton)
if (showNewButton && !WriteGuard.isReadonly(ref))
ElevatedButton.icon(
onPressed: () => context.go('/stock-out/new'),
icon: const Icon(Icons.add, size: 16),
@@ -350,6 +351,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
///
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
final readonly = WriteGuard.isReadonly(ref);
return [
TextButton(
onPressed: () => _showDetail(context, o.id),
@@ -366,13 +368,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
if (o.status == 'approved')
if (!readonly && o.status == 'approved')
TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
),
if (o.status == 'draft') ...[
if (!readonly && o.status == 'draft') ...[
TextButton(
onPressed: () => context.go('/stock-out/edit/${o.id}'),
child: const Text('修改',
@@ -389,7 +391,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
],
if (o.status == 'pending') ...[
if (!readonly && o.status == 'pending') ...[
TextButton(
key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o),
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/theme/app_theme.dart';
import '../providers/connectivity_provider.dart';
/// 线
/// - +
/// - SnackBar /
///
/// [foreground]
class NetworkRetryButton extends ConsumerWidget {
final Color foreground;
const NetworkRetryButton({super.key, this.foreground = Colors.white});
@override
Widget build(BuildContext context, WidgetRef ref) {
final checking = ref.watch(connectivityCheckingProvider);
return TextButton(
onPressed: checking
? null
: () async {
final ok =
await ref.read(connectivityProvider.notifier).retry();
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(ok ? '已重新连接服务器' : '仍无法连接服务器,请稍后重试'),
backgroundColor: ok ? AppTheme.success : AppTheme.danger,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
));
},
style: TextButton.styleFrom(
foregroundColor: foreground,
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: 10),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (checking)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(foreground),
),
)
else
Icon(Icons.refresh, size: 16, color: foreground),
const SizedBox(width: 6),
Text(
checking ? '重试中…' : '重试',
style: TextStyle(
color: foreground, fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth/auth_state.dart';
/// role == 'readonly'
/// //////
///
/// `if (!ref.watch(isReadonlyProvider))`
/// `middleware.ReadOnly()` 403
/// UI +
///
///
/// ```dart
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
/// ```
/// children [hidden] collection-if
/// ```dart
/// if (!WriteGuard.isReadonly(ref)) ...[button, const SizedBox(width: 8)]
/// ```
class WriteGuard extends ConsumerWidget {
final Widget child;
///
final Widget placeholder;
const WriteGuard({
super.key,
required this.child,
this.placeholder = const SizedBox.shrink(),
});
/// collection-if /
static bool isReadonly(WidgetRef ref) => ref.watch(isReadonlyProvider);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(isReadonlyProvider) ? placeholder : child;
}
}
+11 -9
View File
@@ -36,7 +36,7 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机
### Workflow 标签
`.gitea/workflows/deploy.yml``ci.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`
`.gitea/workflows/deploy-{client,site,server}.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`
### 检查 Runner 状态
@@ -89,19 +89,21 @@ kill $(pgrep -f act_runner_start.sh)
仓库根目录 `.gitea/workflows/` 下有三个 workflow 文件:
### `deploy.yml`部署流程
### `deploy-{client,site,server}.yml`三条独立部署流程
**触发条件:** push `v*.*.*` tag
发版已拆成三条互不影响的流水线,按 tag 前缀触发:
**步骤:**
1. `compile.sh` — 编译 Go 后端(linux/amd64+ Flutter Web,打包到 `dist/`
2. `test.sh` `go test` + `flutter analyze`
3. `release.sh` — 解析 CHANGELOG.md,更新 version.yaml,创建 Forgejo Release,上传 3 个 asset
4. `deploy.sh` — SSH 到 EC2 部署,完成后发 Telegram 通知
| workflow | 触发 tag | 范围 |
|----------|---------|------|
| `deploy-client.yml` | `client-v*.*.*` | Flutter 全平台 + version.yaml |
| `deploy-site.yml` | `site-v*.*.*` | Eleventy 营销站 |
| `deploy-server.yml` | `server-v*.*.*` | Go 后端 + nginx |
**步骤(各自):** `compile-<part>*.sh` 编译打包 → `test.sh <part>` 门禁 → `release-<part>.sh` 创建 Forgejo Release → `deploy-<part>.sh` SSH 到 EC2 部署 → Telegram 通知。公共函数在 `scripts/ci/lib-forgejo.sh`
### `manual.yml` — 手动部署 / 回滚
**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入目标 tag(如 `v1.0.0`
**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入带前缀 tag(如 `client-v1.0.55`,按前缀路由到对应 `deploy-<part>.sh`
**步骤:** 只运行 `deploy.sh`,从 Forgejo Release 下载已有产物直接部署,跳过编译和测试。
+1 -1
View File
@@ -69,5 +69,5 @@ download_urls:
## 工作原理
- `scripts/ci/compile-ios.sh`:建临时 keychain 导入证书、安装 profile、生成 ExportOptionsmanual / app-store)、`flutter build ipa``xcrun altool --upload-app` 上传;CFBundleVersion 由版本号推导单调递增;缺 secrets 时跳过。
- `.gitea/workflows/deploy.yml``build-ios` jobmac runner,串行于 build-android。
- `.gitea/workflows/deploy-client.yml``build-ios` jobmac runner,串行于 build-android。
- 工程:`client/ios`bundle id `com.yanmei.jiu`,应用名「岩美酒库」)。
+2 -2
View File
@@ -6,7 +6,7 @@ set -euo pipefail
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
VER="${TAG#client-v}"
# versionCode 必须单调递增,否则 Android 无法覆盖升级安装。
# 由版本号推导:major*10000 + minor*100 + patch(如 1.0.17 -> 10017)。
@@ -43,7 +43,7 @@ cd client
flutter build apk --release \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
"--dart-define=APP_VERSION=v${VER}"
cd ..
# Locate the built APK (path differs across Flutter versions) and copy to dist/
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# compile-backend.sh <tag> — build the Linux backend binary + shared-infra
# configs.tar.gz into dist/. Server pipeline (server-v*). Does NOT touch
# version.yaml (that is client-owned) nor any Flutter/web artifact.
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
echo "==> compile-backend: tag=${TAG}"
# Build Go backend (linux/amd64 for EC2)
cd backend
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
cd ..
mkdir -p dist
mv backend/jiu-server dist/jiu-server
# Shared infrastructure (nginx/systemd/env/compose). version.yaml is NOT here —
# it belongs to the client pipeline.
tar -czf dist/configs.tar.gz \
deploy/nginx-jiu.conf \
deploy/jiu.service \
deploy/production.env.template \
deploy/setup-ec2.sh \
deploy/docker-compose.yml \
deploy/docker-compose.jiu.yml
echo "==> compile-backend: done — dist/ contents:"
ls -lh dist/
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# compile-client-web.sh <tag> — build the Flutter Web app into dist/web.tar.gz.
# Client pipeline (client-v*). Served at /app by nginx.
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#client-v}"
echo "==> compile-client-web: version=${VER}"
# Sync Flutter pubspec version (BSD sed on macOS)
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
cd client
flutter build web --release \
--base-href=/app/ \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=v${VER}"
cd ..
mkdir -p dist
tar -czf dist/web.tar.gz -C client/build web
echo "==> compile-client-web: done — dist/ contents:"
ls -lh dist/
+2 -2
View File
@@ -17,7 +17,7 @@ set -euo pipefail
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
VER="${TAG#client-v}"
# CFBundleVersionbuild 号)必须单调递增,否则 TestFlight 拒绝重复上传。
MAJOR="$(echo "$VER" | cut -d. -f1)"
@@ -105,7 +105,7 @@ flutter build ipa --release \
--export-options-plist="$WORK/ExportOptions.plist" \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
"--dart-define=APP_VERSION=v${VER}"
cd ..
IPA="$(ls client/build/ios/ipa/*.ipa | head -1)"
+2 -2
View File
@@ -6,7 +6,7 @@ set -euo pipefail
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
VER="${TAG#client-v}"
echo "==> compile-macos: version=${VER}"
@@ -18,7 +18,7 @@ cd client
flutter build macos --release \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
"--dart-define=APP_VERSION=v${VER}"
cd ..
# Package .app bundle into zip
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# compile-site.sh <tag> — build the Eleventy marketing site into
# dist/marketing.tar.gz. Site pipeline (site-v*). Marketing pages only —
# the web version of the app is built by compile-client-web.sh.
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
echo "==> compile-site: tag=${TAG}"
cd web
npm ci --prefer-offline
npm run build
cd ..
mkdir -p dist
tar -czf dist/marketing.tar.gz -C web/dist .
echo "==> compile-site: done — dist/ contents:"
ls -lh dist/
+2 -2
View File
@@ -6,7 +6,7 @@ set -euo pipefail
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
VER="${TAG#client-v}"
echo "==> compile-windows: version=${VER}"
@@ -41,7 +41,7 @@ flutter create --platforms=windows . --project-name jiu_client
flutter build windows --release \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
"--dart-define=APP_VERSION=v${VER}"
popd > /dev/null
# Package the Release folder into a Windows installer with Inno Setup (ISCC).
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env bash
# compile.sh <tag> — build backend + Flutter web, package into dist/
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
echo "==> compile: version=$VER"
# Sync Flutter pubspec version
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
# Build Go backend (linux/amd64 for EC2)
cd backend
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
cd ..
# Build Flutter Web
cd client
flutter build web --release \
--base-href=/app/ \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
cd ..
# Build marketing site
cd web
npm ci --prefer-offline
npm run build
cd ..
# Package artifacts
mkdir -p dist
mv backend/jiu-server dist/jiu-server
tar -czf dist/web.tar.gz -C client/build web
tar -czf dist/marketing.tar.gz -C web/dist .
echo "==> compile: done — dist/ contents:"
ls -lh dist/
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# deploy-client.sh <tag> — deploy the Flutter web app, desktop/mobile installers,
# and version.yaml to EC2. Does NOT restart the backend (version.yaml is read
# per-request) nor touch nginx / the marketing site.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> deploy-client: tag=${TAG}"
if [ ! -f dist/web.tar.gz ] || [ ! -f dist/version.yaml ]; then
download_release_assets "$TAG"
fi
echo "==> deploy-client: dist/ contents:"
ls -lh dist/
# Prefer the version.yaml released for this tag (carries bumped build_number +
# changelog); fall back to the checkout copy only if the asset is absent.
VERSION_YAML="dist/version.yaml"
[ -f "$VERSION_YAML" ] || VERSION_YAML="backend/config/version.yaml"
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 EC2"
${SCP} "$VERSION_YAML" "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml"
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/"
# 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 "${EC2_USER}@${EC2_HOST}:/tmp/jiu-windows-x64-setup.exe" || true
[ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${EC2_USER}@${EC2_HOST}:/tmp/jiu-macos-x64.zip" || true
[ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${EC2_USER}@${EC2_HOST}:/tmp/jiu-android.apk" || true
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
# Update version config (WorkingDirectory=/opt/jiu reads config/version.yaml).
# Backend re-reads it per request — no restart needed.
mkdir -p /opt/jiu/config
cp /tmp/version.yaml /opt/jiu/config/version.yaml
# Swap Flutter web app (atomic)
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
# Publish installers to the nginx-served downloads dir; keep only the latest.
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-windows-x64-setup.exe ] && mv -f /tmp/jiu-windows-x64-setup.exe /opt/jiu/downloads/ || 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
echo "Client deploy complete!"
ENDSSH
teardown_ssh
rm -rf /tmp/jiu-web-new
echo "==> deploy-client: done"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# deploy-server.sh <tag> — deploy the backend binary + nginx config to EC2.
# Two modes: uses dist/ built in the same job, or downloads the release assets
# by tag (manual rollback). Does NOT touch the web app, marketing site, or
# version.yaml.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> deploy-server: tag=${TAG}"
if [ ! -f dist/jiu-server ] || [ ! -f dist/configs.tar.gz ]; then
download_release_assets "$TAG"
fi
echo "==> deploy-server: dist/ contents:"
ls -lh dist/
rm -rf /tmp/jiu-configs
mkdir -p /tmp/jiu-configs
tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs
setup_ssh
echo "==> deploy-server: uploading files to EC2"
${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server"
${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
# Replace backend binary
sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
# Start and health check
sudo systemctl start jiu
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -f http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
# Update jiu nginx config in the pangolin-edge reverse proxy (host-bind-mounted
# conf.d; reload nginx inside the container).
cp /tmp/nginx-jiu.conf /home/ec2-user/pangolin/edge/conf.d/jiu.conf
docker exec pangolin-edge nginx -t && docker exec pangolin-edge nginx -s reload
echo "Server deploy complete!"
ENDSSH
teardown_ssh
rm -rf /tmp/jiu-configs
echo "==> deploy-server: done"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# deploy-site.sh <tag> — deploy the Eleventy marketing site to EC2
# (/opt/jiu/marketing). Does NOT restart the backend or touch nginx.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> deploy-site: tag=${TAG}"
if [ ! -f dist/marketing.tar.gz ]; then
download_release_assets "$TAG"
fi
echo "==> deploy-site: dist/ contents:"
ls -lh dist/
rm -rf /tmp/jiu-marketing-new
mkdir -p /tmp/jiu-marketing-new
tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new
setup_ssh
echo "==> deploy-site: uploading marketing site to EC2"
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
echo "Site deploy complete!"
ENDSSH
teardown_ssh
rm -rf /tmp/jiu-marketing-new
echo "==> deploy-site: done"
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env bash
# deploy.sh <tag> — deploy release to EC2
# Works in two modes:
# 1. Automated (after compile.sh): uses dist/ built in the same job
# 2. Manual/rollback: downloads assets from Forgejo Release by tag
set -euo pipefail
TAG="$1"
echo "==> deploy: tag=${TAG}"
# Download from Forgejo if dist/ was not built in this job
if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ] || [ ! -f dist/marketing.tar.gz ]; then
echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo"
mkdir -p dist
curl -kf \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${TAG}" \
-o /tmp/release-info.json
python3 - <<'PYEOF'
import json, urllib.request, ssl, os, sys
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with open('/tmp/release-info.json') as f:
release = json.load(f)
token = os.environ['FORGEJO_TOKEN']
forgejo_url = os.environ['FORGEJO_URL']
repo = os.environ['GITEA_REPOSITORY']
release_id = release['id']
for asset in release.get('assets', []):
url = f"{forgejo_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset['id']}"
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
with urllib.request.urlopen(req, context=ctx) as resp:
with open(f"dist/{asset['name']}", 'wb') as out:
out.write(resp.read())
print(f"Downloaded: {asset['name']}")
PYEOF
fi
echo "==> deploy: dist/ contents:"
ls -lh dist/
# Extract configs (nginx, version.yaml, etc.)
rm -rf /tmp/jiu-configs
mkdir -p /tmp/jiu-configs
tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs
# Extract Flutter web build
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
# Extract marketing site
rm -rf /tmp/jiu-marketing-new
mkdir -p /tmp/jiu-marketing-new
tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new
# Setup SSH
mkdir -p ~/.ssh
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
echo "==> deploy: uploading files to EC2"
# Upload backend binary
${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server"
# Upload version.yaml
${SCP} /tmp/jiu-configs/backend/config/version.yaml "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml"
# Upload nginx config
${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf"
# Upload Flutter web
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/"
# Upload marketing site (built by compile.sh)
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
# Upload desktop client 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 "${EC2_USER}@${EC2_HOST}:/tmp/jiu-windows-x64-setup.exe" || true
[ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${EC2_USER}@${EC2_HOST}:/tmp/jiu-macos-x64.zip" || true
[ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${EC2_USER}@${EC2_HOST}:/tmp/jiu-android.apk" || true
echo "==> deploy: running remote deploy"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
# Replace backend binary
sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
# Update version configWorkingDirectory=/opt/jiu,读 config/version.yaml
mkdir -p /opt/jiu/config
cp /tmp/version.yaml /opt/jiu/config/version.yaml
# Start and health check
sudo systemctl start jiu
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -f http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
# Swap Flutter web app (atomic)
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
# Update marketing site
mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
# Publish desktop client installers to the nginx-served downloads dir.
# Keep only the latest version: clear old installers, then drop in the new ones.
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-windows-x64-setup.exe ] && mv -f /tmp/jiu-windows-x64-setup.exe /opt/jiu/downloads/ || 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
# Update jiu nginx config in the pangolin-edge reverse proxy.
# nginx now runs inside the `pangolin-edge` container (host networking), not on
# the host. Its /etc/nginx/conf.d is bind-mounted from the host dir below, so we
# write the config there and reload nginx *inside the container*.
cp /tmp/nginx-jiu.conf /home/ec2-user/pangolin/edge/conf.d/jiu.conf
docker exec pangolin-edge nginx -t && docker exec pangolin-edge nginx -s reload
echo "Deploy complete!"
ENDSSH
# Cleanup SSH key
rm -f ~/.ssh/ec2_deploy.pem
rm -rf /tmp/jiu-configs /tmp/jiu-web-new
echo "==> deploy: done"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# lib-forgejo.sh — shared CI helpers for the three release pipelines
# (client / site / server). `source` this from release-*.sh and deploy-*.sh.
#
# Provides:
# ver_from_tag <tag> -> strips client-v|site-v|server-v|v prefix
# extract_release_notes <file> -> prints the latest "## [x]" section body
# create_release <tag> <body> -> POSTs a Forgejo Release, sets RELEASE_ID
# upload_asset <file> -> uploads one asset (needs RELEASE_ID)
# download_release_assets <tag> -> downloads all assets of a release into dist/
# setup_ssh / teardown_ssh -> prepare/clean the EC2 deploy key (sets SSH/SCP)
#
# Requires env (where relevant): FORGEJO_URL, FORGEJO_TOKEN, GITEA_REPOSITORY,
# EC2_SSH_KEY, EC2_HOST, EC2_USER.
# Strip the part-specific tag prefix, leaving a bare semver (1.2.3).
ver_from_tag() {
local tag="$1"
tag="${tag#client-v}"
tag="${tag#site-v}"
tag="${tag#server-v}"
tag="${tag#v}"
printf '%s' "$tag"
}
# Print the body (lines) of the most recent "## [ver] - date" section.
extract_release_notes() {
python3 - "$1" <<'PYEOF'
import re, sys
fn = sys.argv[1]
try:
with open(fn, encoding='utf-8') as f:
content = f.read()
parts = re.split(r'\n## ', '\n' + content)
if len(parts) > 1:
lines = parts[1].strip().split('\n')
notes = []
for line in lines[1:]:
s = line.strip()
if s.startswith('## '):
break
if s:
notes.append(s)
print('\n'.join(notes) if notes else lines[0])
else:
print('')
except Exception:
print('')
PYEOF
}
# create_release <tag> <body_json_string> — sets global RELEASE_ID on success.
create_release() {
local tag="$1" body="$2" http_code resp
echo "==> release: creating Forgejo Release ${tag}"
http_code=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${tag}\",\"name\":\"Release ${tag}\",\"body\":${body},\"draft\":false,\"prerelease\":false}")
resp=$(cat /tmp/release_resp.json)
echo "==> release: API response (HTTP ${http_code}): ${resp}"
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "==> release: FAILED — HTTP ${http_code}" >&2
return 1
fi
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${resp}")
echo "==> release: release_id=${RELEASE_ID}"
}
# upload_asset <file> — requires RELEASE_ID.
upload_asset() {
local file="$1" code
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${file}")
echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then return 1; fi
}
# download_release_assets <tag> — pull every asset of the release into dist/.
download_release_assets() {
local tag="$1"
echo "==> deploy: downloading assets for ${tag} from Forgejo"
mkdir -p dist
curl -kf -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${tag}" \
-o /tmp/release-info.json
python3 - <<'PYEOF'
import json, urllib.request, ssl, os
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
rel = json.load(open('/tmp/release-info.json'))
token = os.environ['FORGEJO_TOKEN']
url = os.environ['FORGEJO_URL']
repo = os.environ['GITEA_REPOSITORY']
rid = rel['id']
for a in rel.get('assets', []):
u = f"{url}/api/v1/repos/{repo}/releases/{rid}/assets/{a['id']}"
req = urllib.request.Request(u, headers={'Authorization': f'token {token}'})
with urllib.request.urlopen(req, context=ctx) as r, open(f"dist/{a['name']}", 'wb') as o:
o.write(r.read())
print('Downloaded:', a['name'])
PYEOF
}
# setup_ssh — write the EC2 deploy key and export SSH / SCP commands.
setup_ssh() {
mkdir -p ~/.ssh
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
}
teardown_ssh() {
rm -f ~/.ssh/ec2_deploy.pem
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# release-client.sh <tag> — client pipeline release.
# 1. Update backend/config/version.yaml: version / build_number / release_notes
# / macos+windows+android download URLs / changelog[] (latest 3 from
# CHANGELOG-client.md).
# 2. Create the Forgejo Release client-vX and upload the app artifacts +
# version.yaml (so manual rollback can re-fetch the exact manifest).
#
# version.yaml is client-owned: the backend reads it per-request, so deploying
# it does NOT require a backend restart or a server-pipeline run.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
VER="$(ver_from_tag "$TAG")"
echo "==> release-client: tag=${TAG} version=${VER}"
NOTES=$(extract_release_notes CHANGELOG-client.md)
echo "==> release-client: release_notes=${NOTES}"
# --- Update version.yaml (scalars + download URLs) and append changelog[] ---
python3 - "$VER" "$NOTES" "CHANGELOG-client.md" <<'PYEOF'
import sys, re, json
ver = sys.argv[1]
notes = sys.argv[2]
changelog_file = sys.argv[3]
base = "https://jiu.51yanmei.com/downloads"
mac_url = f"{base}/jiu-macos-x64.zip"
win_url = f"{base}/jiu-windows-x64-setup.exe"
android_url = f"{base}/jiu-android.apk"
# Read current version.yaml, dropping any existing trailing `changelog:` block
# (changelog is always emitted last, so everything from it to EOF is regenerated).
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:'):
try:
b = int(line.split(':', 1)[1].strip()) + 1
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)
# Parse CHANGELOG-client.md -> latest 3 entries (same shape as web/_data/changelog.js)
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)
# Append changelog as a JSON array (valid YAML flow style — no pyyaml needed).
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
# Stage version.yaml as a release asset so manual rollback can re-fetch it.
mkdir -p dist
cp backend/config/version.yaml dist/version.yaml
BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF'
import sys, json
print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2]))
PYEOF
)
create_release "$TAG" "$BODY"
upload_asset dist/web.tar.gz
upload_asset dist/version.yaml
[ -f dist/jiu-macos-x64.zip ] && upload_asset dist/jiu-macos-x64.zip || true
[ -f dist/jiu-windows-x64-setup.exe ] && upload_asset dist/jiu-windows-x64-setup.exe || true
[ -f dist/jiu-android.apk ] && upload_asset dist/jiu-android.apk || true
echo "==> release-client: done — Release ${TAG} created"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# release-server.sh <tag> — create the Forgejo Release for the backend pipeline
# and upload jiu-server + configs.tar.gz.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> release-server: tag=${TAG}"
NOTES=$(extract_release_notes CHANGELOG-server.md)
echo "==> release-server: release_notes=${NOTES}"
BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF'
import sys, json
print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2]))
PYEOF
)
create_release "$TAG" "$BODY"
upload_asset dist/jiu-server
upload_asset dist/configs.tar.gz
echo "==> release-server: done — Release ${TAG} created"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# release-site.sh <tag> — create the Forgejo Release for the marketing-site
# pipeline and upload marketing.tar.gz.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> release-site: tag=${TAG}"
NOTES=$(extract_release_notes CHANGELOG-site.md)
echo "==> release-site: release_notes=${NOTES}"
BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF'
import sys, json
print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2]))
PYEOF
)
create_release "$TAG" "$BODY"
upload_asset dist/marketing.tar.gz
echo "==> release-site: done — Release ${TAG} created"
-141
View File
@@ -1,141 +0,0 @@
#!/usr/bin/env bash
# release.sh <tag> — update version.yaml, package configs, create Forgejo Release
set -euo pipefail
TAG="$1"
VER="${TAG#v}"
echo "==> release: tag=${TAG} version=${VER}"
# Extract release notes from CHANGELOG.md (first summary line of the latest section)
RELEASE_NOTES=$(python3 - <<'PYEOF'
import re, sys
try:
with open('CHANGELOG.md') as f:
content = f.read()
parts = re.split(r'\n## ', '\n' + content)
if len(parts) > 1:
section = parts[1]
lines = section.strip().split('\n')
notes = []
for line in lines[1:]:
stripped = line.strip()
# 只在遇到同级 ## 时停止,### 子标题保留
if stripped.startswith('## '):
break
if stripped:
notes.append(stripped)
print('\n'.join(notes) if notes else lines[0])
else:
print('')
except Exception as e:
print('', file=sys.stderr)
print('')
PYEOF
)
echo "==> release: release_notes=${RELEASE_NOTES}"
# Update backend/config/version.yaml (version, build_number, release_notes, macos/windows URL)
python3 - "${VER}" "${RELEASE_NOTES}" "${TAG}" "${FORGEJO_URL}" "${GITEA_REPOSITORY}" <<'PYEOF'
import sys
ver = sys.argv[1]
notes = sys.argv[2]
tag = sys.argv[3]
furl = sys.argv[4].rstrip('/')
repo = sys.argv[5]
# Public download URLs are served by jiu.51yanmei.com (same-origin HTTPS via the
# pangolin nginx /downloads/ location), NOT the LAN-only HTTP Forgejo instance —
# otherwise the HTTPS download page hands the browser an http://192.168.x URL and
# Chrome blocks it as insecure / it's unreachable from the public internet.
base = "https://jiu.51yanmei.com/downloads"
mac_url = f"{base}/jiu-macos-x64.zip"
win_url = f"{base}/jiu-windows-x64-setup.exe"
android_url = f"{base}/jiu-android.apk"
lines = []
with open('backend/config/version.yaml') as f:
for line in f:
if line.startswith('version:'):
lines.append('version: "' + ver + '"\n')
elif line.startswith('build_number:'):
try:
build = int(line.split(':', 1)[1].strip()) + 1
except Exception:
build = 1
lines.append('build_number: ' + str(build) + '\n')
elif line.startswith('release_notes:'):
lines.append('release_notes: "' + notes.replace('\\', '\\\\').replace('"', '\\"') + '"\n')
elif line.startswith(' macos:'):
lines.append(' macos: "' + mac_url + '"\n')
elif line.startswith(' windows:'):
lines.append(' windows: "' + win_url + '"\n')
elif line.startswith(' android:'):
lines.append(' android: "' + android_url + '"\n')
else:
lines.append(line)
with open('backend/config/version.yaml', 'w') as f:
f.writelines(lines)
print('version.yaml updated to', ver, '| macos:', mac_url, '| windows:', win_url, '| android:', android_url)
PYEOF
# Package configs.tar.gz (includes updated version.yaml)
tar -czf dist/configs.tar.gz \
deploy/nginx-jiu.conf \
deploy/jiu.service \
deploy/production.env.template \
deploy/setup-ec2.sh \
deploy/docker-compose.yml \
deploy/docker-compose.jiu.yml \
backend/config/version.yaml
echo "==> release: creating Forgejo Release ${TAG}"
# Build release body from CHANGELOG excerpt
BODY=$(python3 - "${TAG}" "${RELEASE_NOTES}" <<'PYEOF'
import sys, json
tag = sys.argv[1]
notes = sys.argv[2]
body = '## ' + tag + '\n\n' + notes
print(json.dumps(body))
PYEOF
)
HTTP_CODE=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Release ${TAG}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")
RESP=$(cat /tmp/release_resp.json)
echo "==> release: API response (HTTP ${HTTP_CODE}): ${RESP}"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "==> release: FAILED — HTTP ${HTTP_CODE}"
exit 1
fi
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}")
echo "==> release: release_id=${RELEASE_ID}"
# Upload assets
upload_asset() {
local file="$1"
local code
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${file}")
echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then exit 1; fi
}
upload_asset dist/jiu-server
upload_asset dist/web.tar.gz
upload_asset dist/marketing.tar.gz
upload_asset dist/configs.tar.gz
upload_asset dist/jiu-macos-x64.zip
upload_asset dist/jiu-windows-x64-setup.exe
upload_asset dist/jiu-android.apk
echo "==> release: done — Release ${TAG} created"
+26 -9
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env bash
# test.sh — run backend and frontend checks
# test.sh [part] — run gate checks for a pipeline part.
# server -> go test
# client -> flutter analyze
# (none) -> both (backwards compatible)
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
@@ -7,14 +10,28 @@ export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}"
export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}"
echo "==> test: go test"
cd backend
go test ./...
cd ..
PART="${1:-all}"
echo "==> test: flutter analyze"
cd client
flutter analyze --no-fatal-infos --no-fatal-warnings
cd ..
run_server() {
echo "==> test: go test"
cd backend
go test ./...
cd ..
}
run_client() {
echo "==> test: flutter analyze"
cd client
flutter analyze --no-fatal-infos --no-fatal-warnings
cd ..
}
case "$PART" in
server) run_server ;;
client) run_client ;;
site) echo "==> test: site has no test gate (build is the gate)" ;;
all) run_server; run_client ;;
*) echo "unknown part: $PART" >&2; exit 1 ;;
esac
echo "==> test: done"
-1973
View File
File diff suppressed because it is too large Load Diff
-821
View File
@@ -1,821 +0,0 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-14T00:13:09.458Z"
},
"seq": 51,
"items": [
{
"id": 1,
"title": "两套 CSS / 设计令牌并存",
"desc": "主站 njk 用 color.css+style.css,功能页用 colors_and_type.css,导航/页脚各写一份,维护分裂。建议合并到一套,功能页走 base.njk 模板。",
"level": "mid",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.138Z",
"done": true,
"completed_at": "2026-06-07T17:48:38.554Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 2,
"title": "备案号为占位假值",
"desc": "现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。",
"level": "low",
"tags": [
"Web"
],
"created_at": "2026-06-07T17:30:32.188Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 3,
"title": "注册接口同源依赖问题",
"desc": "用 window.location.origin+'/api/v1/public/register',要求同源部署;分离部署时指错。需确认部署形态。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.237Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.523Z",
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 4,
"title": "scan.html / features 走 passthrough 不享模板",
"desc": "features/scan.html 直接复制,不经 njk 模板,页脚/备案/CSS 都得手动同步。建议长期改造为模板化。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.287Z",
"done": true,
"completed_at": "2026-06-07T18:07:13.211Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 5,
"title": "页脚双份维护",
"desc": "njk 站点用 footer.njk 读变量,功能页把页脚硬编码各写一份,改一处不同步。根因同「两套模板」。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.334Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.584Z",
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 6,
"title": "注册成功提示路径需核对",
"desc": "提示「忘记门店编码可在「系统设置→关于」查看」,需与实际客户端菜单路径一致。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.381Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.648Z",
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 8,
"title": "补全「查看本店其他商品」功能",
"desc": "PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:08:24.432Z",
"done": true,
"completed_at": "2026-06-07T23:18:23.239Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 9,
"title": "设计并实现添加门店微信功能",
"desc": "PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:08:32.748Z",
"done": true,
"completed_at": "2026-06-07T23:28:52.932Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 10,
"title": "公开商品 API 补充 sale_price 并在页面展示价格",
"desc": "product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go",
"level": "mid",
"tags": [
"前端",
"后端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:21.217Z",
"done": true,
"completed_at": "2026-06-07T23:28:53.001Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 13,
"title": "公开商品 API 补充 batch.quantity 和 product code/barcode",
"desc": "Flutter _AuthenticityCard 读取 batch['quantity'] 但后端 public.go batchData 未返回 quantity。另 product code/barcode 未暴露,参数卡无法显示商品编码。handler/public.go GetProduct",
"level": "low",
"tags": [
"后端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:43.136Z",
"done": true,
"completed_at": "2026-06-08T14:40:04.464Z",
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 14,
"title": "Footer「商品报错」「意见反馈」补充实际功能",
"desc": "public_product_screen.dart _Footer 底部两个链接无 url/action,点击静默。需设计方案:跳转反馈表单、弹出联系方式、或发邮件。line:1353-1355",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:50.299Z",
"done": true,
"completed_at": "2026-06-08T00:06:15.299Z",
"version": "v1.0.24",
"status": "accepted"
},
{
"id": 15,
"title": "4 张商品属性字典表 + 后端 CRUD",
"desc": "新增 product_origin_options/product_shelf_life_options/product_storage_options/product_description_docs 4 张字典表(TenantBase+shop_id 隔离);Product 增加 4 个可空外键;AutoMigrate 注册;handler/product_attr.go CRUD16 个方法);router.go 追加 4 组路由在 /product-options/ 下",
"level": "mid",
"tags": [
"后端",
"数据库"
],
"created_at": "2026-06-07T22:51:51.881Z",
"done": true,
"completed_at": "2026-06-07T22:56:24.807Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 16,
"title": "public.go 返回产地/保质期/储存/介绍,公开页去硬编码",
"desc": "public.go GetProduct Preload 4 张新表 + 三级回退(描述文档→旧Description→通用中性兜底)+ 默认值常量(保质期/储存方式);Flutter public_product_screen.dart _ParamsCard/_DescriptionSection 改读真实数据,移除茅台 mock",
"level": "mid",
"tags": [
"后端",
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:51:59.988Z",
"done": true,
"completed_at": "2026-06-07T22:58:32.882Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 17,
"title": "入库表单关联 4 个商品属性字典",
"desc": "stock_in_form_screen.dart 生产日期字段附近增加产地/保质期/储存方式/描述文档 4 个下拉选择(数据来自字典 List 接口);FindOrCreate/Update 接受并写入 4 个 id;前端新增对应 API client 方法",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:52:07.331Z",
"done": true,
"completed_at": "2026-06-07T23:03:13.631Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 18,
"title": "基础数据管理 UI 增加 4 个字典维护 Tab",
"desc": "products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:52:13.283Z",
"done": true,
"completed_at": "2026-06-08T00:06:15.371Z",
"version": "v1.0.24",
"status": "accepted"
},
{
"id": 19,
"title": "入库单新建表单增加产地/保质期/储存方式,选填项默认折叠",
"desc": "新建入库单的商品行增加产地、保质期、储存方式三个字典下拉选项(已有字典数据)。必填项(商品名、数量等)正常显示;选填项默认隐藏,点击展开按钮后在下一行展示。",
"level": "high",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T14:56:32.850Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done",
"reject_reason": null,
"rejected_at": null
},
{
"id": 20,
"title": "商品详情页重设计:展示所有字段,内容为公开页超集",
"desc": "当前 product_detail_screen.dart 显示内容过于简单。需先做页面设计(UI 方案),内容要求是公开扫码页的超集:包含商品基本信息、参数(产地/保质期/储存方式)、价格、描述文档、图片、库存批次、入库记录等管理侧专属字段。另:商品公开链接展示为可点击超链接(点击可跳转公开扫码页)。",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T14:59:08.342Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 21,
"title": "授权信息功能方案设计",
"desc": "完整方案设计,涵盖:界面设计与交互、前端实现方案、后端接口与数据模型、安全策略(防盗用、防滥用、授权校验)等。需产出设计文档后再进入开发阶段。",
"level": "low",
"tags": [
"前端",
"后端",
"安全",
"文档"
],
"created_at": "2026-06-08T15:02:58.896Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done",
"subtasks": [
{
"sid": "21A",
"title": "后端:Ed25519 签发验签工具 + GenerateKey 修复",
"tier": 2,
"deps": [],
"status": "done",
"created_at": "2026-06-09T16:14:58.628Z"
},
{
"sid": "21B",
"title": "数据库:license_devices 表 + licenses 表改造",
"tier": 2,
"deps": [
"21A"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.490Z"
},
{
"sid": "21C",
"title": "后端:注册自动签发 30 天 trial license",
"tier": 2,
"deps": [
"21A"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.541Z"
},
{
"sid": "21D",
"title": "后端:LicenseGuard 中间件 + JWT claim 扩展",
"tier": 2,
"deps": [
"21B",
"21C"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.590Z"
},
{
"sid": "21E",
"title": "前端:license model/repository/device_id 采集",
"tier": 2,
"deps": [
"21A"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.639Z"
},
{
"sid": "21F",
"title": "前端:激活与设备管理 UI(授权 Tab 重构)",
"tier": 2,
"deps": [
"21D",
"21E"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.689Z"
},
{
"sid": "21G",
"title": "前端:gating 横幅 + 到期弹窗(app_shell 注入)",
"tier": 2,
"deps": [
"21D",
"21E"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.739Z"
},
{
"sid": "21H",
"title": "端到端验证:phase 切换 + 签名拒绝 + 多设备上限",
"tier": 2,
"deps": [
"21D",
"21F",
"21G"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.789Z"
}
]
},
{
"id": 22,
"title": "关于我们页文档/更新日志链接更新,Web 新增版本历史页",
"desc": "1. 客户端「关于我们」页面的「打开文档」「更新日志」链接指向需更新为正确地址。2. Web 营销站新增版本更新页面(/changelog 或 /download/),从 CHANGELOG.md 自动渲染,展示所有历史版本更新记录。",
"level": "low",
"tags": [
"前端",
"Web",
"文档"
],
"created_at": "2026-06-08T15:05:00.447Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 23,
"title": "注册/联系入口「发邮件」升级为「加客服微信」并优化文案",
"desc": "当前注册成功页和网站联系支持入口引导用户「发邮件」,体验较弱。升级为扫码加客服微信,同时梳理相关文案措辞,使表达更专业、有亲和力。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-08T15:06:00.175Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 24,
"title": "服务条款与隐私政策页面",
"desc": "在营销网站(jiu.51yanmei.com)补充服务条款(Terms of Service)和隐私政策(Privacy Policy)页面,注册页底部添加链接。最低优先级,待其他功能稳定后处理。",
"level": "low",
"tags": [
"Web",
"文档"
],
"created_at": "2026-06-08T15:06:26.383Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 25,
"title": "移动端左侧抽屉菜单支持手势侧滑唤出",
"desc": "窄屏(手机)模式下,导航 Drawer 当前需点击汉堡按钮打开,需支持从屏幕左边缘向右滑动手势唤出菜单,符合移动端操作习惯。涉及 app_shell.dart 的 Drawer 配置。",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T15:09:27.652Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 26,
"title": "入库单详情商品明细改为卡片式竖排布局",
"desc": "当前入库单详情的商品明细以横向表格展示,字段多时在窄屏上挤压严重。改为每件商品独占一个卡片区块,字段竖向排列(商品名、规格、数量、单价、产地、保质期等各占一行),信息层次清晰,移动端阅读体验更好。宽屏可保持表格,窄屏自动切换为卡片流(复用 MobileListCard 模式)。",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T15:11:09.971Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 27,
"title": "桌面客户端单实例限制,禁止同时运行多个进程",
"desc": "Windows 和 macOS 客户端启动时检测是否已有实例运行;若有,则激活已有窗口并退出新进程。Flutter desktop 可通过 window_single_instance 或 dart:io 锁文件实现。",
"level": "mid",
"tags": [
"Windows",
"mac"
],
"status": "done",
"created_at": "2026-06-08T15:58:11.759Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 29,
"title": "修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行",
"desc": "stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行",
"level": "high",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:13.249Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 30,
"title": "统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖",
"desc": "partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&x).Where(\"id=? AND shop_id=?\").Updates(fields) 白名单模式",
"level": "high",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:15.102Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 31,
"title": "添加库存与财务流水定期对账检查,防止事务中断导致不平账",
"desc": "出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制",
"level": "high",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:16.854Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 32,
"title": "迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段",
"desc": "licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本",
"level": "mid",
"tier": 2,
"tags": [
"后端",
"数据库"
],
"status": "done",
"created_at": "2026-06-10T15:31:25.715Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 33,
"title": "将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层",
"desc": "checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService",
"level": "mid",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:28.106Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 34,
"title": "统一后端 API 错误响应格式为 {code, message},创建 util/response.go",
"desc": "各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用",
"level": "mid",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:33.596Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 35,
"title": "生产环境 CORS 强制校验:非 debug 模式禁止 Origin=*",
"desc": "config.go 默认 CORSOrigin=\"*\",注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin==\"*\" 时 log.Fatal 拒绝启动",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:44.159Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 36,
"title": "License 私钥未配置时改为 Fatal 而非静默跳过",
"desc": "license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:46.792Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 37,
"title": "统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize()",
"desc": "inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:49.032Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 38,
"title": "将拼音回填从启动逻辑改为一次性迁移脚本",
"desc": "main.go 每次启动执行 backfillPinyin 全表扫描,数据量大后拖慢启动。改为 go run ./cmd/migrate-pinyin 一次性迁移,后续新数据靠写入时自动生成",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:57.979Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 39,
"title": "DB 连接池参数(MaxIdleConns/MaxOpenConns)改为可配置项",
"desc": "main.go 中硬编码 MaxIdleConns=10, MaxOpenConns=100,无法运维调优。改为读取 config(viper),并在文档中说明推荐值",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:32:00.233Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 40,
"title": "明确 Inventories 反范式字段语义:是当前值还是入库快照",
"desc": "inventories 表存了 product_name/warehouse_name/supplier_name 等,既可能是冗余(与 products JOIN 重复),也可能是历史快照(入库时锁定)。需明确语义并在 model 注释中说明;若是快照,应只在写入时固定,不随主表变化",
"level": "low",
"tier": 2,
"tags": [
"后端",
"数据库"
],
"status": "done",
"created_at": "2026-06-10T15:32:03.641Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 41,
"title": "CheckInventoryAvailability Scan错误未检查导致所有商品误报库存不足",
"desc": "service/stock.go:312 DB查询失败时sums为空,所有商品have=0,合法出库审核被全部拒绝",
"level": "high",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:34.919Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 42,
"title": "finance ListRecords 和 inventory Logs handler 缺 pageSize 上限",
"desc": "finance.go:36 和 inventory.go:130 漏加 ValidatePageSize#37 未覆盖这两个 handler,可被任意用户触发全表扫描",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.152Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 43,
"title": "ReconcileInventory 两条 Scan 错误未检查导致对账误判",
"desc": "admin.go:122-123 invQuery/logQuery Scan错误被丢弃;invQuery失败→差异消失显示全部正常;logQuery失败→所有库存飘红为差异",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.205Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 44,
"title": "license Info 端点 device_count 统计错误且 ListDevices 错误被吞",
"desc": "handler/license.go:67-70 两个bug1.devs,_ 错误被_吞掉DB故障时deviceCount=02.ListDevices(shopID)包含该店所有license设备而非当前license",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.257Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 45,
"title": "license Activate 幂等路径 Update 错误未检查",
"desc": "service/license.go:70 s.db.Model(&existing).Update()失败时仍返回nil error,设备名未更新但调用方不知道",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.016Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 46,
"title": "license Verify 加载 license 缺 shop_id 过滤(防御纵深)",
"desc": "service/license.go:102 WHERE id=? AND is_active=1 应加 AND shop_id=? 防御数据异常时跨租户泄露",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.066Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 47,
"title": "util/response.go 的 RespondError 等辅助函数从未被调用(#34 实际未完成)",
"desc": "#34 只创建了util/response.go,没有将任何handler迁移过去,实际错误格式仍不统一,需完成迁移",
"level": "low",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.118Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 48,
"title": "清理 ErrDeviceMismatch 死代码(多设备迁移后残留)",
"desc": "service/license.go:24 ErrDeviceMismatch定义后从未返回,多设备迁移(#32)后已无单设备约束",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.168Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 49,
"title": "修复 act_runner→Forgejo 任务状态上报丢失导致构建成功却标失败",
"desc": "v1.0.27~v1.0.32 连续 6 个版本 CI 全部'失败',但日志显示所有 job 实际构建成功(🏁 Job succeeded),是 act_runner 最终状态上报丢失(Failed to write EOT),被 Forgejo 僵尸任务清理标为 failure。生产环境因此停留在 v1.0.266/8)。需排查 relay/GOAWAY race 或升级 act_runner/Forgejo。",
"level": "high",
"tier": 2,
"tags": [
"CI/CD"
],
"status": "done",
"created_at": "2026-06-11T13:55:07.388Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 50,
"title": "License model 的 license_key 索引超 InnoDB 3072 字节限制需改用前缀索引",
"desc": "license.go 的 LicenseKey 是 size:2048+uniqueIndexutf8mb4 下 8192 字节超 MySQL InnoDB 索引上限,全新环境 AutoMigrate 必失败(线上已手动建 license_key(768) 前缀索引绕过)。需在 model 层固化:缩短列或 GORM 指定索引前缀长度,并同步 schema.sql。",
"level": "mid",
"tier": 3,
"tags": [
"后端",
"数据库"
],
"status": "done",
"created_at": "2026-06-11T15:52:40.353Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 51,
"title": "iOS 上线 TestFlight 正式分发",
"desc": "路线已定:CI 构建签名 IPA → 上传 TestFlight。前置(用户侧)Apple Developer Program($99/年)+App Store Connect 建 App(com.yanmei.jiu)+三件套凭证(.p12/.mobileprovision/.p8)+7个 Forgejo secrets+TestFlight 公开链接填 version.yaml。代码缺口(我方)compile-ios.sh 需在 flutter build ipa 前向 Release.xcconfig 注入 manual 签名(CODE_SIGN_STYLE/DEVELOPMENT_TEAM/PROVISIONING_PROFILE_SPECIFIER/CODE_SIGN_IDENTITY),否则 archive 阶段因 Automatic 无 team 必失败。详见计划 luminous-hugging-platypus.md。暂不调度。",
"level": "mid",
"tier": 1,
"tags": [
"iOS",
"CI/CD"
],
"status": "open",
"created_at": "2026-06-13T15:22:29.363Z",
"done": false,
"completed_at": null,
"version": null
}
]
}
-1066
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
module.exports = function() {
const raw = fs.readFileSync(path.join(__dirname, '../../CHANGELOG.md'), 'utf8');
const raw = fs.readFileSync(path.join(__dirname, '../../CHANGELOG-client.md'), 'utf8');
const lines = raw.split('\n');
const versions = [];
let current = null;
+3 -4
View File
@@ -72,7 +72,7 @@
<div class="docs-version">
<i data-lucide="tag" class="icon"></i>
<span class="docs-version-label">版本</span>
<span>v1.0.23</span>
<span>v1.0.54</span>
</div>
</div>
</div>
@@ -840,6 +840,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -872,14 +873,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+89 -66
View File
@@ -308,7 +308,7 @@
<div class="container ph-inner">
<div>
<div class="ph-eyebrow">
<span class="version-badge">v1.0.23</span>
<span class="version-badge">v1.0.54</span>
<span>下载岩美</span>
</div>
<h1 class="ph-title">随时随地,<br/>轻松管理酒库。</h1>
@@ -318,11 +318,11 @@
<div class="ph-meta">
<div class="ph-meta-item">
<div class="ph-meta-label">最新版本</div>
<div class="ph-meta-val version-badge">v1.0.23</div>
<div class="ph-meta-val version-badge">v1.0.54</div>
</div>
<div class="ph-meta-item">
<div class="ph-meta-label">发布日期</div>
<div class="ph-meta-val">2026-06-07</div>
<div class="ph-meta-val">2026-06-17</div>
</div>
<div class="ph-meta-item">
<div class="ph-meta-label">可用平台</div>
@@ -545,14 +545,14 @@
<section class="section">
<div class="container">
<h2 class="fs-3xl fw-6 text-brand-900 m-0 mb-6" style="letter-spacing: var(--tracking-cn-display);">版本更新日志</h2>
<p class="fs-md text-muted m-0">最近 3 个版本。</p>
<p class="fs-md text-muted m-0" id="changelog-count">最近 3 个版本。</p>
<div class="changelog">
<div class="changelog" id="changelog-list">
<div class="changelog-entry current">
<div class="changelog-meta">
<div class="changelog-version">v1.0.23</div>
<div class="changelog-date">2026-06-07</div>
<div class="changelog-version">v1.0.54</div>
<div class="changelog-date">2026-06-17</div>
<span class="changelog-tag">当前版本</span>
</div>
<div class="changelog-body">
@@ -561,12 +561,16 @@
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-fix"></span>问题修复
<span class="dot dot-feature"></span>新功能
</div>
<ul>
<li>官网功能详情页(库存管理/审核流)导航栏现与主站完全一致,不再缺少「库存」「审核流」入口</li>
<li>只读账号自动隐藏所有写操作按钮(新增/编辑/删除/审核/提交/结清/导入等),顶栏显示「只读」标识,权限一目了然</li>
<li>左侧抽屉和系统设置页新增「退出登录」入口,窄屏也能方便退出</li>
<li>酒行信息新增「微信号」显示</li>
</ul>
</div>
@@ -579,47 +583,9 @@
</div>
<ul>
<li>官网功能详情页接入统一模板系统,导航/页脚/版权信息改一处自动同步全站</li>
<li>网络请求失败时自动重试(间隔递增),离线时提供带状态反馈的「重试」按钮</li>
</ul>
</div>
</div>
</div>
<div class="changelog-entry">
<div class="changelog-meta">
<div class="changelog-version">v1.0.22</div>
<div class="changelog-date">2026-06-07</div>
</div>
<div class="changelog-body">
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-feature"></span>新功能
</div>
<ul>
<li>官网移动端适配:手机浏览器下导航折叠、首页各区块单列排版、features 详情页和页脚自适应窄屏</li>
</ul>
</div>
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-improve"></span>改进
</div>
<ul>
<li>客户端移动端布局优化:入库/出库详情页商品明细改为卡片式竖排,操作按钮收纳至溢出菜单,窄屏不再横向溢出</li>
<li>信息架构整理:仓库管理移入基础数据,系统设置新增授权 Tab 和数据管理组,关于我们补充帮助文档/法律信息/系统信息入口</li>
<li>iOS 顶栏避开状态栏,菜单按钮不再被时间/信号遮挡</li>
</ul>
</div>
@@ -632,17 +598,9 @@
</div>
<ul>
<li>官网所有功能页死链修正(`/download.html` → `/download/``/docs.html` → `/docs/`</li>
<li>库存导入改用 Excel 中的「入库日期」作为入库时间(不再统一记为导入当天);相同数据重复导入时自动跳过,仅更新有变化的字段,避免重复与误覆盖</li>
<li>官网删除未实现功能的虚假宣传(红冲、调拨单、库存推送通知、过期预警等),文案与实际功能对齐</li>
<li>官网客服邮箱更新为真实邮箱,移除假冒客服电话,页脚清除无目标死链</li>
<li>导航新增「库存」「审核流」功能页入口,首页模块卡加「了解更多」链接</li>
<li>下载页接口失败时按钮改为禁用态并提示,不再无响应</li>
<li>首页 iOS 平台说明改为「TestFlight 内测中」,与下载页状态一致</li>
<li>修复 iOS 上顶栏被状态栏遮挡导致无法打开菜单/退出登录的问题</li>
</ul>
</div>
@@ -652,8 +610,8 @@
<div class="changelog-entry">
<div class="changelog-meta">
<div class="changelog-version">v1.0.21</div>
<div class="changelog-date">2026-06-07</div>
<div class="changelog-version">v1.0.53</div>
<div class="changelog-date">2026-06-15</div>
</div>
<div class="changelog-body">
@@ -662,12 +620,37 @@
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-feature"></span>新功能
<span class="dot dot-fix"></span>问题修复
</div>
<ul>
<li>商品详情页添加照片时,手机端(Android/iOS)支持直接拍照或从相册选择</li>
<li>修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)</li>
</ul>
</div>
</div>
</div>
<div class="changelog-entry">
<div class="changelog-meta">
<div class="changelog-version">v1.0.52</div>
<div class="changelog-date">2026-06-15</div>
</div>
<div class="changelog-body">
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-improve"></span>改进
</div>
<ul>
<li>热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题</li>
</ul>
</div>
@@ -747,6 +730,46 @@
});
}
// 用 /api/v1/public/release 返回的 changelog 重渲染更新日志时间线,使客户端
// 发版无需重建官网即可同步。结构与构建时 web/_data/changelog.js 一致;接口
// 不可达或字段缺失时保留构建时静态时间线作 fallback。
function renderChangelog(entries) {
if (!Array.isArray(entries) || entries.length === 0) return;
var dotClass = { '新功能': 'dot-feature', '改进': 'dot-improve', '修复': 'dot-fix' };
var label = { '修复': '问题修复' };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function(c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
var html = '';
entries.forEach(function(entry, i) {
var current = i === 0;
html += '<div class="changelog-entry' + (current ? ' current' : '') + '">';
html += '<div class="changelog-meta">';
html += '<div class="changelog-version">v' + esc(entry.version) + '</div>';
html += '<div class="changelog-date">' + esc(entry.date) + '</div>';
if (current) html += '<span class="changelog-tag">当前版本</span>';
html += '</div><div class="changelog-body">';
if (entry.intro) html += '<p class="changelog-intro">' + esc(entry.intro) + '</p>';
(entry.sections || []).forEach(function(section) {
var dot = dotClass[section.type] || 'dot-improve';
var name = label[section.type] || section.type;
html += '<div class="changelog-cat"><div class="changelog-cat-label">';
html += '<span class="dot ' + dot + '"></span>' + esc(name) + '</div><ul>';
(section.items || []).forEach(function(item) {
html += '<li>' + esc(item) + '</li>';
});
html += '</ul></div>';
});
html += '</div></div>';
});
var list = document.getElementById('changelog-list');
if (list) list.innerHTML = html;
var count = document.getElementById('changelog-count');
if (count) count.textContent = '最近 ' + entries.length + ' 个版本。';
}
document.addEventListener('DOMContentLoaded', function() {
var detected = detectPlatform();
updateDetectCard(detected);
@@ -756,6 +779,7 @@
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && data.version) updateVersionBadges(data.version);
if (data && data.changelog) renderChangelog(data.changelog);
if (data && data.download_urls && data.download_urls.macos) {
platforms.macos.btnHref = data.download_urls.macos;
var macBtn = document.getElementById('macos-dl-btn');
@@ -822,6 +846,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -854,14 +879,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+2 -3
View File
@@ -670,6 +670,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -702,14 +703,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+2 -3
View File
@@ -707,6 +707,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -739,14 +740,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+5 -7
View File
@@ -240,8 +240,8 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<div class="container hero-inner">
<div>
<div class="d-inline-flex items-center gap-8 bg-0 border rounded-pill fs-sm text-gray-7 mb-24" style="padding: 6px 14px 6px 8px;">
<span class="badge badge-brand">v1.0.23</span>
<span>2026-06-07 更新 ·</span>
<span class="badge badge-brand">v1.0.54</span>
<span>2026-06-17 更新 ·</span>
<span class="text-brand fw-5">查看更新</span>
</div>
<h1>从入库到出库,<br/><em>一套系统</em>管到底。</h1>
@@ -605,7 +605,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>单门店 · 最多 3 用户</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>全部模块开放</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>Web 端 + 移动端</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>邮件技术支持</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>微信技术支持</li>
</ul>
<a href="/register/" class="btn btn-secondary w-full justify-center mt-auto">免费开通</a>
</div>
@@ -703,6 +703,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -715,7 +716,6 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<li><a href="/#reports">数据洞察</a></li>
<li><a href="/#pricing">价格方案</a></li>
<li><a href="/download/">版本更新</a></li>
<li><a href="/changelog/">更新日志</a></li>
</ul>
</div>
<div class="footer-col">
@@ -736,14 +736,12 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+4 -5
View File
@@ -210,7 +210,7 @@
</table>
</div>
<p class="success-note">如忘记门店编码,登录后可在「系统设置 → 关于」中查看。</p>
<p class="success-note">如忘记门店编码,登录后可在「系统设置」中查看。</p>
<a href="/app/" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:8px;">
<i data-lucide="log-in" class="icon"></i>前往登录
</a>
@@ -221,7 +221,7 @@
<script>
(function() {
var API_URL = window.location.origin + '/api/v1/public/register';
var API_URL = "https://jiu.51yanmei.com/api/v1/public/register";
function $(id) { return document.getElementById(id); }
@@ -383,6 +383,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -415,14 +416,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+43 -2
View File
@@ -390,9 +390,9 @@ pageStyle: |
<section class="section">
<div class="container">
<h2 class="fs-3xl fw-6 text-brand-900 m-0 mb-6" style="letter-spacing: var(--tracking-cn-display);">版本更新日志</h2>
<p class="fs-md text-muted m-0">最近 {{ changelog | length }} 个版本。</p>
<p class="fs-md text-muted m-0" id="changelog-count">最近 {{ changelog | length }} 个版本。</p>
<div class="changelog">
<div class="changelog" id="changelog-list">
{% for entry in changelog %}
<div class="changelog-entry{% if loop.first %} current{% endif %}">
<div class="changelog-meta">
@@ -499,6 +499,46 @@ pageStyle: |
});
}
// 用 /api/v1/public/release 返回的 changelog 重渲染更新日志时间线,使客户端
// 发版无需重建官网即可同步。结构与构建时 web/_data/changelog.js 一致;接口
// 不可达或字段缺失时保留构建时静态时间线作 fallback。
function renderChangelog(entries) {
if (!Array.isArray(entries) || entries.length === 0) return;
var dotClass = { '新功能': 'dot-feature', '改进': 'dot-improve', '修复': 'dot-fix' };
var label = { '修复': '问题修复' };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function(c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
var html = '';
entries.forEach(function(entry, i) {
var current = i === 0;
html += '<div class="changelog-entry' + (current ? ' current' : '') + '">';
html += '<div class="changelog-meta">';
html += '<div class="changelog-version">v' + esc(entry.version) + '</div>';
html += '<div class="changelog-date">' + esc(entry.date) + '</div>';
if (current) html += '<span class="changelog-tag">当前版本</span>';
html += '</div><div class="changelog-body">';
if (entry.intro) html += '<p class="changelog-intro">' + esc(entry.intro) + '</p>';
(entry.sections || []).forEach(function(section) {
var dot = dotClass[section.type] || 'dot-improve';
var name = label[section.type] || section.type;
html += '<div class="changelog-cat"><div class="changelog-cat-label">';
html += '<span class="dot ' + dot + '"></span>' + esc(name) + '</div><ul>';
(section.items || []).forEach(function(item) {
html += '<li>' + esc(item) + '</li>';
});
html += '</ul></div>';
});
html += '</div></div>';
});
var list = document.getElementById('changelog-list');
if (list) list.innerHTML = html;
var count = document.getElementById('changelog-count');
if (count) count.textContent = '最近 ' + entries.length + ' 个版本。';
}
document.addEventListener('DOMContentLoaded', function() {
var detected = detectPlatform();
updateDetectCard(detected);
@@ -508,6 +548,7 @@ pageStyle: |
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && data.version) updateVersionBadges(data.version);
if (data && data.changelog) renderChangelog(data.changelog);
if (data && data.download_urls && data.download_urls.macos) {
platforms.macos.btnHref = data.download_urls.macos;
var macBtn = document.getElementById('macos-dl-btn');