Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40d9aba657 | |||
| aa7099ba94 | |||
| eca62ba2c3 | |||
| 21700fa64c | |||
| 5cfd1795ec | |||
| f15692bbd5 | |||
| 196901b6d3 | |||
| 53465ed704 | |||
| 5371d40f05 | |||
| a63ee263ca | |||
| e8bdb19195 | |||
| cd672e500f | |||
| 018180de8c | |||
| 3f3aa41121 | |||
| 73dbd65ef5 | |||
| 8749bbae72 |
+46
-36
@@ -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: 纯内部)
|
||||
- 依赖升级、版本号 bump(chore: 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()
|
||||
@@ -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 }}"
|
||||
@@ -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 }}"
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -59,3 +59,6 @@ backend/issue
|
||||
# Claude 私有文件
|
||||
.claude/commands/
|
||||
.claude/worktrees/
|
||||
|
||||
# iOS 证书私钥(绝不入库)
|
||||
.ios-certs/
|
||||
|
||||
@@ -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
|
||||
|
||||
### 修复
|
||||
@@ -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 解析库,按唯一编号自动去重,入库时间取自表格中的「入库日期」
|
||||
@@ -0,0 +1,15 @@
|
||||
# Changelog — Site(官网)
|
||||
|
||||
营销宣传站(`web/`,Eleventy)的更新记录。tag 前缀 `site-v*`。
|
||||
**不含** Web 版 app(Web 版 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*`),与客户端、后端互不影响
|
||||
|
||||
### 改进
|
||||
- 下载页「更新日志」改为运行时从接口动态拉取,客户端发版后即时刷新、无需重建官网;接口不可达时回退到内置内容
|
||||
@@ -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/CD(Forgejo,`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
|
||||
执行顺序:本地 build → test(按 part)→ 更新对应 CHANGELOG → git commit → tag `<part>-v<ver>` → push main+tag。
|
||||
CI/CD(Forgejo)按 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/CD(Forgejo,`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译
|
||||
### 修复
|
||||
- ...
|
||||
```
|
||||
只保留有内容的分类。`/release` 会检查 CHANGELOG 是否已有该版本节,没有则从 git log 自动生成。
|
||||
只保留有内容的分类。`/release` 会检查对应 CHANGELOG 是否已有该版本节,没有则从该 part 上一个 tag 的 git log 自动生成。
|
||||
|
||||
### 异常上报
|
||||
|
||||
@@ -310,28 +324,10 @@ CI/CD(Forgejo,`.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 工具。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -69,5 +69,5 @@ download_urls:
|
||||
## 工作原理
|
||||
|
||||
- `scripts/ci/compile-ios.sh`:建临时 keychain 导入证书、安装 profile、生成 ExportOptions(manual / app-store)、`flutter build ipa`、`xcrun altool --upload-app` 上传;CFBundleVersion 由版本号推导单调递增;缺 secrets 时跳过。
|
||||
- `.gitea/workflows/deploy.yml` 的 `build-ios` job:mac runner,串行于 build-android。
|
||||
- `.gitea/workflows/deploy-client.yml` 的 `build-ios` job:mac runner,串行于 build-android。
|
||||
- 工程:`client/ios`(bundle id `com.yanmei.jiu`,应用名「岩美酒库」)。
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -17,7 +17,7 @@ set -euo pipefail
|
||||
. "$(dirname "$0")/_env.sh"
|
||||
|
||||
TAG="$1"
|
||||
VER="${TAG#v}"
|
||||
VER="${TAG#client-v}"
|
||||
|
||||
# CFBundleVersion(build 号)必须单调递增,否则 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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -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).
|
||||
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 config(WorkingDirectory=/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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
-821
@@ -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_price;2) 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 CRUD(16 个方法);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 两个bug:1.devs,_ 错误被_吞掉DB故障时deviceCount=0;2.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.26(6/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+uniqueIndex,utf8mb4 下 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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
Vendored
+3
-4
@@ -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>
|
||||
|
||||
Vendored
+89
-66
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
|
||||
Vendored
+2
-3
@@ -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>
|
||||
|
||||
Vendored
+2
-3
@@ -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>
|
||||
|
||||
Vendored
+5
-7
@@ -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>
|
||||
|
||||
Vendored
+4
-5
@@ -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
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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');
|
||||
|
||||
Reference in New Issue
Block a user