feat(devops): 发版拆分为 client/site/server 三条独立流水线

将单一 CI/CD 流水线拆成三条互不影响的发布流水线,各有 tag 前缀、独立版本
序列与独立 CHANGELOG:

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 08:21:28 +08:00
parent eca62ba2c3
commit aa7099ba94
39 changed files with 998 additions and 534 deletions
+46 -36
View File
@@ -1,17 +1,26 @@
---
description: 执行本地发版流程(build → test → CHANGELOG → commit → tag → push
argument-hint: <version> 如 1.0.22
description: 执行某一条流水线的本地发版(part ∈ client|site|server
argument-hint: <part> [version] 如 `client 1.0.55``server`
model: claude-sonnet-4-6
allowed-tools: Bash, Read, Edit, Write
---
执行本地发版流程。
执行三条独立流水线之一的本地发版流程。`$ARGUMENTS` 形如 `<part> [version]`
**确定版本号:**
- 如果 `$ARGUMENTS` 非空,使用它作为版本号(VERSION)
- 如果 `$ARGUMENTS` 为空,运行 `git describe --tags --abbrev=0` 获取最新 tag(如 `v1.0.20`),去掉 `v` 前缀后将 patch 号加 1(如 `1.0.21`),作为版本号(VERSION)。将自动确定的版本号告知用户。
- **part**(必填):`client` | `site` | `server`,分别对应 tag 前缀 `client-v*` / `site-v*` / `server-v*`、CHANGELOG 文件 `CHANGELOG-client.md` / `CHANGELOG-site.md` / `CHANGELOG-server.md`
- **version**(选填):如 `1.0.55`
后续所有步骤中,将确定好的版本号记为 VERSION
**三者互不影响**client 发版只动 app 产物与 `version.yaml`(官网下载页与后端 `/version` 自动同步,无需重建官网/重启后端);site 只重建营销站;server 只换后端二进制 + nginx
## 0. 解析参数
- 校验 part 合法(client|site|server),非法立即报错退出。
- 记 PART = part。
**确定版本号 VERSION**
- 给了 version:直接用。
- 没给:`git describe --tags --match "<PART>-v*" --abbrev=0` 取该前缀最新 tag(如 `client-v1.0.54`),去掉 `<PART>-v` 前缀后 patch +1(如 `1.0.55`)。若该前缀尚无任何 tag,则 VERSION=`1.0.0`。将自动确定的版本号告知用户。
- 记 TAG = `<PART>-v<VERSION>`CHANGELOG_FILE = `CHANGELOG-<PART>.md`
按以下步骤顺序执行,任意步骤失败立即停止并报告错误。
@@ -19,27 +28,31 @@ allowed-tools: Bash, Read, Edit, Write
检查是否有未提交的改动(`git status`)。如果有,列出文件并询问用户是否继续。
## 2. 本地构建
## 2. 本地构建 + 测试门禁(按 part
```bash
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go build ./...
```
## 3. 运行测试
- **server**
```bash
cd backend && go build ./... && go vet ./... && go test ./...
```
- **client**
```bash
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
```
- **site**
```bash
cd web && npm ci && npm run build
```
```bash
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go test ./...
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
```
## 3. 更新 CHANGELOG_FILE
## 4. 更新 CHANGELOG.md
检查 CHANGELOG.md 中是否已有 `## [VERSION]` 版本节。
检查 CHANGELOG_FILE 中是否已有 `## [VERSION]` 版本节。
- **已有**:直接使用,不修改。
- **没有**:读取 `git log` 从上一个 tag 到 HEAD 的提交记录,自动生成一个版本节插入文件顶部(位于已有版本节之前),格式如下
- **没有**:读取该 part 上一个 tag`<PART>-v*`)到 HEAD 的 `git log`,自动生成版本节插入文件顶部(位于已有版本节之前),格式:
```
## [VERSION] - <今天日期>
@@ -54,36 +67,33 @@ cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
- ...
```
只保留有内容的分类(新功能/改进/修复),根据 commit message 的类型(feat/fix/refactor 等)归类。
只保留有内容的分类,按 commit 类型(feat/fix/refactor 等)归类。
**CHANGELOG 撰写原则:**
- 站在**用户视角**总结,描述对使用体验的实际影响,而非技术实现
- **忽略**以下类型的提交(产品感知弱):
- 发版工具、CI/CD、部署脚本相关(chore: release、ci:、devops:
- 内部文档、开发规范、CLAUDE.md 更新(docs(claude):、docs(internal):
- 代码格式、lint、重构(style:、refactor: 纯内部)
- 依赖升级、版本号 bumpchore: bump、chore: upgrade
- **保留并重点描述**:feat、fix、perf 类,以及用户能感知到的 refactor(如界面改版、交互优化)
- 每条描述用一句话说清楚"用户能做什么"或"修了什么问题",不堆砌技术术语
- 站在**用户视角**总结,描述对使用体验的实际影响,而非技术实现
- **忽略**产品感知弱的提交:发版/CI/部署(chore: release、ci:、devops:)、内部文档/规范(docs(claude):、docs(internal):)、纯格式/重构(style:、refactor:)、依赖升级(chore: bump)。
- **保留并重点描述** feat / fix / perf,以及用户可感知的 refactor(界面改版、交互优化)。
- 每条一句话说清"用户能做什么"或"修了什么问题"。
- **范围按 part 过滤**client 只收 `client/` 相关;server 只收 `backend/` 相关;site 只收 `web/`(营销站)相关。
## 5. 提交代码
## 4. 提交代码
将所有改动(包括 CHANGELOG.md)用以下格式提交到 main
将所有改动( CHANGELOG_FILE提交到 main
```
chore: release vVERSION
chore: release <PART>-v<VERSION>
```
## 6. 打 tag
## 5. 打 tag
```bash
git tag vVERSION
git tag <PART>-v<VERSION>
```
## 7. 推送
## 6. 推送
```bash
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main vVERSION
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main <PART>-v<VERSION>
```
推送成功后提示用户:CI/CD 已触发,等待 Telegram 通知。
推送成功后提示用户:对应流水线(deploy-<PART>已触发,等待 Telegram 通知。
@@ -1,12 +1,16 @@
name: Deploy
name: Deploy Client
on:
push:
tags:
- 'v[0-9]*.[0-9]*.[0-9]*'
- 'client-v[0-9]*.[0-9]*.[0-9]*'
concurrency:
group: deploy-client
cancel-in-progress: false
jobs:
build-linux-web:
build-client-web:
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
@@ -19,19 +23,17 @@ jobs:
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: Compile (Linux backend + Flutter Web)
run: sh scripts/ci/compile.sh "${{ gitea.ref_name }}"
- name: Compile (Flutter Web)
run: sh scripts/ci/compile-client-web.sh "${{ gitea.ref_name }}"
- name: Upload linux-web artifacts
- name: Upload web artifacts
uses: actions/upload-artifact@v3
with:
name: linux-web
name: client-web
path: dist/
build-macos:
# Same physical runner as build-linux-web (label: mac, capacity 1). Run them
# serially via needs so the second job doesn't sit queued and time out.
needs: build-linux-web
needs: build-client-web
runs-on: mac
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -53,9 +55,6 @@ jobs:
path: dist/
build-android:
# Same physical mac runner (label: mac, capacity 1) — it already has the
# Android SDK. Serialize after build-macos via needs so it doesn't sit
# queued and time out.
needs: build-macos
runs-on: mac
env:
@@ -83,10 +82,6 @@ jobs:
path: dist/
build-ios:
# Same physical mac runner (label: mac, capacity 1) with Xcode. Serialize
# after build-android. Builds a signed IPA and uploads to TestFlight; if the
# iOS signing secrets are absent the script skips gracefully (exit 0), so this
# job is safe to keep before the Apple Developer account is configured.
needs: build-android
runs-on: mac
env:
@@ -134,8 +129,8 @@ jobs:
name: windows
path: dist/
release-deploy:
needs: [build-linux-web, build-macos, build-android, build-ios, build-windows]
release-deploy-client:
needs: [build-client-web, build-macos, build-android, build-ios, build-windows]
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
@@ -145,10 +140,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download linux-web artifacts
- name: Download web artifacts
uses: actions/download-artifact@v3
with:
name: linux-web
name: client-web
path: dist/
- name: Download macos artifacts
@@ -169,15 +164,15 @@ jobs:
name: windows
path: dist/
- name: Test
run: sh scripts/ci/test.sh
- name: Test (flutter analyze)
run: sh scripts/ci/test.sh client
- name: Release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release.sh "${{ gitea.ref_name }}"
run: sh scripts/ci/release-client.sh "${{ gitea.ref_name }}"
- name: Deploy
env:
@@ -187,7 +182,7 @@ jobs:
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}"
run: sh scripts/ci/deploy-client.sh "${{ gitea.ref_name }}"
- name: Notify
if: always()
+54
View File
@@ -0,0 +1,54 @@
name: Deploy Server
on:
push:
tags:
- 'server-v[0-9]*.[0-9]*.[0-9]*'
concurrency:
group: deploy-server
cancel-in-progress: false
jobs:
release-deploy-server:
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: Compile (Linux backend + configs)
run: sh scripts/ci/compile-backend.sh "${{ gitea.ref_name }}"
- name: Test (go test)
run: sh scripts/ci/test.sh server
- name: Release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-server.sh "${{ gitea.ref_name }}"
- name: Deploy
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy-server.sh "${{ gitea.ref_name }}"
- name: Notify
if: always()
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}"
+50
View File
@@ -0,0 +1,50 @@
name: Deploy Site
on:
push:
tags:
- 'site-v[0-9]*.[0-9]*.[0-9]*'
concurrency:
group: deploy-site
cancel-in-progress: false
jobs:
release-deploy-site:
runs-on: mac
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: Compile (Eleventy marketing site)
run: sh scripts/ci/compile-site.sh "${{ gitea.ref_name }}"
- name: Release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-site.sh "${{ gitea.ref_name }}"
- name: Deploy
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy-site.sh "${{ gitea.ref_name }}"
- name: Notify
if: always()
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}"
+23 -3
View File
@@ -1,10 +1,16 @@
name: Manual Deploy
# Re-deploy a previously released tag. The tag prefix selects which part:
# client-v* -> deploy-client.sh (web app + installers + version.yaml)
# site-v* -> deploy-site.sh (marketing site)
# server-v* -> deploy-server.sh (backend binary + nginx)
# Each deploy script downloads its assets from the matching Forgejo Release.
on:
workflow_dispatch:
inputs:
version:
description: 'Tag to deploy (e.g. v1.0.0)'
description: 'Prefixed tag to deploy (e.g. client-v1.0.55 / site-v1.0.0 / server-v1.0.0)'
required: true
type: string
@@ -17,7 +23,7 @@ jobs:
with:
ref: ${{ inputs.version }}
- name: Deploy
- name: Deploy (routed by tag prefix)
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
@@ -25,4 +31,18 @@ jobs:
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy.sh "${{ inputs.version }}"
run: |
TAG="${{ inputs.version }}"
case "$TAG" in
client-v*) sh scripts/ci/deploy-client.sh "$TAG" ;;
site-v*) sh scripts/ci/deploy-site.sh "$TAG" ;;
server-v*) sh scripts/ci/deploy-server.sh "$TAG" ;;
*) echo "Unknown tag prefix: $TAG (expected client-v* / site-v* / server-v*)" >&2; exit 1 ;;
esac
- name: Notify
if: always()
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ inputs.version }}" "${{ job.status }}"
+11
View File
@@ -0,0 +1,11 @@
# 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.0] - 2026-06-17
### 新功能
- 后端服务独立发版流水线启用(`server-v*`),与客户端、官网互不影响
+12
View File
@@ -0,0 +1,12 @@
# Changelog — Site(官网)
营销宣传站(`web/`Eleventy)的更新记录。tag 前缀 `site-v*`
**不含** Web 版 appWeb 版 app 属于客户端,见 `CHANGELOG-client.md`)。
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-06-17
### 新功能
- 官网独立发版流水线启用(`site-v*`),与客户端、后端互不影响
+25 -11
View File
@@ -261,22 +261,36 @@ cd client && flutter test
### 发版流程
使用 `/release <version>` slash command
**发版拆成三条互不影响的流水线**,各有 tag 前缀、独立版本序列、独立 CHANGELOG
| part | 范围 | tag 前缀 | CHANGELOG | workflow |
|------|------|---------|-----------|----------|
| **client** | `client/` Flutter 全平台(Web→`/app`、macOS、Windows、Android、iOS+ 应用自更新清单 `version.yaml` | `client-v*` | `CHANGELOG-client.md` | `deploy-client.yml` |
| **site** | `web/` Eleventy 营销宣传站(→`/opt/jiu/marketing`nginx `/`)。**不含** Web 版 app | `site-v*` | `CHANGELOG-site.md` | `deploy-site.yml` |
| **server** | `backend/` Go 服务(`jiu-server`+ 共享基建 nginx-jiu.conf / jiu.service | `server-v*` | `CHANGELOG-server.md` | `deploy-server.yml` |
使用 `/release <part> [version]` slash command
```
/release 1.0.2
/release client 1.0.55 # 指定版本
/release server # 省略则自增 server-v* 最新 tag 的 patch
```
执行顺序:本地 build → test → 更新 CHANGELOG.md → git commit → tag → push main+tag。
CI/CDForgejo`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
执行顺序:本地 build → test(按 part→ 更新对应 CHANGELOG → git commit → tag `<part>-v<ver>` → push main+tag。
CI/CDForgejo)按 tag 前缀触发对应 workflow自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
**多平台构建矩阵**(脚本在 `scripts/ci/`):
- mac runner(容量 1)串行链:`build-linux-web`(后端+Web+官网)→ `build-macos` → `build-android` → `build-ios` → `release-deploy`
- windows runner 并行:`build-windows`Inno Setup 打包 setup.exe
- 分发:桌面(Win/macOS) 发布到下载页 `/downloads/` + 应用内更新;**Android** APK 同样挂 `/downloads/`,需 `ANDROID_*` 签名 secrets(见 `docs/android-signing.md`);**iOS** 走 TestFlight,需 `IOS_*` / `APPSTORE_*` secrets(见 `docs/ios-signing.md`
- Android/iOS 的 job 在对应 secrets 未配置时**优雅跳过**(exit 0,不阻塞发版);本地无 `client/android/key.properties` 时 APK 自动回退 debug 签名
**归属与解耦**(关键):
- `version.yaml` 归 **client**(写 version/build_number/release_notes/下载链接/changelog);nginx/systemd 归 **server**。
- 后端 `/version` 与 `/api/v1/public/release` **每请求实时读** `version.yaml`,无缓存——client 部署 version.yaml 后立即生效,**不重启后端、不触发 server 流水线**。
- 官网下载页运行时 `fetch('/api/v1/public/release')`:版本徽章、各平台下载链接、**更新日志时间线**全部动态刷新——client 发版**无需重建官网**。接口不可达时回退到构建时静态内容(`web/_data/changelog.js` `CHANGELOG-client.md`
**CHANGELOG 格式**Keep a Changelog):
**CI 脚本**`scripts/ci/`):`lib-forgejo.sh`(公共函数)+ `compile-{client-web,site,backend,macos,android,ios,windows}.sh` + `release-{client,site,server}.sh` + `deploy-{client,site,server}.sh`。`compile-{macos,android,ios,windows}.sh` 去 `client-v` 前缀取版本。
**client 多平台构建矩阵**mac runner(容量 1)串行链 `build-client-web → build-macos → build-android → build-ios`windows runner 并行 `build-windows`Inno Setup);`release-deploy-client` 收齐后发布。Android 需 `ANDROID_*`、iOS 走 TestFlight 需 `IOS_*`/`APPSTORE_*` secrets,未配置时该 job 优雅跳过(exit 0,不阻塞)。
**手动回滚**`manual.yml` 输入带前缀 tag(如 `client-v1.0.54`),按前缀路由到对应 `deploy-<part>.sh`,从 Forgejo Release 下载产物。
**CHANGELOG 格式**Keep a Changelog,三个文件同构):
```markdown
## [1.0.2] - YYYY-MM-DD
@@ -289,7 +303,7 @@ CI/CDForgejo`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译
### 修复
- ...
```
只保留有内容的分类。`/release` 会检查 CHANGELOG 是否已有该版本节,没有则从 git log 自动生成。
只保留有内容的分类。`/release` 会检查对应 CHANGELOG 是否已有该版本节,没有则从该 part 上一个 tag 的 git log 自动生成。
### 异常上报
+2
View File
@@ -183,6 +183,7 @@ func (h *PublicHandler) GetRelease(c *gin.Context) {
"version": "1.0.0",
"release_notes": "",
"download_urls": gin.H{"web": "https://jiu.51yanmei.com/app"},
"changelog": []changelogEntry{},
})
return
}
@@ -190,6 +191,7 @@ func (h *PublicHandler) GetRelease(c *gin.Context) {
"version": cfg.Version,
"release_notes": cfg.ReleaseNotes,
"download_urls": cfg.DownloadURLs,
"changelog": changelogOrEmpty(cfg.Changelog),
})
}
+24
View File
@@ -17,6 +17,29 @@ type versionConfig struct {
ForceUpdate bool `yaml:"force_update"`
ReleaseNotes string `yaml:"release_notes"`
DownloadURLs map[string]string `yaml:"download_urls"`
// Changelog 由 client 发版脚本从 CHANGELOG-client.md 写入(最近 3 条),
// 供官网下载页运行时拉取渲染更新日志,无需重建官网。
Changelog []changelogEntry `yaml:"changelog"`
}
type changelogEntry struct {
Version string `yaml:"version" json:"version"`
Date string `yaml:"date" json:"date"`
Intro string `yaml:"intro" json:"intro"`
Sections []changelogSection `yaml:"sections" json:"sections"`
}
type changelogSection struct {
Type string `yaml:"type" json:"type"`
Items []string `yaml:"items" json:"items"`
}
// changelogOrEmpty 保证响应里 changelog 始终是数组(而非 null),方便前端遍历。
func changelogOrEmpty(c []changelogEntry) []changelogEntry {
if c == nil {
return []changelogEntry{}
}
return c
}
// GetVersion GET /version
@@ -33,6 +56,7 @@ func GetVersion(c *gin.Context) {
"force_update": cfg.ForceUpdate,
"release_notes": cfg.ReleaseNotes,
"download_urls": cfg.DownloadURLs,
"changelog": changelogOrEmpty(cfg.Changelog),
})
}
+11 -9
View File
@@ -36,7 +36,7 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机
### Workflow 标签
`.gitea/workflows/deploy.yml``ci.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`
`.gitea/workflows/deploy-{client,site,server}.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`
### 检查 Runner 状态
@@ -89,19 +89,21 @@ kill $(pgrep -f act_runner_start.sh)
仓库根目录 `.gitea/workflows/` 下有三个 workflow 文件:
### `deploy.yml` — 部署流程
### `deploy-{client,site,server}.yml` — 三条独立部署流程
**触发条件:** push `v*.*.*` tag
发版已拆成三条互不影响的流水线,按 tag 前缀触发:
**步骤:**
1. `compile.sh` — 编译 Go 后端(linux/amd64+ Flutter Web,打包到 `dist/`
2. `test.sh` `go test` + `flutter analyze`
3. `release.sh` — 解析 CHANGELOG.md,更新 version.yaml,创建 Forgejo Release,上传 3 个 asset
4. `deploy.sh` — SSH 到 EC2 部署,完成后发 Telegram 通知
| workflow | 触发 tag | 范围 |
|----------|---------|------|
| `deploy-client.yml` | `client-v*.*.*` | Flutter 全平台 + version.yaml |
| `deploy-site.yml` | `site-v*.*.*` | Eleventy 营销站 |
| `deploy-server.yml` | `server-v*.*.*` | Go 后端 + nginx |
**步骤(各自):** `compile-<part>*.sh` 编译打包 → `test.sh <part>` 门禁 → `release-<part>.sh` 创建 Forgejo Release → `deploy-<part>.sh` SSH 到 EC2 部署 → Telegram 通知。公共函数在 `scripts/ci/lib-forgejo.sh`
### `manual.yml` — 手动部署 / 回滚
**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入目标 tag(如 `v1.0.0`
**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入带前缀 tag(如 `client-v1.0.55`,按前缀路由到对应 `deploy-<part>.sh`
**步骤:** 只运行 `deploy.sh`,从 Forgejo Release 下载已有产物直接部署,跳过编译和测试。
+1 -1
View File
@@ -69,5 +69,5 @@ download_urls:
## 工作原理
- `scripts/ci/compile-ios.sh`:建临时 keychain 导入证书、安装 profile、生成 ExportOptionsmanual / app-store)、`flutter build ipa``xcrun altool --upload-app` 上传;CFBundleVersion 由版本号推导单调递增;缺 secrets 时跳过。
- `.gitea/workflows/deploy.yml``build-ios` jobmac runner,串行于 build-android。
- `.gitea/workflows/deploy-client.yml``build-ios` jobmac runner,串行于 build-android。
- 工程:`client/ios`bundle id `com.yanmei.jiu`,应用名「岩美酒库」)。
+2 -2
View File
@@ -6,7 +6,7 @@ set -euo pipefail
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
VER="${TAG#client-v}"
# versionCode 必须单调递增,否则 Android 无法覆盖升级安装。
# 由版本号推导:major*10000 + minor*100 + patch(如 1.0.17 -> 10017)。
@@ -43,7 +43,7 @@ cd client
flutter build apk --release \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
"--dart-define=APP_VERSION=v${VER}"
cd ..
# Locate the built APK (path differs across Flutter versions) and copy to dist/
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# compile-backend.sh <tag> — build the Linux backend binary + shared-infra
# configs.tar.gz into dist/. Server pipeline (server-v*). Does NOT touch
# version.yaml (that is client-owned) nor any Flutter/web artifact.
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
echo "==> compile-backend: tag=${TAG}"
# Build Go backend (linux/amd64 for EC2)
cd backend
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
cd ..
mkdir -p dist
mv backend/jiu-server dist/jiu-server
# Shared infrastructure (nginx/systemd/env/compose). version.yaml is NOT here —
# it belongs to the client pipeline.
tar -czf dist/configs.tar.gz \
deploy/nginx-jiu.conf \
deploy/jiu.service \
deploy/production.env.template \
deploy/setup-ec2.sh \
deploy/docker-compose.yml \
deploy/docker-compose.jiu.yml
echo "==> compile-backend: done — dist/ contents:"
ls -lh dist/
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# compile-client-web.sh <tag> — build the Flutter Web app into dist/web.tar.gz.
# Client pipeline (client-v*). Served at /app by nginx.
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#client-v}"
echo "==> compile-client-web: version=${VER}"
# Sync Flutter pubspec version (BSD sed on macOS)
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
cd client
flutter build web --release \
--base-href=/app/ \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=v${VER}"
cd ..
mkdir -p dist
tar -czf dist/web.tar.gz -C client/build web
echo "==> compile-client-web: done — dist/ contents:"
ls -lh dist/
+2 -2
View File
@@ -17,7 +17,7 @@ set -euo pipefail
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
VER="${TAG#client-v}"
# CFBundleVersionbuild 号)必须单调递增,否则 TestFlight 拒绝重复上传。
MAJOR="$(echo "$VER" | cut -d. -f1)"
@@ -105,7 +105,7 @@ flutter build ipa --release \
--export-options-plist="$WORK/ExportOptions.plist" \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
"--dart-define=APP_VERSION=v${VER}"
cd ..
IPA="$(ls client/build/ios/ipa/*.ipa | head -1)"
+2 -2
View File
@@ -6,7 +6,7 @@ set -euo pipefail
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
VER="${TAG#client-v}"
echo "==> compile-macos: version=${VER}"
@@ -18,7 +18,7 @@ cd client
flutter build macos --release \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
"--dart-define=APP_VERSION=v${VER}"
cd ..
# Package .app bundle into zip
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# compile-site.sh <tag> — build the Eleventy marketing site into
# dist/marketing.tar.gz. Site pipeline (site-v*). Marketing pages only —
# the web version of the app is built by compile-client-web.sh.
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
echo "==> compile-site: tag=${TAG}"
cd web
npm ci --prefer-offline
npm run build
cd ..
mkdir -p dist
tar -czf dist/marketing.tar.gz -C web/dist .
echo "==> compile-site: done — dist/ contents:"
ls -lh dist/
+2 -2
View File
@@ -6,7 +6,7 @@ set -euo pipefail
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
VER="${TAG#client-v}"
echo "==> compile-windows: version=${VER}"
@@ -41,7 +41,7 @@ flutter create --platforms=windows . --project-name jiu_client
flutter build windows --release \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
"--dart-define=APP_VERSION=v${VER}"
popd > /dev/null
# Package the Release folder into a Windows installer with Inno Setup (ISCC).
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env bash
# compile.sh <tag> — build backend + Flutter web, package into dist/
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
echo "==> compile: version=$VER"
# Sync Flutter pubspec version
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
# Build Go backend (linux/amd64 for EC2)
cd backend
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
cd ..
# Build Flutter Web
cd client
flutter build web --release \
--base-href=/app/ \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
cd ..
# Build marketing site
cd web
npm ci --prefer-offline
npm run build
cd ..
# Package artifacts
mkdir -p dist
mv backend/jiu-server dist/jiu-server
tar -czf dist/web.tar.gz -C client/build web
tar -czf dist/marketing.tar.gz -C web/dist .
echo "==> compile: done — dist/ contents:"
ls -lh dist/
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# deploy-client.sh <tag> — deploy the Flutter web app, desktop/mobile installers,
# and version.yaml to EC2. Does NOT restart the backend (version.yaml is read
# per-request) nor touch nginx / the marketing site.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> deploy-client: tag=${TAG}"
if [ ! -f dist/web.tar.gz ] || [ ! -f dist/version.yaml ]; then
download_release_assets "$TAG"
fi
echo "==> deploy-client: dist/ contents:"
ls -lh dist/
# Prefer the version.yaml released for this tag (carries bumped build_number +
# changelog); fall back to the checkout copy only if the asset is absent.
VERSION_YAML="dist/version.yaml"
[ -f "$VERSION_YAML" ] || VERSION_YAML="backend/config/version.yaml"
rm -rf /tmp/jiu-web-new
mkdir -p /tmp/jiu-web-new
tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1
setup_ssh
echo "==> deploy-client: uploading files to EC2"
${SCP} "$VERSION_YAML" "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml"
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/"
# Desktop / mobile installers (served from /downloads/ by nginx). Guarded —
# may be absent in a partial manual deploy.
[ -f dist/jiu-windows-x64-setup.exe ] && ${SCP} dist/jiu-windows-x64-setup.exe "${EC2_USER}@${EC2_HOST}:/tmp/jiu-windows-x64-setup.exe" || true
[ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${EC2_USER}@${EC2_HOST}:/tmp/jiu-macos-x64.zip" || true
[ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${EC2_USER}@${EC2_HOST}:/tmp/jiu-android.apk" || true
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
# Update version config (WorkingDirectory=/opt/jiu reads config/version.yaml).
# Backend re-reads it per request — no restart needed.
mkdir -p /opt/jiu/config
cp /tmp/version.yaml /opt/jiu/config/version.yaml
# Swap Flutter web app (atomic)
rm -rf /opt/jiu/web-old
mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true
mv /tmp/jiu-web-new /opt/jiu/web
# Publish installers to the nginx-served downloads dir; keep only the latest.
mkdir -p /opt/jiu/downloads
rm -f /opt/jiu/downloads/jiu-windows-* /opt/jiu/downloads/jiu-macos-* /opt/jiu/downloads/jiu-android-* /opt/jiu/downloads/jiu-android.apk 2>/dev/null || true
[ -f /tmp/jiu-windows-x64-setup.exe ] && mv -f /tmp/jiu-windows-x64-setup.exe /opt/jiu/downloads/ || true
[ -f /tmp/jiu-macos-x64.zip ] && mv -f /tmp/jiu-macos-x64.zip /opt/jiu/downloads/ || true
[ -f /tmp/jiu-android.apk ] && mv -f /tmp/jiu-android.apk /opt/jiu/downloads/ || true
echo "Client deploy complete!"
ENDSSH
teardown_ssh
rm -rf /tmp/jiu-web-new
echo "==> deploy-client: done"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# deploy-server.sh <tag> — deploy the backend binary + nginx config to EC2.
# Two modes: uses dist/ built in the same job, or downloads the release assets
# by tag (manual rollback). Does NOT touch the web app, marketing site, or
# version.yaml.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> deploy-server: tag=${TAG}"
if [ ! -f dist/jiu-server ] || [ ! -f dist/configs.tar.gz ]; then
download_release_assets "$TAG"
fi
echo "==> deploy-server: dist/ contents:"
ls -lh dist/
rm -rf /tmp/jiu-configs
mkdir -p /tmp/jiu-configs
tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs
setup_ssh
echo "==> deploy-server: uploading files to EC2"
${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server"
${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
# Replace backend binary
sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
# Start and health check
sudo systemctl start jiu
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -f http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
# Update jiu nginx config in the pangolin-edge reverse proxy (host-bind-mounted
# conf.d; reload nginx inside the container).
cp /tmp/nginx-jiu.conf /home/ec2-user/pangolin/edge/conf.d/jiu.conf
docker exec pangolin-edge nginx -t && docker exec pangolin-edge nginx -s reload
echo "Server deploy complete!"
ENDSSH
teardown_ssh
rm -rf /tmp/jiu-configs
echo "==> deploy-server: done"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# deploy-site.sh <tag> — deploy the Eleventy marketing site to EC2
# (/opt/jiu/marketing). Does NOT restart the backend or touch nginx.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> deploy-site: tag=${TAG}"
if [ ! -f dist/marketing.tar.gz ]; then
download_release_assets "$TAG"
fi
echo "==> deploy-site: dist/ contents:"
ls -lh dist/
rm -rf /tmp/jiu-marketing-new
mkdir -p /tmp/jiu-marketing-new
tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new
setup_ssh
echo "==> deploy-site: uploading marketing site to EC2"
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
echo "Site deploy complete!"
ENDSSH
teardown_ssh
rm -rf /tmp/jiu-marketing-new
echo "==> deploy-site: done"
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env bash
# deploy.sh <tag> — deploy release to EC2
# Works in two modes:
# 1. Automated (after compile.sh): uses dist/ built in the same job
# 2. Manual/rollback: downloads assets from Forgejo Release by tag
set -euo pipefail
TAG="$1"
echo "==> deploy: tag=${TAG}"
# Download from Forgejo if dist/ was not built in this job
if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ] || [ ! -f dist/marketing.tar.gz ]; then
echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo"
mkdir -p dist
curl -kf \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${TAG}" \
-o /tmp/release-info.json
python3 - <<'PYEOF'
import json, urllib.request, ssl, os, sys
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with open('/tmp/release-info.json') as f:
release = json.load(f)
token = os.environ['FORGEJO_TOKEN']
forgejo_url = os.environ['FORGEJO_URL']
repo = os.environ['GITEA_REPOSITORY']
release_id = release['id']
for asset in release.get('assets', []):
url = f"{forgejo_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset['id']}"
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
with urllib.request.urlopen(req, context=ctx) as resp:
with open(f"dist/{asset['name']}", 'wb') as out:
out.write(resp.read())
print(f"Downloaded: {asset['name']}")
PYEOF
fi
echo "==> deploy: dist/ contents:"
ls -lh dist/
# Extract configs (nginx, version.yaml, etc.)
rm -rf /tmp/jiu-configs
mkdir -p /tmp/jiu-configs
tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs
# Extract Flutter web build
rm -rf /tmp/jiu-web-new
mkdir -p /tmp/jiu-web-new
tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1
# Extract marketing site
rm -rf /tmp/jiu-marketing-new
mkdir -p /tmp/jiu-marketing-new
tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new
# Setup SSH
mkdir -p ~/.ssh
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
echo "==> deploy: uploading files to EC2"
# Upload backend binary
${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server"
# Upload version.yaml
${SCP} /tmp/jiu-configs/backend/config/version.yaml "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml"
# Upload nginx config
${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf"
# Upload Flutter web
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/"
# Upload marketing site (built by compile.sh)
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
# Upload desktop client installers (served from /downloads/ by nginx).
# Guarded: may be absent in a partial manual deploy.
[ -f dist/jiu-windows-x64-setup.exe ] && ${SCP} dist/jiu-windows-x64-setup.exe "${EC2_USER}@${EC2_HOST}:/tmp/jiu-windows-x64-setup.exe" || true
[ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${EC2_USER}@${EC2_HOST}:/tmp/jiu-macos-x64.zip" || true
[ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${EC2_USER}@${EC2_HOST}:/tmp/jiu-android.apk" || true
echo "==> deploy: running remote deploy"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
# Replace backend binary
sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
# Update version configWorkingDirectory=/opt/jiu,读 config/version.yaml
mkdir -p /opt/jiu/config
cp /tmp/version.yaml /opt/jiu/config/version.yaml
# Start and health check
sudo systemctl start jiu
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -f http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
# Swap Flutter web app (atomic)
rm -rf /opt/jiu/web-old
mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true
mv /tmp/jiu-web-new /opt/jiu/web
# Update marketing site
mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
# Publish desktop client installers to the nginx-served downloads dir.
# Keep only the latest version: clear old installers, then drop in the new ones.
mkdir -p /opt/jiu/downloads
rm -f /opt/jiu/downloads/jiu-windows-* /opt/jiu/downloads/jiu-macos-* /opt/jiu/downloads/jiu-android-* /opt/jiu/downloads/jiu-android.apk 2>/dev/null || true
[ -f /tmp/jiu-windows-x64-setup.exe ] && mv -f /tmp/jiu-windows-x64-setup.exe /opt/jiu/downloads/ || true
[ -f /tmp/jiu-macos-x64.zip ] && mv -f /tmp/jiu-macos-x64.zip /opt/jiu/downloads/ || true
[ -f /tmp/jiu-android.apk ] && mv -f /tmp/jiu-android.apk /opt/jiu/downloads/ || true
# Update jiu nginx config in the pangolin-edge reverse proxy.
# nginx now runs inside the `pangolin-edge` container (host networking), not on
# the host. Its /etc/nginx/conf.d is bind-mounted from the host dir below, so we
# write the config there and reload nginx *inside the container*.
cp /tmp/nginx-jiu.conf /home/ec2-user/pangolin/edge/conf.d/jiu.conf
docker exec pangolin-edge nginx -t && docker exec pangolin-edge nginx -s reload
echo "Deploy complete!"
ENDSSH
# Cleanup SSH key
rm -f ~/.ssh/ec2_deploy.pem
rm -rf /tmp/jiu-configs /tmp/jiu-web-new
echo "==> deploy: done"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# lib-forgejo.sh — shared CI helpers for the three release pipelines
# (client / site / server). `source` this from release-*.sh and deploy-*.sh.
#
# Provides:
# ver_from_tag <tag> -> strips client-v|site-v|server-v|v prefix
# extract_release_notes <file> -> prints the latest "## [x]" section body
# create_release <tag> <body> -> POSTs a Forgejo Release, sets RELEASE_ID
# upload_asset <file> -> uploads one asset (needs RELEASE_ID)
# download_release_assets <tag> -> downloads all assets of a release into dist/
# setup_ssh / teardown_ssh -> prepare/clean the EC2 deploy key (sets SSH/SCP)
#
# Requires env (where relevant): FORGEJO_URL, FORGEJO_TOKEN, GITEA_REPOSITORY,
# EC2_SSH_KEY, EC2_HOST, EC2_USER.
# Strip the part-specific tag prefix, leaving a bare semver (1.2.3).
ver_from_tag() {
local tag="$1"
tag="${tag#client-v}"
tag="${tag#site-v}"
tag="${tag#server-v}"
tag="${tag#v}"
printf '%s' "$tag"
}
# Print the body (lines) of the most recent "## [ver] - date" section.
extract_release_notes() {
python3 - "$1" <<'PYEOF'
import re, sys
fn = sys.argv[1]
try:
with open(fn, encoding='utf-8') as f:
content = f.read()
parts = re.split(r'\n## ', '\n' + content)
if len(parts) > 1:
lines = parts[1].strip().split('\n')
notes = []
for line in lines[1:]:
s = line.strip()
if s.startswith('## '):
break
if s:
notes.append(s)
print('\n'.join(notes) if notes else lines[0])
else:
print('')
except Exception:
print('')
PYEOF
}
# create_release <tag> <body_json_string> — sets global RELEASE_ID on success.
create_release() {
local tag="$1" body="$2" http_code resp
echo "==> release: creating Forgejo Release ${tag}"
http_code=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${tag}\",\"name\":\"Release ${tag}\",\"body\":${body},\"draft\":false,\"prerelease\":false}")
resp=$(cat /tmp/release_resp.json)
echo "==> release: API response (HTTP ${http_code}): ${resp}"
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "==> release: FAILED — HTTP ${http_code}" >&2
return 1
fi
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${resp}")
echo "==> release: release_id=${RELEASE_ID}"
}
# upload_asset <file> — requires RELEASE_ID.
upload_asset() {
local file="$1" code
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${file}")
echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then return 1; fi
}
# download_release_assets <tag> — pull every asset of the release into dist/.
download_release_assets() {
local tag="$1"
echo "==> deploy: downloading assets for ${tag} from Forgejo"
mkdir -p dist
curl -kf -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${tag}" \
-o /tmp/release-info.json
python3 - <<'PYEOF'
import json, urllib.request, ssl, os
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
rel = json.load(open('/tmp/release-info.json'))
token = os.environ['FORGEJO_TOKEN']
url = os.environ['FORGEJO_URL']
repo = os.environ['GITEA_REPOSITORY']
rid = rel['id']
for a in rel.get('assets', []):
u = f"{url}/api/v1/repos/{repo}/releases/{rid}/assets/{a['id']}"
req = urllib.request.Request(u, headers={'Authorization': f'token {token}'})
with urllib.request.urlopen(req, context=ctx) as r, open(f"dist/{a['name']}", 'wb') as o:
o.write(r.read())
print('Downloaded:', a['name'])
PYEOF
}
# setup_ssh — write the EC2 deploy key and export SSH / SCP commands.
setup_ssh() {
mkdir -p ~/.ssh
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
}
teardown_ssh() {
rm -f ~/.ssh/ec2_deploy.pem
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# release-client.sh <tag> — client pipeline release.
# 1. Update backend/config/version.yaml: version / build_number / release_notes
# / macos+windows+android download URLs / changelog[] (latest 3 from
# CHANGELOG-client.md).
# 2. Create the Forgejo Release client-vX and upload the app artifacts +
# version.yaml (so manual rollback can re-fetch the exact manifest).
#
# version.yaml is client-owned: the backend reads it per-request, so deploying
# it does NOT require a backend restart or a server-pipeline run.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
VER="$(ver_from_tag "$TAG")"
echo "==> release-client: tag=${TAG} version=${VER}"
NOTES=$(extract_release_notes CHANGELOG-client.md)
echo "==> release-client: release_notes=${NOTES}"
# --- Update version.yaml (scalars + download URLs) and append changelog[] ---
python3 - "$VER" "$NOTES" "CHANGELOG-client.md" <<'PYEOF'
import sys, re, json
ver = sys.argv[1]
notes = sys.argv[2]
changelog_file = sys.argv[3]
base = "https://jiu.51yanmei.com/downloads"
mac_url = f"{base}/jiu-macos-x64.zip"
win_url = f"{base}/jiu-windows-x64-setup.exe"
android_url = f"{base}/jiu-android.apk"
# Read current version.yaml, dropping any existing trailing `changelog:` block
# (changelog is always emitted last, so everything from it to EOF is regenerated).
with open('backend/config/version.yaml', encoding='utf-8') as f:
raw = f.read()
raw = re.split(r'\nchangelog:', raw, maxsplit=1)[0].rstrip('\n') + '\n'
out = []
for line in raw.splitlines(keepends=True):
if line.startswith('version:'):
out.append('version: "%s"\n' % ver)
elif line.startswith('build_number:'):
try:
b = int(line.split(':', 1)[1].strip()) + 1
except Exception:
b = 1
out.append('build_number: %d\n' % b)
elif line.startswith('release_notes:'):
safe = notes.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '')
out.append('release_notes: "%s"\n' % safe)
elif line.startswith(' macos:'):
out.append(' macos: "%s"\n' % mac_url)
elif line.startswith(' windows:'):
out.append(' windows: "%s"\n' % win_url)
elif line.startswith(' android:'):
out.append(' android: "%s"\n' % android_url)
else:
out.append(line)
# Parse CHANGELOG-client.md -> latest 3 entries (same shape as web/_data/changelog.js)
versions = []
cur = None
sec = None
for line in open(changelog_file, encoding='utf-8'):
line = line.rstrip('\n')
m = re.match(r'^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})', line)
if m:
if cur:
versions.append(cur)
if len(versions) >= 3:
cur = None
break
cur = {'version': m.group(1), 'date': m.group(2), 'intro': '', 'sections': []}
sec = None
continue
ms = re.match(r'^### (.+)', line)
if ms and cur is not None:
sec = {'type': ms.group(1).strip(), 'items': []}
cur['sections'].append(sec)
continue
mi = re.match(r'^- (.+)', line)
if mi and cur is not None and sec is not None:
sec['items'].append(mi.group(1).strip())
continue
if line.strip() and cur is not None and sec is None and not line.startswith('#'):
cur['intro'] = (cur['intro'] + ' ' + line.strip()).strip()
if cur is not None and len(versions) < 3:
versions.append(cur)
# Append changelog as a JSON array (valid YAML flow style — no pyyaml needed).
text = ''.join(out).rstrip('\n') + '\n'
text += 'changelog: ' + json.dumps(versions, ensure_ascii=False) + '\n'
with open('backend/config/version.yaml', 'w', encoding='utf-8') as f:
f.write(text)
print('version.yaml updated to', ver, '|', len(versions), 'changelog entries')
PYEOF
# Stage version.yaml as a release asset so manual rollback can re-fetch it.
mkdir -p dist
cp backend/config/version.yaml dist/version.yaml
BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF'
import sys, json
print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2]))
PYEOF
)
create_release "$TAG" "$BODY"
upload_asset dist/web.tar.gz
upload_asset dist/version.yaml
[ -f dist/jiu-macos-x64.zip ] && upload_asset dist/jiu-macos-x64.zip || true
[ -f dist/jiu-windows-x64-setup.exe ] && upload_asset dist/jiu-windows-x64-setup.exe || true
[ -f dist/jiu-android.apk ] && upload_asset dist/jiu-android.apk || true
echo "==> release-client: done — Release ${TAG} created"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# release-server.sh <tag> — create the Forgejo Release for the backend pipeline
# and upload jiu-server + configs.tar.gz.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> release-server: tag=${TAG}"
NOTES=$(extract_release_notes CHANGELOG-server.md)
echo "==> release-server: release_notes=${NOTES}"
BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF'
import sys, json
print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2]))
PYEOF
)
create_release "$TAG" "$BODY"
upload_asset dist/jiu-server
upload_asset dist/configs.tar.gz
echo "==> release-server: done — Release ${TAG} created"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# release-site.sh <tag> — create the Forgejo Release for the marketing-site
# pipeline and upload marketing.tar.gz.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh"
TAG="$1"
echo "==> release-site: tag=${TAG}"
NOTES=$(extract_release_notes CHANGELOG-site.md)
echo "==> release-site: release_notes=${NOTES}"
BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF'
import sys, json
print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2]))
PYEOF
)
create_release "$TAG" "$BODY"
upload_asset dist/marketing.tar.gz
echo "==> release-site: done — Release ${TAG} created"
-141
View File
@@ -1,141 +0,0 @@
#!/usr/bin/env bash
# release.sh <tag> — update version.yaml, package configs, create Forgejo Release
set -euo pipefail
TAG="$1"
VER="${TAG#v}"
echo "==> release: tag=${TAG} version=${VER}"
# Extract release notes from CHANGELOG.md (first summary line of the latest section)
RELEASE_NOTES=$(python3 - <<'PYEOF'
import re, sys
try:
with open('CHANGELOG.md') as f:
content = f.read()
parts = re.split(r'\n## ', '\n' + content)
if len(parts) > 1:
section = parts[1]
lines = section.strip().split('\n')
notes = []
for line in lines[1:]:
stripped = line.strip()
# 只在遇到同级 ## 时停止,### 子标题保留
if stripped.startswith('## '):
break
if stripped:
notes.append(stripped)
print('\n'.join(notes) if notes else lines[0])
else:
print('')
except Exception as e:
print('', file=sys.stderr)
print('')
PYEOF
)
echo "==> release: release_notes=${RELEASE_NOTES}"
# Update backend/config/version.yaml (version, build_number, release_notes, macos/windows URL)
python3 - "${VER}" "${RELEASE_NOTES}" "${TAG}" "${FORGEJO_URL}" "${GITEA_REPOSITORY}" <<'PYEOF'
import sys
ver = sys.argv[1]
notes = sys.argv[2]
tag = sys.argv[3]
furl = sys.argv[4].rstrip('/')
repo = sys.argv[5]
# Public download URLs are served by jiu.51yanmei.com (same-origin HTTPS via the
# pangolin nginx /downloads/ location), NOT the LAN-only HTTP Forgejo instance —
# otherwise the HTTPS download page hands the browser an http://192.168.x URL and
# Chrome blocks it as insecure / it's unreachable from the public internet.
base = "https://jiu.51yanmei.com/downloads"
mac_url = f"{base}/jiu-macos-x64.zip"
win_url = f"{base}/jiu-windows-x64-setup.exe"
android_url = f"{base}/jiu-android.apk"
lines = []
with open('backend/config/version.yaml') as f:
for line in f:
if line.startswith('version:'):
lines.append('version: "' + ver + '"\n')
elif line.startswith('build_number:'):
try:
build = int(line.split(':', 1)[1].strip()) + 1
except Exception:
build = 1
lines.append('build_number: ' + str(build) + '\n')
elif line.startswith('release_notes:'):
lines.append('release_notes: "' + notes.replace('\\', '\\\\').replace('"', '\\"') + '"\n')
elif line.startswith(' macos:'):
lines.append(' macos: "' + mac_url + '"\n')
elif line.startswith(' windows:'):
lines.append(' windows: "' + win_url + '"\n')
elif line.startswith(' android:'):
lines.append(' android: "' + android_url + '"\n')
else:
lines.append(line)
with open('backend/config/version.yaml', 'w') as f:
f.writelines(lines)
print('version.yaml updated to', ver, '| macos:', mac_url, '| windows:', win_url, '| android:', android_url)
PYEOF
# Package configs.tar.gz (includes updated version.yaml)
tar -czf dist/configs.tar.gz \
deploy/nginx-jiu.conf \
deploy/jiu.service \
deploy/production.env.template \
deploy/setup-ec2.sh \
deploy/docker-compose.yml \
deploy/docker-compose.jiu.yml \
backend/config/version.yaml
echo "==> release: creating Forgejo Release ${TAG}"
# Build release body from CHANGELOG excerpt
BODY=$(python3 - "${TAG}" "${RELEASE_NOTES}" <<'PYEOF'
import sys, json
tag = sys.argv[1]
notes = sys.argv[2]
body = '## ' + tag + '\n\n' + notes
print(json.dumps(body))
PYEOF
)
HTTP_CODE=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Release ${TAG}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")
RESP=$(cat /tmp/release_resp.json)
echo "==> release: API response (HTTP ${HTTP_CODE}): ${RESP}"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "==> release: FAILED — HTTP ${HTTP_CODE}"
exit 1
fi
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}")
echo "==> release: release_id=${RELEASE_ID}"
# Upload assets
upload_asset() {
local file="$1"
local code
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${file}")
echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then exit 1; fi
}
upload_asset dist/jiu-server
upload_asset dist/web.tar.gz
upload_asset dist/marketing.tar.gz
upload_asset dist/configs.tar.gz
upload_asset dist/jiu-macos-x64.zip
upload_asset dist/jiu-windows-x64-setup.exe
upload_asset dist/jiu-android.apk
echo "==> release: done — Release ${TAG} created"
+26 -9
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env bash
# test.sh — run backend and frontend checks
# test.sh [part] — run gate checks for a pipeline part.
# server -> go test
# client -> flutter analyze
# (none) -> both (backwards compatible)
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
@@ -7,14 +10,28 @@ export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}"
export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}"
echo "==> test: go test"
cd backend
go test ./...
cd ..
PART="${1:-all}"
echo "==> test: flutter analyze"
cd client
flutter analyze --no-fatal-infos --no-fatal-warnings
cd ..
run_server() {
echo "==> test: go test"
cd backend
go test ./...
cd ..
}
run_client() {
echo "==> test: flutter analyze"
cd client
flutter analyze --no-fatal-infos --no-fatal-warnings
cd ..
}
case "$PART" in
server) run_server ;;
client) run_client ;;
site) echo "==> test: site has no test gate (build is the gate)" ;;
all) run_server; run_client ;;
*) echo "unknown part: $PART" >&2; exit 1 ;;
esac
echo "==> test: done"
+1 -1
View File
@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
module.exports = function() {
const raw = fs.readFileSync(path.join(__dirname, '../../CHANGELOG.md'), 'utf8');
const raw = fs.readFileSync(path.join(__dirname, '../../CHANGELOG-client.md'), 'utf8');
const lines = raw.split('\n');
const versions = [];
let current = null;
+3 -4
View File
@@ -72,7 +72,7 @@
<div class="docs-version">
<i data-lucide="tag" class="icon"></i>
<span class="docs-version-label">版本</span>
<span>v1.0.23</span>
<span>v1.0.54</span>
</div>
</div>
</div>
@@ -840,6 +840,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -872,14 +873,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+89 -66
View File
@@ -308,7 +308,7 @@
<div class="container ph-inner">
<div>
<div class="ph-eyebrow">
<span class="version-badge">v1.0.23</span>
<span class="version-badge">v1.0.54</span>
<span>下载岩美</span>
</div>
<h1 class="ph-title">随时随地,<br/>轻松管理酒库。</h1>
@@ -318,11 +318,11 @@
<div class="ph-meta">
<div class="ph-meta-item">
<div class="ph-meta-label">最新版本</div>
<div class="ph-meta-val version-badge">v1.0.23</div>
<div class="ph-meta-val version-badge">v1.0.54</div>
</div>
<div class="ph-meta-item">
<div class="ph-meta-label">发布日期</div>
<div class="ph-meta-val">2026-06-07</div>
<div class="ph-meta-val">2026-06-17</div>
</div>
<div class="ph-meta-item">
<div class="ph-meta-label">可用平台</div>
@@ -545,14 +545,14 @@
<section class="section">
<div class="container">
<h2 class="fs-3xl fw-6 text-brand-900 m-0 mb-6" style="letter-spacing: var(--tracking-cn-display);">版本更新日志</h2>
<p class="fs-md text-muted m-0">最近 3 个版本。</p>
<p class="fs-md text-muted m-0" id="changelog-count">最近 3 个版本。</p>
<div class="changelog">
<div class="changelog" id="changelog-list">
<div class="changelog-entry current">
<div class="changelog-meta">
<div class="changelog-version">v1.0.23</div>
<div class="changelog-date">2026-06-07</div>
<div class="changelog-version">v1.0.54</div>
<div class="changelog-date">2026-06-17</div>
<span class="changelog-tag">当前版本</span>
</div>
<div class="changelog-body">
@@ -561,12 +561,16 @@
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-fix"></span>问题修复
<span class="dot dot-feature"></span>新功能
</div>
<ul>
<li>官网功能详情页(库存管理/审核流)导航栏现与主站完全一致,不再缺少「库存」「审核流」入口</li>
<li>只读账号自动隐藏所有写操作按钮(新增/编辑/删除/审核/提交/结清/导入等),顶栏显示「只读」标识,权限一目了然</li>
<li>左侧抽屉和系统设置页新增「退出登录」入口,窄屏也能方便退出</li>
<li>酒行信息新增「微信号」显示</li>
</ul>
</div>
@@ -579,47 +583,9 @@
</div>
<ul>
<li>官网功能详情页接入统一模板系统,导航/页脚/版权信息改一处自动同步全站</li>
<li>网络请求失败时自动重试(间隔递增),离线时提供带状态反馈的「重试」按钮</li>
</ul>
</div>
</div>
</div>
<div class="changelog-entry">
<div class="changelog-meta">
<div class="changelog-version">v1.0.22</div>
<div class="changelog-date">2026-06-07</div>
</div>
<div class="changelog-body">
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-feature"></span>新功能
</div>
<ul>
<li>官网移动端适配:手机浏览器下导航折叠、首页各区块单列排版、features 详情页和页脚自适应窄屏</li>
</ul>
</div>
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-improve"></span>改进
</div>
<ul>
<li>客户端移动端布局优化:入库/出库详情页商品明细改为卡片式竖排,操作按钮收纳至溢出菜单,窄屏不再横向溢出</li>
<li>信息架构整理:仓库管理移入基础数据,系统设置新增授权 Tab 和数据管理组,关于我们补充帮助文档/法律信息/系统信息入口</li>
<li>iOS 顶栏避开状态栏,菜单按钮不再被时间/信号遮挡</li>
</ul>
</div>
@@ -632,17 +598,9 @@
</div>
<ul>
<li>官网所有功能页死链修正(`/download.html` → `/download/``/docs.html` → `/docs/`</li>
<li>库存导入改用 Excel 中的「入库日期」作为入库时间(不再统一记为导入当天);相同数据重复导入时自动跳过,仅更新有变化的字段,避免重复与误覆盖</li>
<li>官网删除未实现功能的虚假宣传(红冲、调拨单、库存推送通知、过期预警等),文案与实际功能对齐</li>
<li>官网客服邮箱更新为真实邮箱,移除假冒客服电话,页脚清除无目标死链</li>
<li>导航新增「库存」「审核流」功能页入口,首页模块卡加「了解更多」链接</li>
<li>下载页接口失败时按钮改为禁用态并提示,不再无响应</li>
<li>首页 iOS 平台说明改为「TestFlight 内测中」,与下载页状态一致</li>
<li>修复 iOS 上顶栏被状态栏遮挡导致无法打开菜单/退出登录的问题</li>
</ul>
</div>
@@ -652,8 +610,8 @@
<div class="changelog-entry">
<div class="changelog-meta">
<div class="changelog-version">v1.0.21</div>
<div class="changelog-date">2026-06-07</div>
<div class="changelog-version">v1.0.53</div>
<div class="changelog-date">2026-06-15</div>
</div>
<div class="changelog-body">
@@ -662,12 +620,37 @@
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-feature"></span>新功能
<span class="dot dot-fix"></span>问题修复
</div>
<ul>
<li>商品详情页添加照片时,手机端(Android/iOS)支持直接拍照或从相册选择</li>
<li>修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)</li>
</ul>
</div>
</div>
</div>
<div class="changelog-entry">
<div class="changelog-meta">
<div class="changelog-version">v1.0.52</div>
<div class="changelog-date">2026-06-15</div>
</div>
<div class="changelog-body">
<div class="changelog-cat">
<div class="changelog-cat-label">
<span class="dot dot-improve"></span>改进
</div>
<ul>
<li>热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题</li>
</ul>
</div>
@@ -747,6 +730,46 @@
});
}
// 用 /api/v1/public/release 返回的 changelog 重渲染更新日志时间线,使客户端
// 发版无需重建官网即可同步。结构与构建时 web/_data/changelog.js 一致;接口
// 不可达或字段缺失时保留构建时静态时间线作 fallback。
function renderChangelog(entries) {
if (!Array.isArray(entries) || entries.length === 0) return;
var dotClass = { '新功能': 'dot-feature', '改进': 'dot-improve', '修复': 'dot-fix' };
var label = { '修复': '问题修复' };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function(c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
var html = '';
entries.forEach(function(entry, i) {
var current = i === 0;
html += '<div class="changelog-entry' + (current ? ' current' : '') + '">';
html += '<div class="changelog-meta">';
html += '<div class="changelog-version">v' + esc(entry.version) + '</div>';
html += '<div class="changelog-date">' + esc(entry.date) + '</div>';
if (current) html += '<span class="changelog-tag">当前版本</span>';
html += '</div><div class="changelog-body">';
if (entry.intro) html += '<p class="changelog-intro">' + esc(entry.intro) + '</p>';
(entry.sections || []).forEach(function(section) {
var dot = dotClass[section.type] || 'dot-improve';
var name = label[section.type] || section.type;
html += '<div class="changelog-cat"><div class="changelog-cat-label">';
html += '<span class="dot ' + dot + '"></span>' + esc(name) + '</div><ul>';
(section.items || []).forEach(function(item) {
html += '<li>' + esc(item) + '</li>';
});
html += '</ul></div>';
});
html += '</div></div>';
});
var list = document.getElementById('changelog-list');
if (list) list.innerHTML = html;
var count = document.getElementById('changelog-count');
if (count) count.textContent = '最近 ' + entries.length + ' 个版本。';
}
document.addEventListener('DOMContentLoaded', function() {
var detected = detectPlatform();
updateDetectCard(detected);
@@ -756,6 +779,7 @@
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && data.version) updateVersionBadges(data.version);
if (data && data.changelog) renderChangelog(data.changelog);
if (data && data.download_urls && data.download_urls.macos) {
platforms.macos.btnHref = data.download_urls.macos;
var macBtn = document.getElementById('macos-dl-btn');
@@ -822,6 +846,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -854,14 +879,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+2 -3
View File
@@ -670,6 +670,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -702,14 +703,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+2 -3
View File
@@ -707,6 +707,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -739,14 +740,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+5 -7
View File
@@ -240,8 +240,8 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<div class="container hero-inner">
<div>
<div class="d-inline-flex items-center gap-8 bg-0 border rounded-pill fs-sm text-gray-7 mb-24" style="padding: 6px 14px 6px 8px;">
<span class="badge badge-brand">v1.0.23</span>
<span>2026-06-07 更新 ·</span>
<span class="badge badge-brand">v1.0.54</span>
<span>2026-06-17 更新 ·</span>
<span class="text-brand fw-5">查看更新</span>
</div>
<h1>从入库到出库,<br/><em>一套系统</em>管到底。</h1>
@@ -605,7 +605,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>单门店 · 最多 3 用户</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>全部模块开放</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>Web 端 + 移动端</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>邮件技术支持</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>微信技术支持</li>
</ul>
<a href="/register/" class="btn btn-secondary w-full justify-center mt-auto">免费开通</a>
</div>
@@ -703,6 +703,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -715,7 +716,6 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<li><a href="/#reports">数据洞察</a></li>
<li><a href="/#pricing">价格方案</a></li>
<li><a href="/download/">版本更新</a></li>
<li><a href="/changelog/">更新日志</a></li>
</ul>
</div>
<div class="footer-col">
@@ -736,14 +736,12 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+4 -5
View File
@@ -210,7 +210,7 @@
</table>
</div>
<p class="success-note">如忘记门店编码,登录后可在「系统设置 → 关于」中查看。</p>
<p class="success-note">如忘记门店编码,登录后可在「系统设置」中查看。</p>
<a href="/app/" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:8px;">
<i data-lucide="log-in" class="icon"></i>前往登录
</a>
@@ -221,7 +221,7 @@
<script>
(function() {
var API_URL = window.location.origin + '/api/v1/public/register';
var API_URL = "https://jiu.51yanmei.com/api/v1/public/register";
function $(id) { return document.getElementById(id); }
@@ -383,6 +383,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div><a href="mailto:yammy2023@163.com">yammy2023@163.com</a></div>
</div>
@@ -415,14 +416,12 @@
<h5>公司</h5>
<ul>
<li><a href="/register/">注册门店</a></li>
<li><a href="/terms/">服务条款</a></li>
<li><a href="/privacy/">隐私政策</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 岩美科技 · 保留所有权利</div>
<div>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</div>
</div>
</div>
</footer>
+43 -2
View File
@@ -390,9 +390,9 @@ pageStyle: |
<section class="section">
<div class="container">
<h2 class="fs-3xl fw-6 text-brand-900 m-0 mb-6" style="letter-spacing: var(--tracking-cn-display);">版本更新日志</h2>
<p class="fs-md text-muted m-0">最近 {{ changelog | length }} 个版本。</p>
<p class="fs-md text-muted m-0" id="changelog-count">最近 {{ changelog | length }} 个版本。</p>
<div class="changelog">
<div class="changelog" id="changelog-list">
{% for entry in changelog %}
<div class="changelog-entry{% if loop.first %} current{% endif %}">
<div class="changelog-meta">
@@ -499,6 +499,46 @@ pageStyle: |
});
}
// 用 /api/v1/public/release 返回的 changelog 重渲染更新日志时间线,使客户端
// 发版无需重建官网即可同步。结构与构建时 web/_data/changelog.js 一致;接口
// 不可达或字段缺失时保留构建时静态时间线作 fallback。
function renderChangelog(entries) {
if (!Array.isArray(entries) || entries.length === 0) return;
var dotClass = { '新功能': 'dot-feature', '改进': 'dot-improve', '修复': 'dot-fix' };
var label = { '修复': '问题修复' };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function(c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
var html = '';
entries.forEach(function(entry, i) {
var current = i === 0;
html += '<div class="changelog-entry' + (current ? ' current' : '') + '">';
html += '<div class="changelog-meta">';
html += '<div class="changelog-version">v' + esc(entry.version) + '</div>';
html += '<div class="changelog-date">' + esc(entry.date) + '</div>';
if (current) html += '<span class="changelog-tag">当前版本</span>';
html += '</div><div class="changelog-body">';
if (entry.intro) html += '<p class="changelog-intro">' + esc(entry.intro) + '</p>';
(entry.sections || []).forEach(function(section) {
var dot = dotClass[section.type] || 'dot-improve';
var name = label[section.type] || section.type;
html += '<div class="changelog-cat"><div class="changelog-cat-label">';
html += '<span class="dot ' + dot + '"></span>' + esc(name) + '</div><ul>';
(section.items || []).forEach(function(item) {
html += '<li>' + esc(item) + '</li>';
});
html += '</ul></div>';
});
html += '</div></div>';
});
var list = document.getElementById('changelog-list');
if (list) list.innerHTML = html;
var count = document.getElementById('changelog-count');
if (count) count.textContent = '最近 ' + entries.length + ' 个版本。';
}
document.addEventListener('DOMContentLoaded', function() {
var detected = detectPlatform();
updateDetectCard(detected);
@@ -508,6 +548,7 @@ pageStyle: |
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && data.version) updateVersionBadges(data.version);
if (data && data.changelog) renderChangelog(data.changelog);
if (data && data.download_urls && data.download_urls.macos) {
platforms.macos.btnHref = data.download_urls.macos;
var macBtn = document.getElementById('macos-dl-btn');