Compare commits

..

37 Commits

Author SHA1 Message Date
wangjia c001f0e8e6 feat(client): Windows 提供 Inno Setup 安装程序 + 下载改同源 HTTPS
Deploy / build-windows (push) Failing after 22s
Deploy / build-linux-web (push) Failing after 14m4s
Deploy / build-macos (push) Has been skipped
Deploy / release-deploy (push) Has been skipped
- Windows 客户端打成 jiu-windows-x64-setup.exe(Inno Setup,中文向导、
  桌面/开始菜单快捷方式、卸载项),替代解压版 zip
- provision-windows.sh 自动静默安装 Inno Setup(缺则装)
- 下载 URL 从内网 HTTP Forgejo 改为 https://jiu.51yanmei.com/downloads/,
  修复 HTTPS 页面下发 http 内网地址被浏览器拦截(无法安全下载),win/mac 均生效
- deploy.sh 将安装包发布到 EC2 /opt/jiu/downloads/,nginx 新增 /downloads/ 路由

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 07:46:41 +08:00
wangjia f039bd0c61 fix(ci): deploy 适配容器化 nginx(pangolin-edge)
Deploy / build-windows (push) Failing after 25s
Deploy / build-linux-web (push) Successful in 1m14s
Deploy / build-macos (push) Successful in 1m12s
Deploy / release-deploy (push) Has been skipped
nginx 已从主机迁入 pangolin-edge 容器(host 网络模式),其
/etc/nginx/conf.d 由主机 ~/pangolin/edge/conf.d bind-mount。
deploy 改为:jiu.conf 写入该挂载目录 + docker exec pangolin-edge
nginx -t/-s reload,不再 cp 到主机 /etc/nginx 或 systemctl 操作
已废弃的主机 nginx。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 07:20:50 +08:00
wangjia f4beb90769 fix(ci): nginx reload 改用 systemctl reload-or-restart,避免空 PID 文件报错
Deploy / build-linux-web (push) Successful in 1m1s
Deploy / build-windows (push) Successful in 1m42s
Deploy / build-macos (push) Successful in 1m2s
Deploy / release-deploy (push) Failing after 1m17s
nginx -s reload 依赖 /run/nginx.pid,该文件为空时报 invalid PID number。
改用 systemctl reload-or-restart(经 systemd MAINPID 发 HUP,运行中则 reload、
未运行则启动),并 enable nginx 保证重启后自启。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:33:24 +08:00
wangjia 6f0b273950 fix(ci): Windows 构建清除 cp 复制的插件软链,避免 PathExists(errno 183)
Deploy / build-windows (push) Successful in 1m31s
Deploy / build-linux-web (push) Successful in 55s
Deploy / build-macos (push) Successful in 58s
Deploy / release-deploy (push) Failing after 1m20s
cp -r 把 windows/flutter/ephemeral/.plugin_symlinks 复制成真实目录,
flutter createPluginSymlinks 只清软链、清不掉真目录,重建时撞车。
复制后删除 ephemeral/.dart_tool/build,由 flutter 干净重建。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:06:36 +08:00
wangjia b43601b4fd ci: 运行环境自愈预置 + 发版任务串行 + 删 ci.yml/暂停备份
Deploy / build-windows (push) Failing after 25s
Deploy / build-macos (push) Has been cancelled
Deploy / release-deploy (push) Has been cancelled
Deploy / build-linux-web (push) Has been cancelled
- 新增 scripts/ci/provision-{mac,windows}.sh:幂等预置,按 Flutter 版本
  stamp 短路,pipeline 首跑自动 precache + 预热缓存,运行时不再下载 engine
- scripts/ci/_env.sh:抽出共用镜像 env,compile 脚本统一 source
- deploy.yml:build-macos needs build-linux-web,同一 mac runner 串行
  执行,避免排队超时;各 build job 加 Provision 前置步骤
- 删除 .gitea/workflows/ci.yml
- backup.yml:暂停定时备份(保留 workflow_dispatch 手动触发)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 01:04:23 +08:00
wangjia 3aa63d6575 fix(ci): Windows 构建改用 C:\jiu-build 短路径,弃用 subst W: 映射
Deploy / build-windows (push) Successful in 1m41s
Deploy / build-linux-web (push) Failing after 14m36s
Deploy / build-macos (push) Successful in 33m24s
Deploy / release-deploy (push) Has been skipped
subst W: 因 cmd/MSYS 引号转义失败(报"找不到路径"),导致 W: 盘
未创建、cd /w/client 失败。改为将 client/ 复制到 System32 之外的
C:\jiu-build 构建,规避 WOW64 重定向,并用 PowerShell Compress-Archive 打包。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 00:23:09 +08:00
wangjia a3f9f091b4 fix(ci): Windows 构建用 subst W: 绕过 System32 WOW64 路径重定向
Deploy / build-linux-web (push) Successful in 1m6s
Deploy / build-windows (push) Failing after 16s
Deploy / release-deploy (push) Has been cancelled
Deploy / build-macos (push) Has been cancelled
2026-06-01 01:19:02 +08:00
wangjia b3f5a0f083 debug(ci): 添加 Windows 构建目录诊断输出
Deploy / build-windows (push) Failing after 30s
Deploy / build-linux-web (push) Successful in 1m2s
Deploy / build-macos (push) Failing after 14m5s
Deploy / release-deploy (push) Has been skipped
2026-06-01 01:13:48 +08:00
wangjia 0af92e1732 fix(ci): Windows 构建前重新生成 windows/ 平台目录,避免 CMake 路径不存在
Deploy / build-windows (push) Failing after 28s
Deploy / build-linux-web (push) Successful in 1m11s
Deploy / build-macos (push) Failing after 10m10s
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:11:47 +08:00
wangjia ea5526944b feat(client): 添加 Flutter Windows 桌面平台支持
Deploy / build-macos (push) Successful in 52s
Deploy / build-windows (push) Failing after 4m27s
Deploy / release-deploy (push) Has been cancelled
Deploy / build-linux-web (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 08:44:31 +08:00
wangjia c2f8bda4f3 chore(ci): 移除 Windows pipeline 中的 Git 安装步骤
Deploy / build-linux-web (push) Successful in 1m8s
Deploy / build-macos (push) Successful in 50s
Deploy / build-windows (push) Failing after 38s
Deploy / release-deploy (push) Has been skipped
Git 已在 Windows runner 上手动安装并加入系统 PATH,无需每次 CI 安装。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 00:17:43 +08:00
wangjia 7f6584ab64 fix(ci): 改从 TUNA 镜像安装 Git,避免访问 GitHub
Deploy / build-windows (push) Failing after 17s
Deploy / build-linux-web (push) Successful in 54s
Deploy / build-macos (push) Successful in 50s
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 00:07:47 +08:00
wangjia 23de243598 fix(ci): Windows 安装 Git 改用 cmd,避免 PowerShell 执行策略限制
Deploy / build-windows (push) Failing after 18s
Deploy / build-macos (push) Successful in 56s
Deploy / build-linux-web (push) Failing after 12m28s
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 00:01:49 +08:00
wangjia 65e2021138 fix: 修复打印函数缺少 try 块导致 macOS 编译失败;CI 自动安装 Git bash
Deploy / build-windows (push) Failing after 14s
Deploy / build-linux-web (push) Successful in 53s
Deploy / build-macos (push) Successful in 52s
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:58:05 +08:00
wangjia d94c6b109b chore: release v1.0.4
Deploy / build-windows (push) Failing after 19s
Deploy / build-linux-web (push) Successful in 51s
Deploy / build-macos (push) Failing after 32s
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:53:51 +08:00
wangjia 75e3b934bc chore: release v1.0.3
Deploy / build-windows (push) Failing after 9s
Deploy / build-linux-web (push) Successful in 51s
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:39:04 +08:00
wangjia ec7596e272 fix(deploy): version.yaml 路径改为 /opt/jiu/config/(与 WorkingDirectory 一致) 2026-05-26 21:34:14 +08:00
wangjia 9fc379f33a chore: release v1.0.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:27:24 +08:00
wangjia ee826eca7f fix(print): 商品标签改为竖向 20mm×38mm 布局
原设计 4in×2in 横向与实际标签纸不符,导致内容旋转 90°。按实测尺寸重设为竖向 20mm×38mm,重新排布:酒行名顶部深色背景→商品名→QR码→规格/批次/生产日期→联系方式底部。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:20:31 +08:00
wangjia 51863dcbd3 feat(deploy): 新增 Windows 桌面客户端构建与发布支持
CI 拆成三个 job(mac 构建 Linux/Web、windows 构建 Windows 桌面、mac 发布部署);新增 compile-windows.sh 用 Flutter 构建并打包为 jiu-windows-x64.zip;release.sh 自动写入 Windows 下载 URL 到 version.yaml 并上传 zip。下载页 Windows 卡片从"敬请期待"改为真实下载按钮,URL 由 API 动态填充。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:29:17 +08:00
wangjia eda7d37b64 feat: 新增用户自助注册功能
后端新增 POST /api/v1/public/register 接口,支持门店自助注册并自动生成门店编码(S000001 格式);shops 表加 description 字段。营销站新增 /register/ 注册页,含门店信息和管理员账号表单,注册成功后展示门店编码和登录提示。Flutter 登录页底部加「前往官网注册」跳转链接。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:56:34 +08:00
wangjia 471b980179 refactor(web): 营销站重构为 Eleventy SSG,内容迁移至 Markdown
- 引入 Eleventy 静态站点生成器,页面统一使用 Nunjucks 模板
- base.njk 提供公共 nav / footer / CSS,消除三个页面间的重复代码
- 抽取 color.css(设计 token)+ style.css(公共组件 + utility 类)
- index.njk:首页迁移,用 utility class 替换大量页面私有 CSS
- download.njk:下载页重构,版本号/更新日志从 CHANGELOG.md 构建时解析,OS 自动识别
- docs.njk:文档页重构,12 章内容从 web/content/docs.md 构建时渲染,三列布局保留
- docs.css 单独提取,docs 专有样式不污染公共 CSS
- 内容面向非技术用户重写,去除技术术语和内部路径引用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:24:14 +08:00
wangjia 3d1fc0d4ba fix(client): 统一打印错误处理,失败时上报并弹提示
新增 safePrint 封装,所有打印调用点统一 catch 任意异常(含字体加载失败),
自动上报 error_reporter 并展示 SnackBar,不再静默失败。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:55:56 +08:00
wangjia b6223b3816 chore: release v1.0.2
Deploy / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:25:05 +08:00
wangjia 117cec431b docs(claude): 补充发版流程、异常上报、拼音搜索规范
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:09:21 +08:00
wangjia d8295d14da chore: 添加 /release 本地发版 slash command
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:52:54 +08:00
wangjia 5282eb57c9 fix(ci): 对 Forgejo API 的 curl 加 -k,跳过自签名证书验证
Deploy / deploy (push) Successful in 1m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:47:15 +08:00
wangjia f59bdf056f fix(ci): 去掉 curl -s,显示完整错误输出;修复 CHANGELOG ### 子标题解析
Deploy / deploy (push) Failing after 43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:41:15 +08:00
wangjia 01946282ed chore: 更新 CHANGELOG v1.0.1
Deploy / deploy (push) Failing after 44s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:31:56 +08:00
wangjia 0d4c9cced2 fix(inventory): 补回 dart:async import(Timer 仍被导入进度弹框使用)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:29:24 +08:00
wangjia e9a6543a8b feat(search): 库存搜索支持拼音/首字母,按回车触发
- 新增 util.ToPinyin(),使用 go-pinyin 生成全拼和首字母
- Product model 新增 name_pinyin / name_initials 列(AutoMigrate)
- 启动时自动回填存量商品拼音
- Create / Update / FindOrCreate 写入时同步生成拼音
- 库存搜索 SQL 加入拼音/首字母 LIKE 条件
- 前端去掉 300ms 防抖,改为回车/点击搜索按钮触发

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:27:54 +08:00
wangjia 5f2248001d feat(client): Dio 拦截器统一上报 5xx 错误,新增 api_error 类型
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:09:50 +08:00
wangjia 2dbc89b902 feat: 错误自动上报机制
- 后端:error_reports 表 + POST /api/v1/public/errors + GET /api/v1/admin/errors
- Flutter:ErrorReporter 单例,60s 去重,fire-and-forget,不影响用户
- main.dart:FlutterError 和 Zone 错误自动上报
- 打印异常:catch 块加 reportError,Windows 打印问题可自动采集堆栈

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:08:03 +08:00
wangjia 44ffedb56a fix(client): 打印异常加 try-catch,错误以 SnackBar 提示用户
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:41:15 +08:00
wangjia 93878511e5 docs: 补充 SSH remote 地址
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:51:12 +08:00
wangjia 503ee7372e chore: git remote 迁移至 git.51yanmei.com,新增 Android 平台支持
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:49:22 +08:00
wangjia 62dc54fe1c docs: 补充开发完成标准(DoD),明确测试通过前不得提交
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:37:34 +08:00
130 changed files with 8474 additions and 4655 deletions
+76
View File
@@ -0,0 +1,76 @@
执行本地发版流程,版本号为 $ARGUMENTS。
按以下步骤顺序执行,任意步骤失败立即停止并报告错误。
## 1. 检查工作区
检查是否有未提交的改动(`git status`)。如果有,列出文件并询问用户是否继续。
## 2. 本地构建
```bash
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go build ./...
```
## 3. 运行测试
```bash
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go test ./...
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
```
## 4. 更新 CHANGELOG.md
检查 CHANGELOG.md 中是否已有 `## [$ARGUMENTS]` 版本节。
- **已有**:直接使用,不修改。
- **没有**:读取 `git log` 从上一个 tag 到 HEAD 的提交记录,自动生成一个版本节插入到文件顶部(位于已有版本节之前),格式如下:
```
## [$ARGUMENTS] - <今天日期>
### 新功能
- ...
### 改进
- ...
### 修复
- ...
```
只保留有内容的分类(新功能/改进/修复),根据 commit message 的类型(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(如界面改版、交互优化)
- 每条描述用一句话说清楚"用户能做什么"或"修了什么问题",不堆砌技术术语
## 5. 提交代码
将所有改动(包括 CHANGELOG.md)用以下格式提交到 main
```
chore: release v$ARGUMENTS
```
## 6. 打 tag
```bash
git tag v$ARGUMENTS
```
## 7. 推送
```bash
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main v$ARGUMENTS
```
推送成功后提示用户:CI/CD 已触发,等待 Telegram 通知。
+3 -2
View File
@@ -1,8 +1,9 @@
name: DB Backup
on:
schedule:
- cron: '0 18 * * *' # UTC 18:00 = 北京时间 02:00
# 定时备份已暂停(保留手动触发)。恢复时取消下面 schedule 的注释即可。
# schedule:
# - cron: '0 18 * * *' # UTC 18:00 = 北京时间 02:00
workflow_dispatch:
jobs:
-30
View File
@@ -1,30 +0,0 @@
name: CI
on:
push:
branches-ignore: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Go tests
working-directory: backend
run: go test ./...
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Flutter tests
working-directory: client
run: flutter test
+88 -2
View File
@@ -6,7 +6,7 @@ on:
- 'v[0-9]*.[0-9]*.[0-9]*'
jobs:
deploy:
build-linux-web:
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
@@ -16,9 +16,95 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Compile
- 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: Upload linux-web artifacts
uses: actions/upload-artifact@v3
with:
name: linux-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
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 (Flutter macOS)
run: sh scripts/ci/compile-macos.sh "${{ gitea.ref_name }}"
- name: Upload macos artifacts
uses: actions/upload-artifact@v3
with:
name: macos
path: dist/
build-windows:
runs-on: windows
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)
shell: bash
run: sh scripts/ci/provision-windows.sh
- name: Compile (Flutter Windows)
shell: bash
run: sh scripts/ci/compile-windows.sh "${{ gitea.ref_name }}"
- name: Upload windows artifacts
uses: actions/upload-artifact@v3
with:
name: windows
path: dist/
release-deploy:
needs: [build-linux-web, build-macos, build-windows]
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: Download linux-web artifacts
uses: actions/download-artifact@v3
with:
name: linux-web
path: dist/
- name: Download macos artifacts
uses: actions/download-artifact@v3
with:
name: macos
path: dist/
- name: Download windows artifacts
uses: actions/download-artifact@v3
with:
name: windows
path: dist/
- name: Test
run: sh scripts/ci/test.sh
+7
View File
@@ -23,6 +23,13 @@ client/devtools_options.yaml
# Flutter 构建产物
client/build/
# CI 构建产物
dist/
# 营销站构建产物
web/dist/
web/node_modules/
client/.dart_tool/
client/.flutter-plugins
client/.flutter-plugins-dependencies
+65
View File
@@ -5,6 +5,71 @@ 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.10] - 2026-06-04
### 新功能
- Windows 客户端改为提供安装程序(Inno Setup 打包的 `jiu-windows-x64-setup.exe`,含中文向导、桌面/开始菜单快捷方式、卸载项),不再是解压版 zip
### 修复
- 修复客户端「无法安全下载」:下载地址从内网 HTTP Forgejo`http://192.168.3.200:3000`,公网不可达且被浏览器拦截)改为同源 HTTPS `https://jiu.51yanmei.com/downloads/...`Windows、macOS 均生效
## [1.0.9] - 2026-06-04
### 修复
- 适配 nginx 容器化部署:nginx 已迁入 `pangolin-edge` 容器(host 网络),deploy 改为将 jiu.conf 写入 pangolin 挂载目录并 `docker exec pangolin-edge nginx -s reload`,不再操作已废弃的主机 nginx
## [1.0.8] - 2026-06-03
### 修复
- 修复 EC2 部署最后一步 nginx reload 失败(`invalid PID number`):改用 `systemctl reload-or-restart nginx`(经 systemd MAINPID 发 HUP,不依赖空的 /run/nginx.pid),并 `enable nginx` 确保重启后自启
## [1.0.7] - 2026-06-03
### 修复
- 修复 Windows 构建 `PathExistsException`errno 183):复制 client/ 到 C:\jiu-build 后,先清除 cp 复制成真实目录的插件软链(windows/flutter/ephemeral)及 .dart_tool/build,再交给 flutter 干净重建
## [1.0.6] - 2026-06-03
### 改进
- CI 运行环境自愈预置:mac/windows runner 首次构建自动 `flutter precache` 并预热 pub/go/npm 缓存(stamp 文件按 Flutter 版本短路),后续构建不再运行时下载 engine
- 发版流水线同一 mac runner 上的任务改为串行(build-macos 依赖 build-linux-web),避免排队等待超时失败
- 暂停定时数据库备份(保留手动触发)
## [1.0.5] - 2026-06-03
### 修复
- 修复 Windows CI 构建失败:放弃脆弱的 `subst W:` 路径映射,改为将 client/ 复制到 System32 之外的干净短路径(C:\jiu-build)构建,规避 WOW64 文件系统重定向
## [1.0.4] - 2026-05-30
### 新功能
- 支持 macOS 桌面客户端下载,下载页 macOS 卡片已激活,下载 zip 解压即用
## [1.0.3] - 2026-05-28
### 新功能
- 新增用户自助注册功能,在官网 /register/ 填写门店信息即可完成注册,系统自动生成门店编码(S000001 格式)
- 注册账号默认设为超级管理员权限
- 支持 Windows 桌面客户端构建与发布
### 修复
- 修复新门店首次登录时库存、仓库、用户等列表加载崩溃的问题
- 修复库存管理页在无数据时页面崩溃(RangeError)的问题
- 商品标签打印改为横向 38mm×20mm 布局,修复标签内容错位问题
- 修复数据导入中移除不必要的"商品编码"导入项
## [1.0.2] - 2026-05-25
### 修复
- 打印入库单/出库单/商品标签时,若浏览器拦截了弹窗,现在会给出明确提示,不再静默失败
## [1.0.1] - 2026-05-25
### 改进
- 库存搜索支持拼音和拼音首字母(例:搜"mt"或"maotai"可匹配"茅台"
- 库存搜索改为回车/点击触发,减少无效请求
- 新增异常自动上报机制,线上错误可实时追踪
## [1.0.0] - 2026-05-24
初始版本发布,完成核心库存管理功能。
+91
View File
@@ -150,6 +150,45 @@ security: 修复入库单多租户隔离漏洞
---
## 开发完成标准(Definition of Done
**任何功能或修复,必须满足以下全部条件才算完成:**
### 后端变更
```bash
# 1. 编译通过
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go build ./...
# 2. 所有测试通过(不得有 FAIL)
cd backend && go test ./...
# 3. 无编译警告(vet 检查)
cd backend && go vet ./...
```
### 前端变更
```bash
# 1. 静态分析无 errorwarning/info 允许存在)
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
# 2. 所有测试通过
cd client && flutter test
```
### 通用要求
- [ ] 代码已按 Git 提交规范提交(feat/fix/test/...
- [ ] 涉及新接口:已在对应测试文件中覆盖核心路径
- [ ] 涉及数据库变更:schema.sql 和 model 同步更新
- [ ] 多租户隔离未被破坏(所有查询含 shop_id 条件)
**测试未通过前,禁止提交代码、禁止打 tag 发版。**
---
## 代码质量门禁
以下情况**不得提交**
@@ -212,3 +251,55 @@ security: 修复入库单多租户隔离漏洞
tx.Set("gorm:query_option", "FOR UPDATE").Where(...).First(&model)
```
- 适用场景:单号生成(`number_rules`)、库存扣减(`inventories`)、任何 check-then-act 模式
### 发版流程
使用 `/release <version>` slash command
```
/release 1.0.2
```
执行顺序:本地 build → test → 更新 CHANGELOG.md → git commit → tag → push main+tag。
CI/CDForgejo)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
**CHANGELOG 格式**Keep a Changelog):
```markdown
## [1.0.2] - YYYY-MM-DD
### 新功能
- ...
### 改进
- ...
### 修复
- ...
```
只保留有内容的分类。`/release` 会检查 CHANGELOG 是否已有该版本节,没有则从 git log 自动生成。
### 异常上报
客户端异常已在两处统一捕获,**无需**在每个业务层重复处理:
- `main.dart``FlutterError.onError` + `runZonedGuarded` 捕获所有未处理异常
- `api_client.dart`:Dio 拦截器自动上报所有 HTTP 5xx 错误
需要**手动调用** `reportError(e, st)` 的场景:
- 技术性异常(JSON 解析失败、第三方 SDK 崩溃等)
- **不需要**上报:`AppException` 及其子类(已知业务错误)
```dart
} catch (e, st) {
reportError(e, st); // 一行,fire-and-forget
rethrow;
}
```
### 搜索实现(拼音)
商品名搜索同时支持汉字、全拼、首字母:
- `products` 表有 `name_pinyin`(全拼)和 `name_initials`(首字母)两列
- Create / Update / FindOrCreate 写入时自动调用 `util.ToPinyin()` 生成,**无需手动维护**
- 启动时对 `name_pinyin = ''` 的存量数据自动回填(`backfillPinyin`
- 库存搜索 SQL 同时 LIKE 匹配 name、code、name_pinyin、name_initials
- 新增其他需要拼音搜索的实体时,复用 `backend/internal/util/pinyin.go` 的 `ToPinyin()`
+3 -3
View File
@@ -1,7 +1,7 @@
version: "1.0.0"
build_number: 1
version: "1.0.3"
build_number: 4
force_update: false
release_notes: "初始版本发布,完成核心库存管理功能。"
release_notes: "新增用户自助注册功能;商品标签打印修复竖向 20mm×38mm 布局"
download_urls:
macos: ""
windows: ""
+1
View File
@@ -44,6 +44,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.22 // 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
+2
View File
@@ -73,6 +73,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+17
View File
@@ -49,6 +49,23 @@ func (h *AuthHandler) Login(c *gin.Context) {
})
}
// Register POST /api/v1/public/register
func (h *AuthHandler) Register(c *gin.Context) {
var req service.RegisterInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := h.svc.Register(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// Refresh POST /api/v1/auth/refresh
func (h *AuthHandler) Refresh(c *gin.Context) {
var req struct {
+116
View File
@@ -0,0 +1,116 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
)
type ErrorReportHandler struct{ db *gorm.DB }
func NewErrorReportHandler(db *gorm.DB) *ErrorReportHandler {
return &ErrorReportHandler{db: db}
}
type submitErrorRequest struct {
ErrorType string `json:"error_type" binding:"required"`
AppVersion string `json:"app_version"`
Platform string `json:"platform"`
Username string `json:"username"`
ShopID uint64 `json:"shop_id"`
ShopNo string `json:"shop_no"`
Role string `json:"role"`
ErrorMsg string `json:"error_msg" binding:"required"`
StackTrace string `json:"stack_trace"`
OccurredAt int64 `json:"occurred_at"` // Unix 毫秒
}
// Submit POST /api/v1/public/errors
func (h *ErrorReportHandler) Submit(c *gin.Context) {
var req submitErrorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
occurredAt := time.Now()
if req.OccurredAt > 0 {
occurredAt = time.UnixMilli(req.OccurredAt)
}
stack := req.StackTrace
const maxStack = 4000
if len(stack) > maxStack {
stack = stack[:maxStack]
}
report := model.ErrorReport{
ErrorType: req.ErrorType,
AppVersion: req.AppVersion,
Platform: req.Platform,
Username: req.Username,
ShopID: req.ShopID,
ShopNo: req.ShopNo,
Role: req.Role,
ClientIP: c.ClientIP(),
ErrorMsg: req.ErrorMsg,
StackTrace: stack,
OccurredAt: occurredAt,
}
if err := h.db.Create(&report).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "submit failed"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": report.ID})
}
// List GET /api/v1/admin/errors (superadmin only)
func (h *ErrorReportHandler) List(c *gin.Context) {
var q struct {
ErrorType string `form:"error_type"`
Username string `form:"username"`
Platform string `form:"platform"`
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=50"`
}
if err := c.ShouldBindQuery(&q); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if q.PageSize <= 0 || q.PageSize > 200 {
q.PageSize = 50
}
if q.Page <= 0 {
q.Page = 1
}
db := h.db.Model(&model.ErrorReport{})
if q.ErrorType != "" {
db = db.Where("error_type = ?", q.ErrorType)
}
if q.Username != "" {
db = db.Where("username LIKE ?", "%"+q.Username+"%")
}
if q.Platform != "" {
db = db.Where("platform = ?", q.Platform)
}
var total int64
db.Count(&total)
var reports []model.ErrorReport
db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&reports)
c.JSON(http.StatusOK, gin.H{
"data": reports,
"total": total,
"page": q.Page,
"page_size": q.PageSize,
})
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
var total int64
base.Count(&total)
var records []model.FinanceRecord
records := make([]model.FinanceRecord, 0)
offset := (q.Page - 1) * q.PageSize
base.Preload("Partner").Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records)
+3 -3
View File
@@ -64,9 +64,9 @@ func (h *InventoryHandler) List(c *gin.Context) {
inStock := c.Query("in_stock")
if keyword != "" {
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ?)"
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like)
args = append(args, like, like, like, like)
}
if warehouseIDStr != "" {
baseWhere += " AND inv.warehouse_id = ?"
@@ -138,7 +138,7 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
var total int64
query.Count(&total)
var logs []model.InventoryLog
logs := make([]model.InventoryLog, 0)
query.Preload("Product").Preload("Warehouse").Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
+1 -1
View File
@@ -32,7 +32,7 @@ var defaultRules = []struct {
// List GET /api/v1/number-rules
func (h *NumberRuleHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
var rules []model.NumberRule
rules := make([]model.NumberRule, 0)
h.db.Where("shop_id = ?", shopID).Order("id").Find(&rules)
if len(rules) == 0 {
+1 -1
View File
@@ -38,7 +38,7 @@ func (h *PartnerHandler) List(c *gin.Context) {
var total int64
query.Count(&total)
var partners []model.Partner
partners := make([]model.Partner, 0)
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&partners)
c.JSON(http.StatusOK, gin.H{"data": partners, "total": total, "page": page, "page_size": pageSize})
+15 -7
View File
@@ -15,6 +15,7 @@ import (
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type ProductHandler struct {
@@ -47,7 +48,7 @@ func (h *ProductHandler) List(c *gin.Context) {
var total int64
query.Count(&total)
var products []model.Product
products := make([]model.Product, 0)
offset := (page - 1) * pageSize
query.Preload("Category").Offset(offset).Limit(pageSize).
Order("id DESC").Find(&products)
@@ -70,6 +71,7 @@ func (h *ProductHandler) Create(c *gin.Context) {
}
product.ShopID = shopID
product.PublicID = uuid.New().String()
product.NamePinyin, product.NameInitials = util.ToPinyin(product.Name)
// Auto-generate product code if not provided (e.g. P001, P002)
// Retry up to 5 times on duplicate key to handle concurrent creates
@@ -122,6 +124,7 @@ func (h *ProductHandler) Update(c *gin.Context) {
return
}
namePinyin, nameInitials := util.ToPinyin(req.Name)
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
if err := h.db.Model(&product).Updates(map[string]interface{}{
"code": req.Code,
@@ -138,6 +141,8 @@ func (h *ProductHandler) Update(c *gin.Context) {
"description": req.Description,
"remark": req.Remark,
"custom_fields": req.CustomFields,
"name_pinyin": namePinyin,
"name_initials": nameInitials,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -226,13 +231,16 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
var count int64
h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count)
namePinyin, nameInitials := util.ToPinyin(req.Name)
product = model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Name: req.Name,
Series: req.Series,
Spec: req.Spec,
Code: fmt.Sprintf("P%03d", count+1),
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Name: req.Name,
Series: req.Series,
Spec: req.Spec,
Code: fmt.Sprintf("P%03d", count+1),
NamePinyin: namePinyin,
NameInitials: nameInitials,
}
if createErr := h.db.Create(&product).Error; createErr != nil {
// Race condition: try to find the record created by another request
+3 -3
View File
@@ -22,7 +22,7 @@ func NewProductOptionHandler(db *gorm.DB) *ProductOptionHandler {
func (h *ProductOptionHandler) ListNames(c *gin.Context) {
shopID := middleware.GetShopID(c)
var items []model.ProductNameOption
items := make([]model.ProductNameOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
}
@@ -84,7 +84,7 @@ func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
func (h *ProductOptionHandler) ListSeries(c *gin.Context) {
shopID := middleware.GetShopID(c)
var items []model.ProductSeriesOption
items := make([]model.ProductSeriesOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
}
@@ -146,7 +146,7 @@ func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
func (h *ProductOptionHandler) ListSpecs(c *gin.Context) {
shopID := middleware.GetShopID(c)
var items []model.ProductSpecOption
items := make([]model.ProductSpecOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (h *StockInHandler) List(c *gin.Context) {
var total int64
query.Count(&total)
var orders []model.StockInOrder
orders := make([]model.StockInOrder, 0)
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
Offset((page - 1) * pageSize).Limit(pageSize).
Order("id DESC").Find(&orders)
+1 -1
View File
@@ -44,7 +44,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
var total int64
query.Count(&total)
var orders []model.StockOutOrder
orders := make([]model.StockOutOrder, 0)
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
Offset((page - 1) * pageSize).Limit(pageSize).
Order("id DESC").Find(&orders)
+1 -1
View File
@@ -22,7 +22,7 @@ func NewUserHandler(db *gorm.DB) *UserHandler {
// List GET /api/v1/users
func (h *UserHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
var users []model.User
users := make([]model.User, 0)
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).
Order("id ASC").Find(&users)
c.JSON(http.StatusOK, gin.H{"data": users})
+1 -1
View File
@@ -20,7 +20,7 @@ func NewWarehouseHandler(db *gorm.DB) *WarehouseHandler {
func (h *WarehouseHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
var warehouses []model.Warehouse
warehouses := make([]model.Warehouse, 0)
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
c.JSON(http.StatusOK, gin.H{"data": warehouses})
}
+1 -1
View File
@@ -51,7 +51,7 @@ func JWT() gin.HandlerFunc {
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get(CtxRole)
if role != "admin" {
if role != "admin" && role != "superadmin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
+20
View File
@@ -0,0 +1,20 @@
package model
import "time"
// ErrorReport 客户端错误上报记录(系统级,不做多租户隔离)
type ErrorReport struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
CreatedAt time.Time `json:"created_at"`
ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception
AppVersion string `gorm:"size:30" json:"app_version"`
Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos
Username string `gorm:"size:50;index" json:"username"`
ShopID uint64 `gorm:"index" json:"shop_id"`
ShopNo string `gorm:"size:50" json:"shop_no"`
Role string `gorm:"size:20" json:"role"`
ClientIP string `gorm:"size:60" json:"client_ip"`
ErrorMsg string `gorm:"type:text;not null" json:"error_msg"`
StackTrace string `gorm:"type:text" json:"stack_trace"`
OccurredAt time.Time `json:"occurred_at"`
}
+2
View File
@@ -24,6 +24,8 @@ type Product struct {
Description string `gorm:"type:text" json:"description"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
NamePinyin string `gorm:"size:400;index" json:"-"`
NameInitials string `gorm:"size:100;index" json:"-"`
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
+1
View File
@@ -4,6 +4,7 @@ type Shop struct {
Base
Name string `gorm:"size:100;not null" json:"name"`
Code string `gorm:"size:50;uniqueIndex" json:"code"`
Description string `gorm:"type:text" json:"description"`
Address string `gorm:"size:255" json:"address"`
Phone string `gorm:"size:30" json:"phone"`
BusinessHours string `gorm:"size:100" json:"business_hours"`
+4
View File
@@ -33,6 +33,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
publicH := handler.NewPublicHandler(db)
adminH := handler.NewAdminHandler(db)
shopH := handler.NewShopHandler(db)
errorReportH := handler.NewErrorReportHandler(db)
// 健康检查(无需认证,用于前端连通性探测)
r.GET("/health", func(c *gin.Context) {
@@ -56,6 +57,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
{
public.GET("/products/:public_id", publicH.GetProduct)
public.GET("/release", publicH.GetRelease)
public.POST("/errors", errorReportH.Submit)
public.POST("/register", authH.Register)
}
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
@@ -213,6 +216,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
superAdmin.Use(middleware.SuperAdminOnly())
{
superAdmin.POST("/clear-data", adminH.ClearData)
superAdmin.GET("/errors", errorReportH.List)
}
}
}
+72
View File
@@ -2,9 +2,11 @@ package service
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@@ -61,6 +63,76 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m
return pair, &user, nil
}
// RegisterInput 注册新门店所需参数
type RegisterInput struct {
ShopName string `json:"shop_name" binding:"required"`
Address string `json:"address" binding:"required"`
ManagerName string `json:"manager_name" binding:"required"`
Phone string `json:"phone" binding:"required"`
Description string `json:"description"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
}
// RegisterResult 注册成功后返回的数据
type RegisterResult struct {
ShopCode string `json:"shop_code"`
ShopName string `json:"shop_name"`
Username string `json:"username"`
}
// Register 自助注册新门店(公开接口,无需认证)
func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
var result RegisterResult
err = s.db.Transaction(func(tx *gorm.DB) error {
// 先用 UUID 占位创建门店,得到真实 ID
shop := model.Shop{
Name: in.ShopName,
Code: uuid.New().String(),
Address: in.Address,
Phone: in.Phone,
ManagerName: in.ManagerName,
Description: in.Description,
}
if err := tx.Create(&shop).Error; err != nil {
return err
}
// 用自增 ID 生成正式门店编码
shop.Code = fmt.Sprintf("S%06d", shop.ID)
if err := tx.Model(&shop).Update("code", shop.Code).Error; err != nil {
return err
}
// 创建管理员用户
user := model.User{
ShopID: shop.ID,
Username: in.Username,
PasswordHash: string(hash),
RealName: in.ManagerName,
Phone: in.Phone,
Role: "superadmin",
IsActive: true,
}
if err := tx.Create(&user).Error; err != nil {
return err
}
result = RegisterResult{
ShopCode: shop.Code,
ShopName: shop.Name,
Username: user.Username,
}
return nil
})
return &result, err
}
// HashPassword 生成 bcrypt 哈希
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
+48
View File
@@ -0,0 +1,48 @@
package util
import (
"strings"
gp "github.com/mozillazg/go-pinyin"
)
var (
fullArgs = func() gp.Args {
a := gp.NewArgs()
a.Style = gp.Normal
a.Fallback = func(r rune, _ gp.Args) []string {
return []string{strings.ToLower(string(r))}
}
return a
}()
initialArgs = func() gp.Args {
a := gp.NewArgs()
a.Style = gp.FirstLetter
a.Fallback = func(r rune, _ gp.Args) []string {
s := strings.ToLower(string(r))
if s == "" {
return nil
}
return []string{string(s[0])}
}
return a
}()
)
// ToPinyin converts a Chinese name to full pinyin and initials.
// Non-Chinese characters are kept as-is (lowercased).
// Example: "茅台酒" → ("maotaijiu", "mtj")
func ToPinyin(s string) (full, initials string) {
var fb, ib strings.Builder
for _, row := range gp.Pinyin(s, fullArgs) {
if len(row) > 0 {
fb.WriteString(row[0])
}
}
for _, row := range gp.Pinyin(s, initialArgs) {
if len(row) > 0 {
ib.WriteString(row[0])
}
}
return fb.String(), ib.String()
}
+21
View File
@@ -12,6 +12,7 @@ import (
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/router"
"github.com/wangjia/jiu/backend/internal/util"
)
func main() {
@@ -27,6 +28,9 @@ func main() {
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
autoMigrate(db)
// 回填存量商品的拼音索引(一次性,已有值的跳过)
backfillPinyin(db)
// 启动 Gin
gin.SetMode(config.C.Server.Mode)
r := gin.New()
@@ -104,9 +108,26 @@ func autoMigrate(db *gorm.DB) {
&model.ProductSeriesOption{},
&model.ProductSpecOption{},
&model.ProductImage{},
&model.ErrorReport{},
)
if err != nil {
log.Fatalf("auto migrate failed: %v", err)
}
log.Println("AutoMigrate completed")
}
func backfillPinyin(db *gorm.DB) {
var products []model.Product
db.Where("name_pinyin = '' OR name_pinyin IS NULL").Find(&products)
if len(products) == 0 {
return
}
for i := range products {
full, initials := util.ToPinyin(products[i].Name)
db.Model(&products[i]).Updates(map[string]interface{}{
"name_pinyin": full,
"name_initials": initials,
})
}
log.Printf("backfillPinyin: updated %d products", len(products))
}
+1
View File
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `shops` (
`address` VARCHAR(255) DEFAULT NULL,
`phone` VARCHAR(30) DEFAULT NULL,
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
`description` TEXT DEFAULT NULL COMMENT '店铺简介',
`logo_url` VARCHAR(500) DEFAULT '' COMMENT '门店 logo URL',
`business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL',
`shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组',
+4 -1
View File
@@ -53,6 +53,7 @@ func SetupTestDB() *gorm.DB {
deleted_at DATETIME,
name TEXT NOT NULL,
code TEXT UNIQUE,
description TEXT,
address TEXT,
phone TEXT,
manager_name TEXT,
@@ -121,7 +122,9 @@ func SetupTestDB() *gorm.DB {
min_stock INTEGER DEFAULT 0,
description TEXT,
custom_fields TEXT,
remark TEXT
remark TEXT,
name_pinyin TEXT DEFAULT '',
name_initials TEXT DEFAULT ''
)`,
`CREATE TABLE IF NOT EXISTS warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+1 -1
View File
@@ -15,7 +15,7 @@ migration:
- platform: root
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: web
- platform: android
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.yanmei.jiu"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.yanmei.jiu"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="jiu_client"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.yanmei.jiu
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+11
View File
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
@@ -74,6 +75,16 @@ class ApiClient {
return handler.next(e);
}
// 服务端 5xx 错误上报(4xx 是业务/权限错误,不上报)
final statusCode = e.response?.statusCode ?? 0;
if (statusCode >= 500) {
reportError(
'[${e.requestOptions.method}] ${e.requestOptions.path}$statusCode',
e.stackTrace,
errorType: ErrorType.apiError,
);
}
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
try {
+143
View File
@@ -0,0 +1,143 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
class ErrorType {
static const flutterError = 'flutter_error';
static const zoneError = 'zone_error';
static const caughtException = 'caught_exception';
static const apiError = 'api_error';
}
class ErrorReporter {
ErrorReporter._();
static final ErrorReporter instance = ErrorReporter._();
static const _dedupWindowMs = 60 * 1000;
static const _maxStackLength = 3000;
final Map<String, int> _recentMessages = {};
Dio? _dio;
String? _cachedVersion;
Dio get _client {
_dio ??= Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
return _dio!;
}
Future<String> _getVersion() async {
if (_cachedVersion != null) return _cachedVersion!;
try {
final info = await PackageInfo.fromPlatform();
_cachedVersion = info.version;
} catch (_) {
_cachedVersion = 'unknown';
}
return _cachedVersion!;
}
String _getPlatform() {
if (kIsWeb) return 'web';
try {
if (Platform.isIOS) return 'ios';
if (Platform.isAndroid) return 'android';
if (Platform.isMacOS) return 'macos';
if (Platform.isWindows) return 'windows';
if (Platform.isLinux) return 'linux';
} catch (_) {}
return 'unknown';
}
Future<Map<String, dynamic>> _getUserFields() async {
try {
final prefs = await SharedPreferences.getInstance();
return {
'username': prefs.getString('username') ?? '',
'shop_id': int.tryParse(prefs.getString('shop_id') ?? '') ?? 0,
'shop_no': prefs.getString('shop_no') ?? '',
'role': prefs.getString('role') ?? '',
};
} catch (_) {
return {'username': '', 'shop_id': 0, 'shop_no': '', 'role': ''};
}
}
bool _isDuplicate(String message) {
final now = DateTime.now().millisecondsSinceEpoch;
final lastTime = _recentMessages[message];
if (lastTime != null && now - lastTime < _dedupWindowMs) return true;
_recentMessages[message] = now;
if (_recentMessages.length > 100) {
_recentMessages.removeWhere((_, t) => now - t >= _dedupWindowMs);
}
return false;
}
void report({
required String errorType,
required String errorMsg,
StackTrace? stackTrace,
}) {
if (_isDuplicate(errorMsg)) return;
unawaited(_doReport(
errorType: errorType,
errorMsg: errorMsg,
stackTrace: stackTrace,
));
}
Future<void> _doReport({
required String errorType,
required String errorMsg,
StackTrace? stackTrace,
}) async {
try {
final version = await _getVersion();
final userFields = await _getUserFields();
String? stack;
if (stackTrace != null) {
final raw = stackTrace.toString();
stack = raw.length > _maxStackLength ? raw.substring(0, _maxStackLength) : raw;
}
await _client.post('/public/errors', data: {
'error_type': errorType,
'app_version': version,
'platform': _getPlatform(),
'username': userFields['username'],
'shop_id': userFields['shop_id'],
'shop_no': userFields['shop_no'],
'role': userFields['role'],
'error_msg': errorMsg,
'stack_trace': stack ?? '',
'occurred_at': DateTime.now().millisecondsSinceEpoch,
});
} catch (_) {
// 上报失败静默处理,不影响用户
}
}
}
/// 便捷顶层函数,供 catch 块一行调用
void reportError(
Object error,
StackTrace? stack, {
String errorType = ErrorType.caughtException,
}) {
ErrorReporter.instance.report(
errorType: errorType,
errorMsg: error.toString(),
stackTrace: stack,
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ class PageResult<T> {
T Function(Map<String, dynamic>) fromJson,
) {
return PageResult(
data: (json['data'] as List)
data: ((json['data'] as List?) ?? [])
.map((e) => fromJson(e as Map<String, dynamic>))
.toList(),
total: json['total'] as int,
+17
View File
@@ -1,6 +1,8 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
import 'print_util_stub.dart'
if (dart.library.js_interop) 'print_util_web.dart';
@@ -36,3 +38,18 @@ Future<void> printStockInOrder(StockInOrder order) =>
Future<void> printStockOutOrder(StockOutOrder order) =>
printStockOutOrderImpl(order);
/// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。
/// 用法:await safePrint(context, () => printStockInOrder(order));
Future<void> safePrint(BuildContext context, Future<void> Function() fn) async {
try {
await fn();
} catch (e, st) {
reportError(e, st);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打印失败:$e'), backgroundColor: Colors.red),
);
}
}
}
+19 -3
View File
@@ -1,4 +1,5 @@
import 'package:flutter/services.dart';
import '../errors/error_reporter.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
@@ -53,6 +54,7 @@ Future<void> printProductLabelImpl({
String shopAddress = '',
String shopPhone = '',
}) async {
try {
final font = await _loadFont();
final doc = pw.Document();
final qrImage = pw.MemoryImage(qrBytes);
@@ -189,7 +191,11 @@ Future<void> printProductLabelImpl({
),
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
await Printing.layoutPdf(onLayout: (_) async => doc.save());
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
pw.Widget _label2ColRow(
@@ -377,6 +383,7 @@ pw.Widget _sig(pw.Font font, pw.Font bold, String label) {
}
Future<void> printStockInOrderImpl(StockInOrder order) async {
try {
final font = await _loadFont();
final bold = await _loadBoldFont();
@@ -424,7 +431,11 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
),
],
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
await Printing.layoutPdf(onLayout: (_) async => doc.save());
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
@@ -474,5 +485,10 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
),
],
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
try {
await Printing.layoutPdf(onLayout: (_) async => doc.save());
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
+81 -98
View File
@@ -4,13 +4,15 @@ import 'dart:typed_data';
import 'package:web/web.dart' as web;
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
void _openPrintWindow(String html) {
final win = web.window.open('', '_blank');
if (win != null) {
win.document.write(html.toJS);
win.document.close();
if (win == null) {
throw Exception('浏览器已拦截弹窗,请在地址栏允许弹窗后重试');
}
win.document.write(html.toJS);
win.document.close();
}
Future<void> printProductLabelImpl({
@@ -28,111 +30,93 @@ Future<void> printProductLabelImpl({
}) async {
final base64Img = base64Encode(qrBytes);
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '';
final dateVal = (productionDate ?? '').isNotEmpty
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '';
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
final footerContact = [
if (shopAddress.isNotEmpty) shopAddress,
final contactLine = [
if (shopPhone.isNotEmpty) shopPhone,
].join(' · ');
// 标签生成时间
final now = DateTime.now();
final genTime =
'${now.year}-${now.month.toString().padLeft(2,'0')}-${now.day.toString().padLeft(2,'0')}'
' ${now.hour.toString().padLeft(2,'0')}:${now.minute.toString().padLeft(2,'0')}';
final remarkRow = (remark ?? '').isNotEmpty
? '''<div class="spec-row">
<span class="lbl">备 注</span>
<span class="val-full">$remark</span>
</div>'''
: '';
if (shopAddress.isNotEmpty) shopAddress,
].join(' ');
final html = '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Noto+Serif+SC:wght@700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
<style>
@page { size: 4in 2in; margin: 0; }
/* 标签尺寸:38mm × 20mm,横向 */
@page { size: 38mm 20mm; margin: 0; }
* { box-sizing: border-box; margin: 0; padding: 0;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { width: 4in; height: 2in; overflow: hidden; background: #fff; }
body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
.label {
width: 4in; height: 2in;
display: grid; grid-template-rows: 42px 1fr 18px;
width: 38mm; height: 20mm;
display: flex; flex-direction: column;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #111;
}
/* ── Header:酒行名称 ── */
/* ── Header:酒行名称 3.5mm ── */
.hdr {
background: #1f2a3a; color: #f4ecd8;
display: flex; align-items: center; justify-content: space-between;
padding: 0 15px; overflow: hidden;
height: 3.5mm; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 0 1.5mm; overflow: hidden;
}
.hdr-name {
font-family: "Noto Serif SC", serif;
font-weight: 700; font-size: 17px; letter-spacing: 0.08em;
font-size: 5.5pt; font-weight: 700; letter-spacing: 0.08em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hdr-tag {
font-size: 6.5px; letter-spacing: 0.2em; opacity: 0.8;
font-style: italic; white-space: nowrap; flex-shrink: 0; padding-left: 10px;
/* ── 中部:左侧商品信息 + 右侧二维码 ── */
.middle {
flex: 1; display: flex; flex-direction: row; overflow: hidden;
}
/* ── Body ── */
.body {
background: #fff; padding: 9px 15px 8px;
display: grid; grid-template-columns: 1fr 76px; gap: 10px;
align-items: center;
/* 左:商品名 + 规格批次 */
.info {
flex: 1; display: flex; flex-direction: column;
justify-content: center; padding: 0.8mm 1mm 0.5mm 1.5mm;
overflow: hidden;
}
/* 商品名 */
.product-name {
font-family: "Noto Serif SC", serif;
font-weight: 700; font-size: 13px; letter-spacing: 0.04em;
margin-bottom: 7px;
font-size: 6.5pt; font-weight: 700; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 0.8mm;
}
.specs {
display: flex; flex-direction: column; gap: 0.5mm;
}
/* 规格行 */
.spec-row {
display: flex; gap: 6px; align-items: baseline;
font-size: 6.5px; margin-bottom: 3.5px; flex-wrap: nowrap;
display: flex; gap: 0.8mm; align-items: baseline;
font-size: 4pt; white-space: nowrap; overflow: hidden;
}
.lbl { color: #888; letter-spacing: 0.1em; white-space: nowrap; flex-shrink: 0; }
.lbl { color: #999; flex-shrink: 0; }
.val { font-family: "IBM Plex Mono", monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.val-full { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: #444; font-size: 6px; }
overflow: hidden; text-overflow: ellipsis; }
/* 同行双字段 */
.spec-row-2 {
display: grid; grid-template-columns: 28px 1fr 28px 1fr;
gap: 0 7px; font-size: 6.5px; margin-bottom: 3.5px;
align-items: baseline;
/* 右:二维码 */
.qr-wrap {
width: 14mm; flex-shrink: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 0.3mm;
padding: 0.5mm 0.5mm 0.5mm 0;
}
.qr-wrap img { width: 12mm; height: 12mm; object-fit: contain; }
.qr-cap { font-size: 3pt; color: #aaa; letter-spacing: 0.1em; }
/* QR */
.qr-col { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.qr-col img { width: 68px; height: 68px; object-fit: contain; display: block; }
.qr-cap { font-size: 5px; letter-spacing: 0.2em; color: #666; text-align: center; }
/* ── Footer:地址 + 生成时间 ── */
/* ── Footer:联系方式 3mm ── */
.ftr {
background: #f4ecd8;
display: flex; align-items: center; justify-content: space-between;
padding: 0 15px;
font-family: "IBM Plex Mono", monospace;
font-size: 5px; color: #5a4e35; letter-spacing: 0.04em; overflow: hidden;
background: #f4ecd8; height: 3mm; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 0 1.5mm;
font-size: 3.5pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
text-overflow: ellipsis;
}
</style>
</head>
@@ -141,40 +125,24 @@ body { width: 4in; height: 2in; overflow: hidden; background: #fff; }
<div class="hdr">
<span class="hdr-name">$shopName</span>
<span class="hdr-tag">Certificate of Authenticity</span>
</div>
<div class="body">
<div>
<div class="middle">
<div class="info">
<div class="product-name">$name</div>
<div class="spec-row-2">
<span class="lbl">规 格</span>
<span class="val">$specVal</span>
<span class="lbl">系 列</span>
<span class="val">$seriesVal</span>
<div class="specs">
<div class="spec-row"><span class="lbl">规</span><span class="val">$specVal</span></div>
<div class="spec-row"><span class="lbl">批</span><span class="val">$batchVal</span></div>
<div class="spec-row"><span class="lbl">产</span><span class="val">$dateVal</span></div>
</div>
<div class="spec-row-2">
<span class="lbl">批 号</span>
<span class="val">$batchVal</span>
<span class="lbl">生产日期</span>
<span class="val">$dateVal</span>
</div>
$remarkRow
</div>
<div class="qr-col">
<div class="qr-wrap">
<img src="data:image/png;base64,$base64Img" />
<div class="qr-cap">扫码溯源 · TRACE</div>
<div class="qr-cap">扫码溯源</div>
</div>
</div>
<div class="ftr">
<span>${footerContact.isNotEmpty ? footerContact : shopName}</span>
<span>$genTime</span>
</div>
<div class="ftr">${contactLine.isNotEmpty ? contactLine : shopName}</div>
</div>
<script>
@@ -187,7 +155,12 @@ body { width: 4in; height: 2in; overflow: hidden; background: #fff; }
</body>
</html>''';
_openPrintWindow(html);
try {
_openPrintWindow(html);
} catch (e, st) {
reportError(e, st);
rethrow;
}
}
// ── 入库单 ──────────────────────────────────────────────────────────────────
@@ -293,7 +266,12 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
</body>
</html>''';
_openPrintWindow(html);
try {
_openPrintWindow(html);
} catch (e, st) {
reportError(e, st);
rethrow;
}
}
// ── 出库单 ──────────────────────────────────────────────────────────────────
@@ -396,5 +374,10 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
</body>
</html>''';
_openPrintWindow(html);
try {
_openPrintWindow(html);
} catch (e, st) {
reportError(e, st);
rethrow;
}
}
+11
View File
@@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'core/auth/auth_state.dart';
import 'core/errors/error_reporter.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';
import 'providers/connectivity_provider.dart';
@@ -15,6 +16,11 @@ void main() {
debugPrint('═══ FlutterError ════════════════════════════');
debugPrint(details.exceptionAsString());
debugPrint(details.stack.toString());
ErrorReporter.instance.report(
errorType: ErrorType.flutterError,
errorMsg: details.exceptionAsString(),
stackTrace: details.stack,
);
};
runZonedGuarded(
@@ -23,6 +29,11 @@ void main() {
debugPrint('═══ Zone Error ═══════════════════════════════');
debugPrint(error.toString());
debugPrint(stack.toString());
ErrorReporter.instance.report(
errorType: ErrorType.zoneError,
errorMsg: error.toString(),
stackTrace: stack,
);
},
);
}
@@ -10,7 +10,7 @@ class NumberRuleRepository {
Future<List<NumberRule>> list() async {
try {
final resp = await _client.get('/number-rules');
final data = resp.data['data'] as List;
final data = (resp.data['data'] as List?) ?? [];
return data
.map((e) => NumberRule.fromJson(e as Map<String, dynamic>))
.toList();
+1 -1
View File
@@ -7,7 +7,7 @@ class UserRepository {
Future<List<AppUser>> list() async {
final resp = await _client.get('/users');
final data = resp.data['data'] as List;
final data = (resp.data['data'] as List?) ?? [];
return data.map((e) => AppUser.fromJson(e as Map<String, dynamic>)).toList();
}
@@ -12,7 +12,7 @@ class WarehouseRepository {
try {
final resp = await _client.get('/warehouses');
final body = resp.data as Map<String, dynamic>;
final raw = body['data'] as List;
final raw = (body['data'] as List?) ?? [];
return raw
.map((e) => Warehouse.fromJson(e as Map<String, dynamic>))
.toList();
+16
View File
@@ -3,7 +3,9 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../core/storage/login_history.dart';
import '../../providers/connectivity_provider.dart';
@@ -484,6 +486,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
letterSpacing: 2)),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => launchUrl(
Uri.parse('${AppConfig.publicBaseUrl}/register/'),
mode: LaunchMode.externalApplication,
),
child: const Text(
'还没有门店账号?前往官网注册',
style: TextStyle(
fontSize: 15,
color: AppTheme.textSecondary,
),
),
),
],
),
),
@@ -28,21 +28,16 @@ class InventoryListScreen extends ConsumerStatefulWidget {
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final _searchCtrl = TextEditingController();
Timer? _debounce;
Set<String> _filterWarehouse = {};
@override
void dispose() {
_searchCtrl.dispose();
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
ref.read(inventoryListProvider.notifier).setKeyword(value);
});
void _triggerSearch() {
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
}
Future<void> _editRemark(BuildContext context, Inventory item) async {
@@ -333,15 +328,20 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
const Spacer(),
SizedBox(
width: 200,
width: 220,
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
hintText: '搜索商品名/编码',
prefixIcon: Icon(Icons.search, size: 16),
hintStyle: TextStyle(fontSize: 13),
decoration: InputDecoration(
hintText: '名称/编码/拼音,回车搜索',
prefixIcon: const Icon(Icons.search, size: 16),
hintStyle: const TextStyle(fontSize: 12),
suffixIcon: IconButton(
icon: const Icon(Icons.search, size: 16),
tooltip: '搜索',
onPressed: _triggerSearch,
),
),
onChanged: _onSearchChanged,
onSubmitted: (_) => _triggerSearch(),
),
),
],
@@ -386,7 +386,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: filteredItems
@@ -485,12 +484,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
item.productId != null
? TextButton(
onPressed: () async {
try {
final qrBytes = await ref
.read(productRepositoryProvider)
.getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
await printProductLabel(
final qrBytes = await ref
.read(productRepositoryProvider)
.getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (context.mounted) {
await safePrint(context, () => printProductLabel(
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
@@ -502,14 +501,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打印失败:$e'),
backgroundColor: AppTheme.danger),
);
}
));
}
},
child: const Text('打标签',
@@ -528,10 +528,10 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
Future<void> _print() async {
if (_bytes == null || _printing) return;
setState(() { _printing = true; _printStatus = '正在打印...'; });
final p = widget.product;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
try {
final p = widget.product;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
await printProductLabel(
await safePrint(context, () => printProductLabel(
qrBytes: _bytes!,
name: p.name,
code: p.code,
@@ -540,13 +540,7 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打印失败:$e'), backgroundColor: Colors.red),
);
}
));
} finally {
if (mounted) setState(() { _printing = false; _printStatus = ''; });
}
@@ -1578,8 +1578,6 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
'格式:选项编号 | 选项名称 | 备注'),
_ImportSlot('商品规格', '/import/product-specs',
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
_ImportSlot('商品编码', '/import/product-codes',
'格式:商品编码:xxxx'),
_ImportSlot('库存', '/import/inventory',
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
];
@@ -137,7 +137,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
Future<void> _printOrder() async {
if (_loadedOrder == null) return;
await printStockInOrder(_loadedOrder!);
await safePrint(context, () => printStockInOrder(_loadedOrder!));
}
Future<void> _submit(bool asDraft) async {
@@ -1,4 +1,6 @@
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, printProductLabel;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -248,7 +250,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
TextButton(
onPressed: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
await printStockInOrder(order);
if (context.mounted) {
await safePrint(context, () => printStockInOrder(order));
}
},
child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
@@ -690,7 +694,7 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
IconButton(
icon: const Icon(Icons.print_outlined, color: Colors.white),
tooltip: '打印',
onPressed: () => printStockInOrder(_loadedOrder!),
onPressed: () => safePrint(context, () => printStockInOrder(_loadedOrder!)),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
@@ -948,7 +952,8 @@ class _LabelPrintDialogState extends State<_LabelPrintDialog> {
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e) {
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _status = '${i + 1}行打印失败:$e');
}
}
@@ -222,7 +222,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Future<void> _printOrder() async {
if (_loadedOrder == null) return;
await printStockOutOrder(_loadedOrder!);
await safePrint(context, () => printStockOutOrder(_loadedOrder!));
}
Future<void> _submit(bool asDraft) async {
@@ -1,5 +1,7 @@
import '../../repositories/product_repository.dart';
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder, printProductLabel;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -255,7 +257,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
TextButton(
onPressed: () async {
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
await printStockOutOrder(order);
if (context.mounted) {
await safePrint(context, () => printStockOutOrder(order));
}
},
child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
@@ -682,7 +686,7 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
IconButton(
icon: const Icon(Icons.print_outlined, color: Colors.white),
tooltip: '打印',
onPressed: () => printStockOutOrder(_loadedOrder!),
onPressed: () => safePrint(context, () => printStockOutOrder(_loadedOrder!)),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
@@ -942,10 +946,9 @@ class _LabelPrintDialogState extends State<_LabelPrintDialog> {
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e) {
if (mounted) {
setState(() => _status = '${i + 1}行打印失败:$e');
}
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _status = '${i + 1}行打印失败:$e');
}
}
if (mounted) {
+12 -28
View File
@@ -69,10 +69,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.2.0"
collection:
dependency: transitive
description:
@@ -205,14 +205,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router:
dependency: "direct main"
description:
@@ -225,10 +217,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "2.0.0"
http:
dependency: transitive
description:
@@ -373,22 +365,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.4.1"
package_config:
dependency: transitive
description:
@@ -694,10 +678,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
@@ -734,10 +718,10 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
@@ -758,10 +742,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
version: "15.2.0"
web:
dependency: "direct main"
description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: jiu_client
description: 酒库管理系统 - 酒店仓库管理
publish_to: 'none'
version: 1.0.0+1
version: 1.0.3+1
environment:
sdk: '>=3.0.0 <4.0.0'
+17
View File
@@ -0,0 +1,17 @@
flutter/ephemeral/
# Visual Studio user-specific files.
*.suo
*.user
*.userosscache
*.sln.docstates
# Visual Studio build-related files.
x64/
x86/
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
+108
View File
@@ -0,0 +1,108 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(jiu_client LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "jiu_client")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(VERSION 3.14...3.25)
# Define build configuration option.
get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
if(IS_MULTICONFIG)
set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
CACHE STRING "" FORCE)
else()
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
endif()
# Define settings for the Profile build mode.
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
# Use Unicode for all projects.
add_definitions(-DUNICODE -D_UNICODE)
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_17)
target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
target_compile_options(${TARGET} PRIVATE /EHsc)
target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
target_compile_definitions(${TARGET} PRIVATE "$<$<CONFIG:Debug>:_DEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# Support files are copied into place next to the executable, so that it can
# run in place. This is done instead of making a separate bundle (as on Linux)
# so that building and running from within Visual Studio will work.
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
# Make the "install" step default, as it's required to run.
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
if(PLUGIN_BUNDLED_LIBRARIES)
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
CONFIGURATIONS Profile;Release
COMPONENT Runtime)
+109
View File
@@ -0,0 +1,109 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.14)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
# Set fallback configurations for older versions of the flutter tool.
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
set(FLUTTER_TARGET_PLATFORM "windows-x64")
endif()
# === Flutter Library ===
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"flutter_export.h"
"flutter_windows.h"
"flutter_messenger.h"
"flutter_plugin_registrar.h"
"flutter_texture_registrar.h"
)
list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
add_dependencies(flutter flutter_assemble)
# === Wrapper ===
list(APPEND CPP_WRAPPER_SOURCES_CORE
"core_implementations.cc"
"standard_codec.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
"plugin_registrar.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
list(APPEND CPP_WRAPPER_SOURCES_APP
"flutter_engine.cc"
"flutter_view_controller.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
# Wrapper sources needed for a plugin.
add_library(flutter_wrapper_plugin STATIC
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
)
apply_standard_settings(flutter_wrapper_plugin)
set_target_properties(flutter_wrapper_plugin PROPERTIES
POSITION_INDEPENDENT_CODE ON)
set_target_properties(flutter_wrapper_plugin PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
target_include_directories(flutter_wrapper_plugin PUBLIC
"${WRAPPER_ROOT}/include"
)
add_dependencies(flutter_wrapper_plugin flutter_assemble)
# Wrapper sources needed for the runner.
add_library(flutter_wrapper_app STATIC
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_APP}
)
apply_standard_settings(flutter_wrapper_app)
target_link_libraries(flutter_wrapper_app PUBLIC flutter)
target_include_directories(flutter_wrapper_app PUBLIC
"${WRAPPER_ROOT}/include"
)
add_dependencies(flutter_wrapper_app flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
${PHONY_OUTPUT}
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
${FLUTTER_TARGET_PLATFORM} $<CONFIG>
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
)
@@ -0,0 +1,17 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter/plugin_registry.h>
// Registers Flutter plugins.
void RegisterPlugins(flutter::PluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
@@ -0,0 +1,26 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
printing
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
+40
View File
@@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 3.14)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME} WIN32
"flutter_window.cpp"
"main.cpp"
"utils.cpp"
"win32_window.cpp"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
"Runner.rc"
"runner.exe.manifest"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the build version.
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
# Disable Windows macros that collide with C++ standard library functions.
target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
# Add dependency libraries and include directories. Add any application-specific
# dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
+121
View File
@@ -0,0 +1,121 @@
// Microsoft Visual C++ generated resource script.
//
#pragma code_page(65001)
#include "resource.h"
#define APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 2 resource.
//
#include "winres.h"
/////////////////////////////////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
// English (United States) resources
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
#ifdef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// TEXTINCLUDE
//
1 TEXTINCLUDE
BEGIN
"resource.h\0"
END
2 TEXTINCLUDE
BEGIN
"#include ""winres.h""\r\n"
"\0"
END
3 TEXTINCLUDE
BEGIN
"\r\n"
"\0"
END
#endif // APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// Icon
//
// Icon with lowest ID value placed first to ensure application icon
// remains consistent on all systems.
IDI_APP_ICON ICON "resources\\app_icon.ico"
/////////////////////////////////////////////////////////////////////////////
//
// Version
//
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
#else
#define VERSION_AS_NUMBER 1,0,0,0
#endif
#if defined(FLUTTER_VERSION)
#define VERSION_AS_STRING FLUTTER_VERSION
#else
#define VERSION_AS_STRING "1.0.0"
#endif
VS_VERSION_INFO VERSIONINFO
FILEVERSION VERSION_AS_NUMBER
PRODUCTVERSION VERSION_AS_NUMBER
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS__WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.yanmei" "\0"
VALUE "FileDescription", "jiu_client" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "jiu_client" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.yanmei. All rights reserved." "\0"
VALUE "OriginalFilename", "jiu_client.exe" "\0"
VALUE "ProductName", "jiu_client" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1252
END
END
#endif // English (United States) resources
/////////////////////////////////////////////////////////////////////////////
#ifndef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 3 resource.
//
/////////////////////////////////////////////////////////////////////////////
#endif // not APSTUDIO_INVOKED
+71
View File
@@ -0,0 +1,71 @@
#include "flutter_window.h"
#include <optional>
#include "flutter/generated_plugin_registrant.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
: project_(project) {}
FlutterWindow::~FlutterWindow() {}
bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) {
return false;
}
RECT frame = GetClientArea();
// The size here must match the window dimensions to avoid unnecessary surface
// creation / destruction in the startup path.
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
frame.right - frame.left, frame.bottom - frame.top, project_);
// Ensure that basic setup of the controller was successful.
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
return false;
}
RegisterPlugins(flutter_controller_->engine());
SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() {
this->Show();
});
// Flutter can complete the first frame before the "show window" callback is
// registered. The following call ensures a frame is pending to ensure the
// window is shown. It is a no-op if the first frame hasn't completed yet.
flutter_controller_->ForceRedraw();
return true;
}
void FlutterWindow::OnDestroy() {
if (flutter_controller_) {
flutter_controller_ = nullptr;
}
Win32Window::OnDestroy();
}
LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
// Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) {
std::optional<LRESULT> result =
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
lparam);
if (result) {
return *result;
}
}
switch (message) {
case WM_FONTCHANGE:
flutter_controller_->engine()->ReloadSystemFonts();
break;
}
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
}
+33
View File
@@ -0,0 +1,33 @@
#ifndef RUNNER_FLUTTER_WINDOW_H_
#define RUNNER_FLUTTER_WINDOW_H_
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <memory>
#include "win32_window.h"
// A window that does nothing but host a Flutter view.
class FlutterWindow : public Win32Window {
public:
// Creates a new FlutterWindow hosting a Flutter view running |project|.
explicit FlutterWindow(const flutter::DartProject& project);
virtual ~FlutterWindow();
protected:
// Win32Window:
bool OnCreate() override;
void OnDestroy() override;
LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
LPARAM const lparam) noexcept override;
private:
// The project to run.
flutter::DartProject project_;
// The Flutter instance hosted by this window.
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
};
#endif // RUNNER_FLUTTER_WINDOW_H_
+43
View File
@@ -0,0 +1,43 @@
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <windows.h>
#include "flutter_window.h"
#include "utils.h"
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
_In_ wchar_t *command_line, _In_ int show_command) {
// Attach to console when present (e.g., 'flutter run') or create a
// new console when running with a debugger.
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
CreateAndAttachConsole();
}
// Initialize COM, so that it is available for use in the library and/or
// plugins.
::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
flutter::DartProject project(L"data");
std::vector<std::string> command_line_arguments =
GetCommandLineArguments();
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"jiu_client", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);
::MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0)) {
::TranslateMessage(&msg);
::DispatchMessage(&msg);
}
::CoUninitialize();
return EXIT_SUCCESS;
}
+16
View File
@@ -0,0 +1,16 @@
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ generated include file.
// Used by Runner.rc
//
#define IDI_APP_ICON 101
// Next default values for new objects
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 102
#define _APS_NEXT_COMMAND_VALUE 40001
#define _APS_NEXT_CONTROL_VALUE 1001
#define _APS_NEXT_SYMED_VALUE 101
#endif
#endif
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
+65
View File
@@ -0,0 +1,65 @@
#include "utils.h"
#include <flutter_windows.h>
#include <io.h>
#include <stdio.h>
#include <windows.h>
#include <iostream>
void CreateAndAttachConsole() {
if (::AllocConsole()) {
FILE *unused;
if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
_dup2(_fileno(stdout), 1);
}
if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
_dup2(_fileno(stdout), 2);
}
std::ios::sync_with_stdio();
FlutterDesktopResyncOutputStreams();
}
}
std::vector<std::string> GetCommandLineArguments() {
// Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
int argc;
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
if (argv == nullptr) {
return std::vector<std::string>();
}
std::vector<std::string> command_line_arguments;
// Skip the first argument as it's the binary name.
for (int i = 1; i < argc; i++) {
command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
}
::LocalFree(argv);
return command_line_arguments;
}
std::string Utf8FromUtf16(const wchar_t* utf16_string) {
if (utf16_string == nullptr) {
return std::string();
}
unsigned int target_length = ::WideCharToMultiByte(
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
-1, nullptr, 0, nullptr, nullptr)
-1; // remove the trailing null character
int input_length = (int)wcslen(utf16_string);
std::string utf8_string;
if (target_length == 0 || target_length > utf8_string.max_size()) {
return utf8_string;
}
utf8_string.resize(target_length);
int converted_length = ::WideCharToMultiByte(
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
input_length, utf8_string.data(), target_length, nullptr, nullptr);
if (converted_length == 0) {
return std::string();
}
return utf8_string;
}
+19
View File
@@ -0,0 +1,19 @@
#ifndef RUNNER_UTILS_H_
#define RUNNER_UTILS_H_
#include <string>
#include <vector>
// Creates a console for the process, and redirects stdout and stderr to
// it for both the runner and the Flutter library.
void CreateAndAttachConsole();
// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
// encoded in UTF-8. Returns an empty std::string on failure.
std::string Utf8FromUtf16(const wchar_t* utf16_string);
// Gets the command line arguments passed in as a std::vector<std::string>,
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
std::vector<std::string> GetCommandLineArguments();
#endif // RUNNER_UTILS_H_
+288
View File
@@ -0,0 +1,288 @@
#include "win32_window.h"
#include <dwmapi.h>
#include <flutter_windows.h>
#include "resource.h"
namespace {
/// Window attribute that enables dark mode window decorations.
///
/// Redefined in case the developer's machine has a Windows SDK older than
/// version 10.0.22000.0.
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
/// Registry key for app theme preference.
///
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
/// value indicates apps should use light mode.
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
// The number of Win32Window objects that currently exist.
static int g_active_window_count = 0;
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
// Scale helper to convert logical scaler values to physical using passed in
// scale factor
int Scale(int source, double scale_factor) {
return static_cast<int>(source * scale_factor);
}
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
// This API is only needed for PerMonitor V1 awareness mode.
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
HMODULE user32_module = LoadLibraryA("User32.dll");
if (!user32_module) {
return;
}
auto enable_non_client_dpi_scaling =
reinterpret_cast<EnableNonClientDpiScaling*>(
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
if (enable_non_client_dpi_scaling != nullptr) {
enable_non_client_dpi_scaling(hwnd);
}
FreeLibrary(user32_module);
}
} // namespace
// Manages the Win32Window's window class registration.
class WindowClassRegistrar {
public:
~WindowClassRegistrar() = default;
// Returns the singleton registrar instance.
static WindowClassRegistrar* GetInstance() {
if (!instance_) {
instance_ = new WindowClassRegistrar();
}
return instance_;
}
// Returns the name of the window class, registering the class if it hasn't
// previously been registered.
const wchar_t* GetWindowClass();
// Unregisters the window class. Should only be called if there are no
// instances of the window.
void UnregisterWindowClass();
private:
WindowClassRegistrar() = default;
static WindowClassRegistrar* instance_;
bool class_registered_ = false;
};
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
const wchar_t* WindowClassRegistrar::GetWindowClass() {
if (!class_registered_) {
WNDCLASS window_class{};
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
window_class.lpszClassName = kWindowClassName;
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.cbClsExtra = 0;
window_class.cbWndExtra = 0;
window_class.hInstance = GetModuleHandle(nullptr);
window_class.hIcon =
LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
window_class.hbrBackground = 0;
window_class.lpszMenuName = nullptr;
window_class.lpfnWndProc = Win32Window::WndProc;
RegisterClass(&window_class);
class_registered_ = true;
}
return kWindowClassName;
}
void WindowClassRegistrar::UnregisterWindowClass() {
UnregisterClass(kWindowClassName, nullptr);
class_registered_ = false;
}
Win32Window::Win32Window() {
++g_active_window_count;
}
Win32Window::~Win32Window() {
--g_active_window_count;
Destroy();
}
bool Win32Window::Create(const std::wstring& title,
const Point& origin,
const Size& size) {
Destroy();
const wchar_t* window_class =
WindowClassRegistrar::GetInstance()->GetWindowClass();
const POINT target_point = {static_cast<LONG>(origin.x),
static_cast<LONG>(origin.y)};
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
double scale_factor = dpi / 96.0;
HWND window = CreateWindow(
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
nullptr, nullptr, GetModuleHandle(nullptr), this);
if (!window) {
return false;
}
UpdateTheme(window);
return OnCreate();
}
bool Win32Window::Show() {
return ShowWindow(window_handle_, SW_SHOWNORMAL);
}
// static
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
if (message == WM_NCCREATE) {
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
SetWindowLongPtr(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
EnableFullDpiSupportIfAvailable(window);
that->window_handle_ = window;
} else if (Win32Window* that = GetThisFromHandle(window)) {
return that->MessageHandler(window, message, wparam, lparam);
}
return DefWindowProc(window, message, wparam, lparam);
}
LRESULT
Win32Window::MessageHandler(HWND hwnd,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
switch (message) {
case WM_DESTROY:
window_handle_ = nullptr;
Destroy();
if (quit_on_close_) {
PostQuitMessage(0);
}
return 0;
case WM_DPICHANGED: {
auto newRectSize = reinterpret_cast<RECT*>(lparam);
LONG newWidth = newRectSize->right - newRectSize->left;
LONG newHeight = newRectSize->bottom - newRectSize->top;
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
return 0;
}
case WM_SIZE: {
RECT rect = GetClientArea();
if (child_content_ != nullptr) {
// Size and position the child window.
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
rect.bottom - rect.top, TRUE);
}
return 0;
}
case WM_ACTIVATE:
if (child_content_ != nullptr) {
SetFocus(child_content_);
}
return 0;
case WM_DWMCOLORIZATIONCOLORCHANGED:
UpdateTheme(hwnd);
return 0;
}
return DefWindowProc(window_handle_, message, wparam, lparam);
}
void Win32Window::Destroy() {
OnDestroy();
if (window_handle_) {
DestroyWindow(window_handle_);
window_handle_ = nullptr;
}
if (g_active_window_count == 0) {
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
}
}
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
return reinterpret_cast<Win32Window*>(
GetWindowLongPtr(window, GWLP_USERDATA));
}
void Win32Window::SetChildContent(HWND content) {
child_content_ = content;
SetParent(content, window_handle_);
RECT frame = GetClientArea();
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
frame.bottom - frame.top, true);
SetFocus(child_content_);
}
RECT Win32Window::GetClientArea() {
RECT frame;
GetClientRect(window_handle_, &frame);
return frame;
}
HWND Win32Window::GetHandle() {
return window_handle_;
}
void Win32Window::SetQuitOnClose(bool quit_on_close) {
quit_on_close_ = quit_on_close;
}
bool Win32Window::OnCreate() {
// No-op; provided for subclasses.
return true;
}
void Win32Window::OnDestroy() {
// No-op; provided for subclasses.
}
void Win32Window::UpdateTheme(HWND const window) {
DWORD light_mode;
DWORD light_mode_size = sizeof(light_mode);
LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
kGetPreferredBrightnessRegValue,
RRF_RT_REG_DWORD, nullptr, &light_mode,
&light_mode_size);
if (result == ERROR_SUCCESS) {
BOOL enable_dark_mode = light_mode == 0;
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
&enable_dark_mode, sizeof(enable_dark_mode));
}
}
+102
View File
@@ -0,0 +1,102 @@
#ifndef RUNNER_WIN32_WINDOW_H_
#define RUNNER_WIN32_WINDOW_H_
#include <windows.h>
#include <functional>
#include <memory>
#include <string>
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
// inherited from by classes that wish to specialize with custom
// rendering and input handling
class Win32Window {
public:
struct Point {
unsigned int x;
unsigned int y;
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
};
struct Size {
unsigned int width;
unsigned int height;
Size(unsigned int width, unsigned int height)
: width(width), height(height) {}
};
Win32Window();
virtual ~Win32Window();
// Creates a win32 window with |title| that is positioned and sized using
// |origin| and |size|. New windows are created on the default monitor. Window
// sizes are specified to the OS in physical pixels, hence to ensure a
// consistent size this function will scale the inputted width and height as
// as appropriate for the default monitor. The window is invisible until
// |Show| is called. Returns true if the window was created successfully.
bool Create(const std::wstring& title, const Point& origin, const Size& size);
// Show the current window. Returns true if the window was successfully shown.
bool Show();
// Release OS resources associated with window.
void Destroy();
// Inserts |content| into the window tree.
void SetChildContent(HWND content);
// Returns the backing Window handle to enable clients to set icon and other
// window properties. Returns nullptr if the window has been destroyed.
HWND GetHandle();
// If true, closing this window will quit the application.
void SetQuitOnClose(bool quit_on_close);
// Return a RECT representing the bounds of the current client area.
RECT GetClientArea();
protected:
// Processes and route salient window messages for mouse handling,
// size change and DPI. Delegates handling of these to member overloads that
// inheriting classes can handle.
virtual LRESULT MessageHandler(HWND window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Called when CreateAndShow is called, allowing subclass window-related
// setup. Subclasses should return false if setup fails.
virtual bool OnCreate();
// Called when Destroy is called.
virtual void OnDestroy();
private:
friend class WindowClassRegistrar;
// OS callback called by message pump. Handles the WM_NCCREATE message which
// is passed when the non-client area is being created and enables automatic
// non-client DPI scaling so that the non-client area automatically
// responds to changes in DPI. All other messages are handled by
// MessageHandler.
static LRESULT CALLBACK WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Retrieves a class instance pointer for |window|
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
// Update the window frame's theme to match the system theme.
static void UpdateTheme(HWND const window);
bool quit_on_close_ = false;
// window handle for top level window.
HWND window_handle_ = nullptr;
// window handle for hosted content.
HWND child_content_ = nullptr;
};
#endif // RUNNER_WIN32_WINDOW_H_
+8
View File
@@ -59,6 +59,14 @@ server {
try_files /scan.html =404;
}
# 桌面客户端安装包下载(Windows .exe / macOS .zip
location /downloads/ {
alias /opt/jiu/downloads/;
add_header Content-Disposition "attachment";
add_header Cache-Control "no-cache";
autoindex off;
}
# 营销站点兜底(根路径及所有其他路径)
location / {
root /opt/jiu/marketing;
+53
View File
@@ -0,0 +1,53 @@
; Inno Setup script for 岩美酒库 Windows 客户端
; Compiled in CI:
; ISCC /DAppVer=<ver> "/DSrcDir=<ReleaseDir>" "/DOutDir=<dist>" jiu-installer.iss
; Produces: <OutDir>\jiu-windows-x64-setup.exe
#ifndef AppVer
#define AppVer "0.0.0"
#endif
#ifndef SrcDir
#define SrcDir "."
#endif
#ifndef OutDir
#define OutDir "."
#endif
#define MyAppName "岩美酒库"
#define MyAppExe "jiu_client.exe"
#define MyAppPublisher "岩美"
[Setup]
AppId={{A3F5C1E2-9B47-4D8A-B6E0-2C1D4F8A9E33}
AppName={#MyAppName}
AppVersion={#AppVer}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\Jiu
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
OutputDir={#OutDir}
OutputBaseFilename=jiu-windows-x64-setup
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
UninstallDisplayName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExe}
[Languages]
Name: "chinesesimp"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce
[Files]
Source: "{#SrcDir}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExe}"
Name: "{group}\卸载 {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{commondesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExe}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExe}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
+46 -118
View File
@@ -10,9 +10,9 @@
| 服务 | 地址 |
|------|------|
| Gitea Web UI | `http://192.168.3.200:3000` |
| Gitea SSH | `ssh://192.168.3.200:2222` |
| Actions 面板 | `http://192.168.3.200:3000/wangjia/jiu/actions` |
| Gitea Web UI | `https://git.51yanmei.com` |
| Gitea SSH | `ssh://git@git.51yanmei.com:2222` |
| Actions 面板 | `https://git.51yanmei.com/wangjia/jiu/actions` |
| 线上应用 | `https://jiu.51yanmei.com` |
| 管理端 Web | `https://jiu.51yanmei.com/app/` |
@@ -28,8 +28,8 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机
|------|------|
| `/Users/wangjia/bin/act_runner` | act_runner 二进制(v0.2.11 |
| `/Users/wangjia/bin/act_runner_start.sh` | 启动脚本(含 TCP 中继逻辑) |
| `/Users/wangjia/bin/tcp_relay.py` | Python3 TCP 中继`127.0.0.1:13000 → 192.168.3.200:3000` |
| `/Users/wangjia/.act_runner` | Runner 注册信息(地址指向 `127.0.0.1:13000` |
| `/Users/wangjia/bin/tcp_relay.py` | Python3 TCP 中继(旧内网方案,现已通过域名直连) |
| `/Users/wangjia/.act_runner` | Runner 注册信息 |
| `/Users/wangjia/.act_runner_config.yaml` | Runner 配置(label: `mac:host`capacity: 1 |
| `~/Library/LaunchAgents/com.jiu.act-runner.plist` | LaunchAgent plist,开机自启 |
| `/tmp/act_runner.log` | 运行日志 |
@@ -38,12 +38,6 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机
`.gitea/workflows/deploy.yml``ci.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`
### 为什么需要 TCP 中继(重要)
Shadowrocket(代理工具)的 Network Extension 会拦截 LaunchAgent 启动的 Go 二进制发出的 TCP 连接,即使 Shadowrocket 内已配置直连规则。现象:Go gRPC 报 `no route to host`,而同进程里的 Python3 socket 可以正常连接。
**解决方案:** 用 Python3 做透明 TCP 中继。`tcp_relay.py` 监听 `127.0.0.1:13000`,将连接转发到 `192.168.3.200:3000`。act_runner 的注册地址改为 `http://127.0.0.1:13000`,完全绕开 Shadowrocket 拦截。
### 检查 Runner 状态
```bash
@@ -51,7 +45,7 @@ Shadowrocket(代理工具)的 Network Extension 会拦截 LaunchAgent 启动
tail -f /tmp/act_runner.log
# 确认进程在跑
ps aux | grep -E "act_runner|tcp_relay" | grep -v grep
ps aux | grep act_runner | grep -v grep
# 手动重启(LaunchAgent 会自动重启,一般不需要)
kill $(pgrep -f act_runner_start.sh)
@@ -59,12 +53,10 @@ kill $(pgrep -f act_runner_start.sh)
### Runner 不工作时的排查步骤
1. **确认 NAS 可达**`curl -sf http://192.168.3.200:3000/api/v1/version`
2. **确认 relay 在跑**`ps aux | grep tcp_relay`
3. **确认 relay 端口监听**`nc -zv 127.0.0.1 13000`
4. **查看最近日志**`tail -30 /tmp/act_runner.log`
5. **手动测试**`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功)
6. **强制重启**`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉
1. **确认 Gitea 可达**`curl -sf https://git.51yanmei.com/api/v1/version`
2. **查看最近日志**`tail -30 /tmp/act_runner.log`
3. **手动测试**`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功)
4. **强制重启**`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉
### 重新注册 Runnertoken 失效时)
@@ -81,15 +73,13 @@ rm ~/.act_runner
# 4. 注册(从终端运行,不要从 LaunchAgent
/Users/wangjia/bin/act_runner register \
--no-interactive \
--instance http://192.168.3.200:3000 \
--instance https://git.51yanmei.com \
# SSH remote: ssh://git@git.51yanmei.com:2222/wangjia/jiu.git
--token <新token> \
--name mac-runner \
--labels mac:host
# 5. 把注册文件里的地址改回 127.0.0.1:13000
sed -i '' 's|http://192.168.3.200:3000|http://127.0.0.1:13000|' ~/.act_runner
# 6. 重启(LaunchAgent 自动接管)
# 5. 重启(LaunchAgent 自动接管)
kill $(pgrep -f act_runner_start.sh)
```
@@ -101,19 +91,19 @@ kill $(pgrep -f act_runner_start.sh)
### `deploy.yml` — 主部署流程
**触发条件:** push `main` 分支
**触发条件:** push `v*.*.*` tag
**步骤:**
1. `go test ./...` — 跑后端测试
2. `flutter test` — 跑前端测试
3. 编译后端:`GOOS=linux GOARCH=amd64 go build` 生成 `jiu-server`
4. 编译 Flutter Web`flutter build web --release --base-href=/app/`
5. 创建 Forgejo Release(版本号格式 `vYYYYMMDD.HHMM`),上传 `jiu-server``web.tar.gz`
6. SSH 到 EC2 部署:
- 停止 jiu 服务 → 替换二进制 → 启动 → 等待健康检查通过
- 替换 Flutter Web 静态文件到 `/opt/jiu/web/`
- 同步营销站点到 `/opt/jiu/marketing/`
- `nginx -s reload`
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 通知
### `manual.yml` — 手动部署 / 回滚
**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入目标 tag(如 `v1.0.0`
**步骤:** 只运行 `deploy.sh`,从 Forgejo Release 下载已有产物直接部署,跳过编译和测试。
### `ci.yml` — PR 测试流程
@@ -121,12 +111,6 @@ kill $(pgrep -f act_runner_start.sh)
**步骤:** 只跑测试(`go test` + `flutter test`),不构建不部署。
### `backup.yml` — 每日备份
**触发条件:** 每天凌晨 2:00cron
**步骤:** SSH 到 EC2,导出 MySQL 数据库备份,上传到指定目录。
---
## Secrets 配置
@@ -138,26 +122,23 @@ kill $(pgrep -f act_runner_start.sh)
| `EC2_SSH_KEY` | EC2 服务器的 SSH 私钥(PEM 格式,完整内容) |
| `EC2_HOST` | EC2 服务器 IP 或域名 |
| `EC2_USER` | EC2 SSH 用户名(如 `ec2-user``ubuntu` |
| `DB_PASSWORD` | MySQL root 密码(备份 workflow 使用 |
| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release |
| `FORGEJO_URL` | Gitea 内网地址,如 `http://192.168.3.200:3000` |
| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release、下载 asset |
| `FORGEJO_URL` | Gitea 地址:`https://git.51yanmei.com` |
| `TELEGRAM_TOKEN` | Telegram Bot Token(部署通知) |
| `TELEGRAM_CHAT_ID` | Telegram Chat ID(接收通知的对话) |
---
## 触发部署方式
日常开发流程:
```bash
# 开发 → 本地测试
go test ./...
flutter test
# 用户确认后推送到 NAS Gitea(触发自动部署)
git push nas main
# 开发完成,确认测试通过后打 tag
git tag -a v1.1.0 -m "v1.1.0"
git push nas main && git push nas v1.1.0
# → CI 自动:compile → test → release → deploy → Telegram 通知
```
推送到 `nas` remote(指向 `http://192.168.3.200:3000/wangjia/jiu.git`)后,Gitea Actions 自动运行 `deploy.yml`,全流程约 5~8 分钟。
**回滚:** Forgejo UI → Actions → Manual Deploy → 输入旧 tag → Run
---
@@ -166,17 +147,16 @@ git push nas main
```
/opt/jiu/
├── backend/
── jiu-server # Go 可执行文件
── jiu-server # Go 可执行文件
│ └── config/
│ └── version.yaml # 版本信息(由 CI 自动更新)
├── web/ # Flutter Web 构建产物(/app/ 路径)
├── marketing/ # 营销站点静态 HTML
│ ├── assets/ # CSS / SVG
│ ├── assets/
│ ├── index.html
│ ├── docs.html
│ ├── download.html
── scan.html
│ └── features/
│ ├── inventory.html
│ └── approval.html
── scan.html
├── images/ # 商品图片上传目录
└── web-old/ # 上一版本 Flutter Web(回滚备用)
```
@@ -188,64 +168,12 @@ Systemd 服务名:`jiu`
## Nginx 路由说明
配置文件:`deploy/nginx-jiu.conf`部署时手动 scp 至服务器
配置文件:`deploy/nginx-jiu.conf`CI 部署时自动更新
| 路径 | 服务 | 目录 |
|------|------|------|
| `/api/*`, `/health`, `/version` | Go 后端 API | proxy → `localhost:8080` |
| `/images/*` | 商品图片 | `/opt/jiu/images/` |
| `/app/` | Flutter 管理端 SPA | `/opt/jiu/web/` |
| `/` | 营销首页 | `/opt/jiu/marketing/index.html` |
| `/docs` | 文档页 | `/opt/jiu/marketing/docs.html` |
| `/download` | 下载页 | `/opt/jiu/marketing/download.html` |
| `/scan/<id>` | 商品防伪扫码页 | `/opt/jiu/marketing/scan.html` |
| `/features/*` | 功能介绍页 | `/opt/jiu/marketing/features/` |
| `/assets/*` | 营销站点静态资源 | `/opt/jiu/marketing/assets/` |
> **注意:** `nginx-jiu.conf` 只包含 `jiu.51yanmei.com` 的 server block。服务器上其他服务的 nginx 配置不受影响。更新 nginx 配置时只需替换此文件,然后 `nginx -t && nginx -s reload`
---
## 回滚方式
### 后端回滚
```bash
# SSH 到 EC2
sudo systemctl stop jiu
# 从上一个 Forgejo Release 下载 jiu-server
cp /path/to/old-jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
sudo systemctl start jiu
```
### Flutter Web 回滚
```bash
# 恢复上一版本
mv /opt/jiu/web /opt/jiu/web-broken
mv /opt/jiu/web-old /opt/jiu/web
sudo nginx -s reload
```
---
## 环境变量(jiu 服务)
Systemd service 文件(`/etc/systemd/system/jiu.service`)中配置:
```ini
[Service]
Environment=DB_HOST=127.0.0.1
Environment=DB_PORT=3306
Environment=DB_NAME=jiu
Environment=DB_USER=jiu
Environment=DB_PASSWORD=<从 secrets 获取>
Environment=JWT_SECRET=<随机字符串>
Environment=APP_VERSION=1.0.0
Environment=BUILD_DATE=2025-05-23
Environment=DOWNLOAD_URL_MACOS=
Environment=DOWNLOAD_URL_WINDOWS=
```
`APP_VERSION``BUILD_DATE` 在每次部署时通过 deploy.yml 更新(预留,目前读取 Forgejo Release tag)。
| 路径 | 服务 |
|------|------|
| `/api/*`, `/health`, `/version` | Go 后端 API |
| `/images/*` | 商品图片静态文件 |
| `/app/` | Flutter 管理端 SPA |
| `/` | 营销首页 |
| `/scan/<id>` | 商品扫码页 |
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# _env.sh — shared CI mirror env. `source` this from other scripts.
# Idempotent: only sets vars if not already provided by the environment.
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}"
# Ensure Homebrew tools are on PATH (macOS runners); harmless elsewhere.
case ":${PATH}:" in
*":/opt/homebrew/bin:"*) ;;
*) export PATH="/opt/homebrew/bin:${PATH}" ;;
esac
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# compile-macos.sh <tag> — build Flutter macOS desktop, package into dist/
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
echo "==> compile-macos: version=${VER}"
# Sync Flutter pubspec version (BSD sed on macOS)
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
# Build Flutter macOS
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}"
cd ..
# Package .app bundle into zip
mkdir -p dist
python3 - <<'PYEOF'
import zipfile, os
src = os.path.join('client', 'build', 'macos', 'Build', 'Products', 'Release', 'jiu.app')
dst = os.path.join('dist', 'jiu-macos-x64.zip')
with zipfile.ZipFile(dst, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(src):
for fname in files:
fp = os.path.join(root, fname)
zf.write(fp, os.path.join('jiu.app', os.path.relpath(fp, src)))
size = os.path.getsize(dst)
print(f'Packaged {dst} ({size // 1024 // 1024} MB)')
PYEOF
echo "==> compile-macos: done — dist/ contents:"
ls -lh dist/
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# compile-windows.sh <tag> — build Flutter Windows desktop, package into dist/
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
echo "==> compile-windows: version=${VER}"
# Sync Flutter pubspec version (done in the checkout, before copying out)
sed -i "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
# The Forgejo Windows runner checks out under
# C:\Windows\System32\config\systemprofile\.cache\act\...\hostexecutor
# Building there triggers WOW64 filesystem redirection (System32 -> SysWOW64)
# which breaks CMake/Flutter native paths. So copy the Flutter project to a
# clean short path OUTSIDE System32 and build there. This replaces the previous
# `subst W:` trick, which was fragile due to cmd/MSYS quoting.
BUILD_ROOT="/c/jiu-build"
BUILD_CLIENT="${BUILD_ROOT}/client"
echo "==> copying client/ to ${BUILD_CLIENT}"
rm -rf "$BUILD_ROOT"
mkdir -p "$BUILD_ROOT"
cp -r client "$BUILD_CLIENT"
# `cp -r` turns Flutter's plugin symlinks (windows/flutter/ephemeral/.plugin_symlinks/*)
# into real directories. flutter's createPluginSymlinks only cleans up *symlinks*, so it
# then fails to create a symlink over the copied real dir (errno 183 / PathExists).
# Drop all regenerable artifacts so the build below recreates them cleanly.
rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
"${BUILD_CLIENT}/.dart_tool" \
"${BUILD_CLIENT}/build"
# Build (run from the clean path; pushd/popd avoids needing $() to save cwd)
pushd "$BUILD_CLIENT" > /dev/null
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}"
popd > /dev/null
# Package the Release folder into a Windows installer with Inno Setup (ISCC).
# Inno Setup is installed by provision-windows.sh.
ISCC="/c/Program Files (x86)/Inno Setup 6/ISCC.exe"
if [ ! -f "$ISCC" ]; then
echo "ERROR: Inno Setup (ISCC) not found at '$ISCC'. Run provision-windows.sh first." >&2
exit 1
fi
# Run ISCC entirely under the clean build root: 32-bit ISCC would hit WOW64
# redirection if it read the .iss / wrote output under the System32 workspace.
cp deploy/windows/jiu-installer.iss "${BUILD_ROOT}/jiu-installer.iss"
mkdir -p "${BUILD_ROOT}/dist"
echo "==> building installer with Inno Setup (version ${VER})"
"$ISCC" \
"/DAppVer=${VER}" \
"/DSrcDir=C:\\jiu-build\\client\\build\\windows\\x64\\runner\\Release" \
"/DOutDir=C:\\jiu-build\\dist" \
"C:\\jiu-build\\jiu-installer.iss"
# Copy the installer back to the workspace dist/ via PowerShell (64-bit, so it
# can write the System32 workspace path without WOW64 redirection).
mkdir -p dist
echo "==> copying installer -> dist/jiu-windows-x64-setup.exe"
powershell -NoProfile -Command \
"Copy-Item 'C:\\jiu-build\\dist\\jiu-windows-x64-setup.exe' 'dist\\jiu-windows-x64-setup.exe' -Force"
echo "==> compile-windows: done — dist/ contents:"
ls -lh dist/
+9 -4
View File
@@ -2,10 +2,8 @@
# compile.sh <tag> — build backend + Flutter web, package into dist/
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
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}"
# shellcheck source=scripts/ci/_env.sh
. "$(dirname "$0")/_env.sh"
TAG="$1"
VER="${TAG#v}"
@@ -29,10 +27,17 @@ flutter build web --release \
"--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/
+36 -14
View File
@@ -10,17 +10,21 @@ 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 ]; then
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 -sf \
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, os, sys
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)
@@ -33,7 +37,7 @@ 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) as resp:
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']}")
@@ -53,6 +57,11 @@ 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
@@ -77,9 +86,14 @@ ${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/ngin
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 (from repo checkout)
# Upload marketing site (built by compile.sh)
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
web/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
/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
echo "==> deploy: running remote deploy"
@@ -91,21 +105,21 @@ sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
# Update version config
mkdir -p /opt/jiu/backend/config
cp /tmp/version.yaml /opt/jiu/backend/config/version.yaml
# 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 -sf http://localhost:8080/health > /dev/null 2>&1; then
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -sf http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
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
@@ -117,9 +131,17 @@ mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
# Update nginx config and reload
sudo cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf
sudo nginx -t && sudo nginx -s reload
# Publish desktop client installers to the nginx-served downloads dir
mkdir -p /opt/jiu/downloads
[ -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
# 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
+1 -1
View File
@@ -16,7 +16,7 @@ fi
MSG="${ICON} 岩美 ${TAG} ${LABEL}
https://jiu.51yanmei.com"
curl -sf -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
curl -f -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${MSG}" \
> /dev/null
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# provision-mac.sh — idempotent provisioning for the macOS host runner.
#
# Pre-warms everything the build jobs (compile.sh / compile-macos.sh) would
# otherwise download at runtime: Flutter macOS+web engine artifacts, pub
# packages, Go modules, npm deps. Safe to run on every pipeline run — it
# short-circuits via a stamp file keyed to the installed Flutter version, so
# once provisioned it returns in milliseconds and downloads nothing.
set -euo pipefail
SCRIPT_DIR="$(dirname "$0")"
# shellcheck source=scripts/ci/_env.sh
. "${SCRIPT_DIR}/_env.sh"
STAMP_DIR="${HOME}/.cache/jiu-ci"
STAMP="${STAMP_DIR}/provisioned-mac"
CUR="${STAMP_DIR}/.flutter-version-mac.cur"
mkdir -p "$STAMP_DIR"
# --- detection: skip if already provisioned for this Flutter version ---
if ! command -v flutter >/dev/null 2>&1; then
echo "ERROR: flutter not found on PATH. Install Flutter on this runner first." >&2
exit 1
fi
flutter --version --machine > "$CUR" 2>/dev/null || flutter --version > "$CUR" 2>&1 || true
if [ -s "$STAMP" ] && cmp -s "$STAMP" "$CUR"; then
echo "==> provision-mac: already provisioned (Flutter unchanged), skipping."
exit 0
fi
echo "==> provision-mac: not provisioned (or Flutter changed) — running."
# --- toolchain presence (do not auto-install system-level tools) ---
for tool in go node npm; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: '$tool' not found on PATH. Install it on this runner first." >&2
exit 1
fi
done
# --- warm Flutter platform engines (the macOS SDK download seen in CI) ---
echo "==> precache Flutter engines (macos, web)"
flutter precache --macos --web --universal
# --- warm dependency caches ---
echo "==> flutter pub get (client)"
(cd "${SCRIPT_DIR}/../../client" && flutter pub get)
echo "==> go mod download (backend)"
(cd "${SCRIPT_DIR}/../../backend" && go mod download)
echo "==> npm ci (web)"
(cd "${SCRIPT_DIR}/../../web" && npm ci --prefer-offline)
# --- mark provisioned ---
cp "$CUR" "$STAMP"
echo "==> provision-mac: done."
echo "--- versions ---"
flutter --version | head -1
go version
node --version

Some files were not shown because too many files have changed in this diff Show More