Compare commits

...

28 Commits

Author SHA1 Message Date
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
wangjia 626efa9c34 fix(ci): flutter analyze 忽略 warning/info,只在 error 时失败
Deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:20:02 +08:00
wangjia b69c23c88a feat(deploy): 部署完成后发送 Telegram 通知
Deploy / deploy (push) Failing after 48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:09:09 +08:00
wangjia 53f56ce086 feat(deploy): SemVer 版本管理 + 自动化发版流水线
- deploy.yml: tag 触发(v*.*.*),四阶段调脚本(compile/test/release/deploy)
- manual.yml: workflow_dispatch 支持手动回滚,checkout 指定 tag
- scripts/ci/compile.sh: 构建 Go 二进制 + Flutter Web,打包 dist/
- scripts/ci/test.sh: go test + flutter analyze
- scripts/ci/release.sh: 解析 CHANGELOG.md → 更新 version.yaml → 创建 Forgejo Release
- scripts/ci/deploy.sh: 从 Release 下载产物(自动/手动均可)→ 部署到 EC2
- CHANGELOG.md: Keep a Changelog 格式,初始 v1.0.0 条目
- backend: GetRelease 改读 version.yaml,移除 ENV 变量依赖
- backend/config/version.yaml: 重置为 v1.0.0
- web/download.html: 动态拉取 /api/v1/public/release 展示版本号和更新内容

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:41:08 +08:00
wangjia d48f9f4bd0 fix(client): 商品公开页暂无图片时显示门店名首字,替换硬编码岩美
Deploy / deploy (push) Successful in 1m20s
_EmptyGalleryContent 接收 shopName 参数,无图片时展示
门店名第一个字符的文字头像,不再显示品牌名。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:59:17 +08:00
wangjia b591bc522d fix(web): 禁用深色模式自动适配,修复渲染叠层问题
Deploy / deploy (push) Successful in 2m9s
Flutter Web canvas 在系统深色模式下背景透明,导致底层 HTML
内容透出形成叠层。营销站点未声明 color-scheme,浏览器自动调色
也会破坏白色背景设计。

- client/web/index.html: 添加 color-scheme=light meta 及 body 白色背景
- web/index.html: 添加 color-scheme=light meta
- web/assets/colors_and_type.css: :root 声明 color-scheme: light

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:40:03 +08:00
wangjia b15e422953 fix(nginx): /product/ 路径转发给 Flutter Web,修复扫码跳转营销页问题
Deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:34:28 +08:00
111 changed files with 7380 additions and 4672 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 通知。
+69 -80
View File
@@ -2,106 +2,95 @@ name: Deploy
on:
push:
branches: [main]
tags:
- 'v[0-9]*.[0-9]*.[0-9]*'
jobs:
deploy:
build-linux-web:
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: Go tests
working-directory: backend
run: go test ./...
- name: Compile (Linux backend + Flutter Web)
run: sh scripts/ci/compile.sh "${{ gitea.ref_name }}"
- name: Flutter tests
working-directory: client
run: flutter test
- name: Upload linux-web artifacts
uses: actions/upload-artifact@v3
with:
name: linux-web
path: dist/
- name: Build backend (linux/amd64)
working-directory: backend
run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
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
- name: Build Flutter Web
working-directory: client
run: flutter build web --release --base-href=/app/ --dart-define=BASE_URL=https://jiu.51yanmei.com --dart-define=PUBLIC_URL=https://jiu.51yanmei.com
steps:
- uses: actions/checkout@v4
- name: Package & Create Forgejo Release
- 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-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 windows artifacts
uses: actions/download-artifact@v3
with:
name: windows
path: dist/
- name: Test
run: sh scripts/ci/test.sh
- name: Release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
run: |
tar -czf web.tar.gz -C client/build web
tar -czf configs.tar.gz \
deploy/nginx-jiu.conf \
deploy/jiu.service \
deploy/production.env.template \
deploy/setup-ec2.sh \
deploy/docker-compose.yml \
deploy/docker-compose.jiu.yml
VERSION="v$(date +%Y%m%d.%H%M)"
COMMIT="${{ gitea.sha }}"
COMMIT_MSG=$(git log -1 --pretty="%s" HEAD)
RECENT_LOGS=$(git log -5 --pretty="- %s (%h)" HEAD)
BODY=$(printf '## 构建信息\n- **版本**: %s\n- **Commit**: %s\n- **时间**: %s\n\n## 最近提交\n%s' \
"${VERSION}" "${COMMIT}" "$(date '+%Y-%m-%d %H:%M:%S %Z')" "${RECENT_LOGS}")
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release.sh "${{ gitea.ref_name }}"
RESP=$(curl -s -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${VERSION}\",\"name\":\"Release ${VERSION}\",\"body\":$(echo "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"draft\":false,\"prerelease\":false}")
echo "API response: ${RESP}"
RELEASE_ID=$(echo "${RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@backend/jiu-server"
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@web.tar.gz"
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@configs.tar.gz"
echo "Release ${VERSION} created"
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s' "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to EC2
- name: Deploy
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: |
scp -O -i ~/.ssh/ec2_deploy.pem backend/jiu-server ${EC2_USER}@${EC2_HOST}:/tmp/jiu-server
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem" \
client/build/web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem" \
web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/
scp -O -i ~/.ssh/ec2_deploy.pem deploy/nginx-jiu.conf \
${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} << 'ENDSSH'
sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
sudo systemctl start jiu
for i in $(seq 1 30); do
curl -sf http://localhost:8080/health && break
sleep 2
done
rm -rf /opt/jiu/web-old
mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true
mv /tmp/jiu-web-new /opt/jiu/web
mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
sudo cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf
sudo nginx -t && sudo nginx -s reload
ENDSSH
run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}"
- name: Cleanup SSH key
- name: Notify
if: always()
run: rm -f ~/.ssh/ec2_deploy.pem
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}"
+28
View File
@@ -0,0 +1,28 @@
name: Manual Deploy
on:
workflow_dispatch:
inputs:
version:
description: 'Tag to deploy (e.g. v1.0.0)'
required: true
type: string
jobs:
deploy:
runs-on: mac
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.version }}
- name: Deploy
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy.sh "${{ inputs.version }}"
+4
View File
@@ -23,6 +23,10 @@ client/devtools_options.yaml
# Flutter 构建产物
client/build/
# 营销站构建产物
web/dist/
web/node_modules/
client/.dart_tool/
client/.flutter-plugins
client/.flutter-plugins-dependencies
+45
View File
@@ -0,0 +1,45 @@
# Changelog
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.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
初始版本发布,完成核心库存管理功能。
### 新功能
- 商品管理:多规格商品档案、图片、公开二维码扫码展示
- 库存管理:入库审核、出库审核、库存盘点、流水记录
- 财务管理:往来账目、对账单
- 用户权限:管理员 / 成员 / 只读三级权限控制
- 多租户隔离:门店数据完全独立
- Flutter Web 管理端(支持 PC 浏览器)
- 商品扫码公开展示页
- Excel 批量导入商品与库存
+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()`
+4 -4
View File
@@ -1,10 +1,10 @@
version: "1.1.1"
build_number: 2
version: "1.0.3"
build_number: 4
force_update: false
release_notes: "修复了离线模式问题,优化状态栏显示"
release_notes: "新增用户自助注册功能;商品标签打印修复竖向 20mm×38mm 布局"
download_urls:
macos: ""
windows: ""
ios: ""
android: ""
web: ""
web: "https://jiu.51yanmei.com/app"
+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})
}
+11 -18
View File
@@ -2,8 +2,6 @@ package handler
import (
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -80,23 +78,18 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
// GetRelease GET /api/v1/public/release (no auth)
func (h *PublicHandler) GetRelease(c *gin.Context) {
version := os.Getenv("APP_VERSION")
if version == "" {
version = "1.0.0"
cfg, err := loadVersionConfig()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"version": "1.0.0",
"release_notes": "",
"download_urls": gin.H{"web": "https://jiu.51yanmei.com/app"},
})
return
}
buildDate := os.Getenv("BUILD_DATE")
if buildDate == "" {
buildDate = time.Now().Format("2006-01-02")
}
c.JSON(http.StatusOK, gin.H{
"version": version,
"build_date": buildDate,
"download_urls": gin.H{
"macos": os.Getenv("DOWNLOAD_URL_MACOS"),
"windows": os.Getenv("DOWNLOAD_URL_WINDOWS"),
"web": "https://jiu.51yanmei.com/app",
},
"changelog": []gin.H{},
"version": cfg.Version,
"release_notes": cfg.ReleaseNotes,
"download_urls": cfg.DownloadURLs,
})
}
+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),
);
}
}
}
+17 -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';
@@ -189,7 +190,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(
@@ -424,7 +429,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 +483,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 = ''; });
}
@@ -130,7 +130,7 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
slivers: [
// 1. Gallery
SliverToBoxAdapter(
child: _HeroGallery(imageUrls: imageUrls),
child: _HeroGallery(imageUrls: imageUrls, shopName: shop?['name'] as String? ?? ''),
),
// 2. Verified ribbon
SliverToBoxAdapter(
@@ -227,7 +227,8 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
class _HeroGallery extends StatefulWidget {
final List<String> imageUrls;
const _HeroGallery({required this.imageUrls});
final String shopName;
const _HeroGallery({required this.imageUrls, this.shopName = ''});
@override
State<_HeroGallery> createState() => _HeroGalleryState();
@@ -280,7 +281,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
),
// Image or placeholder
if (urls.isEmpty)
_EmptyGalleryContent()
_EmptyGalleryContent(shopName: widget.shopName)
else
PageView.builder(
controller: _ctrl,
@@ -291,7 +292,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
child: Image.network(
urls[i],
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _EmptyGalleryContent(),
errorBuilder: (_, __, ___) => _EmptyGalleryContent(shopName: widget.shopName),
),
),
),
@@ -409,8 +410,12 @@ class _HeroGalleryState extends State<_HeroGallery> {
}
class _EmptyGalleryContent extends StatelessWidget {
final String shopName;
const _EmptyGalleryContent({this.shopName = ''});
@override
Widget build(BuildContext context) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -421,9 +426,9 @@ class _EmptyGalleryContent extends StatelessWidget {
color: const Color(0xFF0F3057),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text('岩美',
style: TextStyle(color: Colors.white70, fontSize: 18, fontWeight: FontWeight.w700)),
child: Center(
child: Text(initial,
style: const TextStyle(color: Colors.white70, fontSize: 28, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 12),
@@ -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'
+7
View File
@@ -19,6 +19,13 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="岩美酒库管理系统 — 酒店饮品库存与采购管理平台">
<meta name="color-scheme" content="light">
<style>
html, body {
background-color: #ffffff;
color-scheme: light;
}
</style>
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
+6
View File
@@ -47,6 +47,12 @@ server {
expires 0;
}
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理
location ~ ^/product/ {
root /opt/jiu/web;
try_files $uri /app/index.html;
}
# 扫码页(/scan/<id> → scan.htmlJS 从 URL 读取 id
location /scan/ {
root /opt/jiu/marketing;
+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>` | 商品扫码页 |
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# compile-windows.sh <tag> — build Flutter Windows desktop, package into dist/
set -euo pipefail
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}"
TAG="$1"
VER="${TAG#v}"
echo "==> compile-windows: version=${VER}"
# Sync Flutter pubspec version
sed -i "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
# Build Flutter Windows
cd 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}"
cd ..
# Package release folder into zip using Python (available on all platforms)
mkdir -p dist
python3 - <<'PYEOF'
import zipfile, os
src = os.path.join('client', 'build', 'windows', 'x64', 'runner', 'Release')
dst = os.path.join('dist', 'jiu-windows-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.relpath(fp, src))
size = os.path.getsize(dst)
print(f'Packaged {dst} ({size // 1024 // 1024} MB)')
PYEOF
echo "==> compile-windows: done — dist/ contents:"
ls -lh dist/
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 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}"
TAG="$1"
VER="${TAG#v}"
echo "==> compile: version=$VER"
# Sync Flutter pubspec version
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
# Build Go backend (linux/amd64 for EC2)
cd backend
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
cd ..
# Build Flutter Web
cd client
flutter build web --release \
--base-href=/app/ \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
cd ..
# Build marketing site
cd web
npm ci --prefer-offline
npm run build
cd ..
# Package artifacts
mkdir -p dist
mv backend/jiu-server dist/jiu-server
tar -czf dist/web.tar.gz -C client/build web
tar -czf dist/marketing.tar.gz -C web/dist .
echo "==> compile: done — dist/ contents:"
ls -lh dist/
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# deploy.sh <tag> — deploy release to EC2
# Works in two modes:
# 1. Automated (after compile.sh): uses dist/ built in the same job
# 2. Manual/rollback: downloads assets from Forgejo Release by tag
set -euo pipefail
TAG="$1"
echo "==> deploy: tag=${TAG}"
# Download from Forgejo if dist/ was not built in this job
if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ] || [ ! -f dist/marketing.tar.gz ]; then
echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo"
mkdir -p dist
curl -kf \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${TAG}" \
-o /tmp/release-info.json
python3 - <<'PYEOF'
import json, urllib.request, ssl, os, sys
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with open('/tmp/release-info.json') as f:
release = json.load(f)
token = os.environ['FORGEJO_TOKEN']
forgejo_url = os.environ['FORGEJO_URL']
repo = os.environ['GITEA_REPOSITORY']
release_id = release['id']
for asset in release.get('assets', []):
url = f"{forgejo_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset['id']}"
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
with urllib.request.urlopen(req, context=ctx) as resp:
with open(f"dist/{asset['name']}", 'wb') as out:
out.write(resp.read())
print(f"Downloaded: {asset['name']}")
PYEOF
fi
echo "==> deploy: dist/ contents:"
ls -lh dist/
# Extract configs (nginx, version.yaml, etc.)
rm -rf /tmp/jiu-configs
mkdir -p /tmp/jiu-configs
tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs
# Extract Flutter web build
rm -rf /tmp/jiu-web-new
mkdir -p /tmp/jiu-web-new
tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1
# Extract marketing site
rm -rf /tmp/jiu-marketing-new
mkdir -p /tmp/jiu-marketing-new
tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new
# Setup SSH
mkdir -p ~/.ssh
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
echo "==> deploy: uploading files to EC2"
# Upload backend binary
${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server"
# Upload version.yaml
${SCP} /tmp/jiu-configs/backend/config/version.yaml "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml"
# Upload nginx config
${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf"
# Upload Flutter web
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/"
# Upload marketing site (built by compile.sh)
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
echo "==> deploy: running remote deploy"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
# Replace backend binary
sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
# Update version configWorkingDirectory=/opt/jiu,读 config/version.yaml
mkdir -p /opt/jiu/config
cp /tmp/version.yaml /opt/jiu/config/version.yaml
# Start and health check
sudo systemctl start jiu
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -f http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
# Swap Flutter web app (atomic)
rm -rf /opt/jiu/web-old
mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true
mv /tmp/jiu-web-new /opt/jiu/web
# Update marketing site
mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
# Update nginx config and reload
sudo cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf
sudo nginx -t && sudo nginx -s reload
echo "Deploy complete!"
ENDSSH
# Cleanup SSH key
rm -f ~/.ssh/ec2_deploy.pem
rm -rf /tmp/jiu-configs /tmp/jiu-web-new
echo "==> deploy: done"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# notify.sh <tag> <status> — send Telegram deploy notification
set -euo pipefail
TAG="$1"
STATUS="$2"
if [ "$STATUS" = "success" ]; then
ICON="✅"
LABEL="部署成功"
else
ICON="❌"
LABEL="部署失败"
fi
MSG="${ICON} 岩美 ${TAG} ${LABEL}
https://jiu.51yanmei.com"
curl -f -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${MSG}" \
> /dev/null
echo "==> notify: sent to Telegram (${STATUS})"
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# release.sh <tag> — update version.yaml, package configs, create Forgejo Release
set -euo pipefail
TAG="$1"
VER="${TAG#v}"
echo "==> release: tag=${TAG} version=${VER}"
# Extract release notes from CHANGELOG.md (first summary line of the latest section)
RELEASE_NOTES=$(python3 - <<'PYEOF'
import re, sys
try:
with open('CHANGELOG.md') as f:
content = f.read()
parts = re.split(r'\n## ', '\n' + content)
if len(parts) > 1:
section = parts[1]
lines = section.strip().split('\n')
notes = []
for line in lines[1:]:
stripped = line.strip()
# 只在遇到同级 ## 时停止,### 子标题保留
if stripped.startswith('## '):
break
if stripped:
notes.append(stripped)
print('\n'.join(notes) if notes else lines[0])
else:
print('')
except Exception as e:
print('', file=sys.stderr)
print('')
PYEOF
)
echo "==> release: release_notes=${RELEASE_NOTES}"
# Update backend/config/version.yaml (version, build_number, release_notes, windows URL)
python3 - "${VER}" "${RELEASE_NOTES}" "${TAG}" "${FORGEJO_URL}" "${GITEA_REPOSITORY}" <<'PYEOF'
import sys
ver = sys.argv[1]
notes = sys.argv[2]
tag = sys.argv[3]
furl = sys.argv[4].rstrip('/')
repo = sys.argv[5]
win_url = f"{furl}/{repo}/releases/download/{tag}/jiu-windows-x64.zip"
lines = []
with open('backend/config/version.yaml') as f:
for line in f:
if line.startswith('version:'):
lines.append('version: "' + ver + '"\n')
elif line.startswith('build_number:'):
try:
build = int(line.split(':', 1)[1].strip()) + 1
except Exception:
build = 1
lines.append('build_number: ' + str(build) + '\n')
elif line.startswith('release_notes:'):
lines.append('release_notes: "' + notes.replace('\\', '\\\\').replace('"', '\\"') + '"\n')
elif line.startswith(' windows:'):
lines.append(' windows: "' + win_url + '"\n')
else:
lines.append(line)
with open('backend/config/version.yaml', 'w') as f:
f.writelines(lines)
print('version.yaml updated to', ver, '| windows:', win_url)
PYEOF
# Package configs.tar.gz (includes updated version.yaml)
tar -czf dist/configs.tar.gz \
deploy/nginx-jiu.conf \
deploy/jiu.service \
deploy/production.env.template \
deploy/setup-ec2.sh \
deploy/docker-compose.yml \
deploy/docker-compose.jiu.yml \
backend/config/version.yaml
echo "==> release: creating Forgejo Release ${TAG}"
# Build release body from CHANGELOG excerpt
BODY=$(python3 - "${TAG}" "${RELEASE_NOTES}" <<'PYEOF'
import sys, json
tag = sys.argv[1]
notes = sys.argv[2]
body = '## ' + tag + '\n\n' + notes
print(json.dumps(body))
PYEOF
)
HTTP_CODE=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Release ${TAG}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")
RESP=$(cat /tmp/release_resp.json)
echo "==> release: API response (HTTP ${HTTP_CODE}): ${RESP}"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "==> release: FAILED — HTTP ${HTTP_CODE}"
exit 1
fi
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}")
echo "==> release: release_id=${RELEASE_ID}"
# Upload assets
upload_asset() {
local file="$1"
local code
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${file}")
echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then exit 1; fi
}
upload_asset dist/jiu-server
upload_asset dist/web.tar.gz
upload_asset dist/marketing.tar.gz
upload_asset dist/configs.tar.gz
upload_asset dist/jiu-windows-x64.zip
echo "==> release: done — Release ${TAG} created"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# test.sh — run backend and frontend checks
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}"
echo "==> test: go test"
cd backend
go test ./...
cd ..
echo "==> test: flutter analyze"
cd client
flutter analyze --no-fatal-infos --no-fatal-warnings
cd ..
echo "==> test: done"
+1 -1
View File
@@ -6,7 +6,7 @@ cd "$(dirname "$0")/.."
EC2_HOST="${EC2_HOST:-$(grep EC2_HOST ~/.env 2>/dev/null | cut -d= -f2)}"
EC2_USER="${EC2_USER:-$(grep EC2_USER ~/.env 2>/dev/null | cut -d= -f2)}"
EC2_KEY="${EC2_KEY:-~/.ssh/wangjia.pem}"
FORGEJO_URL="http://192.168.3.200:3000"
FORGEJO_URL="https://git.51yanmei.com"
FORGEJO_TOKEN="${FORGEJO_TOKEN:-$(grep FORGEJO_TOKEN ~/.env 2>/dev/null | cut -d= -f2)}"
echo "==> 1/5 Go 测试"
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
// dev-proxy.js — 本地开发反向代理
// localhost:3000 → 营销站 :3001 / Flutter Web :8888
// /app/* 路由到 Flutter,其余路由到 Eleventy
// 自动把 Flutter index.html 里的 base href="/" 改写为 /app/,使静态资源请求正确走代理
const http = require('http');
const net = require('net');
const PROXY_PORT = parseInt(process.env.PROXY_PORT || '3000', 10);
const ELEVENTY_PORT = parseInt(process.env.ELEVENTY_PORT || '3001', 10);
const FLUTTER_PORT = parseInt(process.env.FLUTTER_PORT || '9090', 10);
const BACKEND_PORT = parseInt(process.env.BACKEND_PORT || '8080', 10);
function isFlutter(url) {
return url === '/app' || url.startsWith('/app/');
}
function isBackend(url) {
return url.startsWith('/api/') || url === '/health' || url.startsWith('/images/');
}
function stripApp(url) {
return url.slice(4) || '/';
}
function proxyHttp(req, res, port, path) {
const opts = {
hostname: 'localhost',
port,
path,
method: req.method,
headers: { ...req.headers, host: `localhost:${port}` },
};
const pr = http.request(opts, (upstream) => {
const ct = upstream.headers['content-type'] || '';
if (port === FLUTTER_PORT && ct.includes('text/html')) {
let body = '';
upstream.setEncoding('utf8');
upstream.on('data', (c) => { body += c; });
upstream.on('end', () => {
// rewrite base href so Flutter asset requests stay under /app/
body = body.replace(/(<base\s+href=")[^"]*(")/i, '$1/app/$2');
const headers = { ...upstream.headers };
headers['content-length'] = Buffer.byteLength(body);
delete headers['transfer-encoding'];
res.writeHead(upstream.statusCode, headers);
res.end(body);
});
} else {
res.writeHead(upstream.statusCode, upstream.headers);
upstream.pipe(res, { end: true });
}
});
pr.on('error', () => res.writeHead(502).end('upstream error'));
req.pipe(pr, { end: true });
}
const server = http.createServer((req, res) => {
if (isFlutter(req.url)) {
proxyHttp(req, res, FLUTTER_PORT, stripApp(req.url));
} else if (isBackend(req.url)) {
proxyHttp(req, res, BACKEND_PORT, req.url);
} else {
proxyHttp(req, res, ELEVENTY_PORT, req.url);
}
});
// WebSocket 透传(Eleventy 热重载 / Flutter DevTools
server.on('upgrade', (req, socket, head) => {
const port = isFlutter(req.url) ? FLUTTER_PORT
: isBackend(req.url) ? BACKEND_PORT
: ELEVENTY_PORT;
const path = isFlutter(req.url) ? stripApp(req.url) : req.url;
const conn = net.createConnection(port, 'localhost', () => {
conn.write(`${req.method} ${path} HTTP/1.1\r\n`);
for (const [k, v] of Object.entries(req.headers)) {
conn.write(`${k}: ${v}\r\n`);
}
conn.write('\r\n');
if (head && head.length) conn.write(head);
socket.pipe(conn);
conn.pipe(socket);
});
conn.on('error', () => socket.destroy());
socket.on('error', () => conn.destroy());
});
server.listen(PROXY_PORT, () => {
console.log(`[proxy] http://localhost:${PROXY_PORT}`);
console.log(` / → Eleventy :${ELEVENTY_PORT}`);
console.log(` /app/ → Flutter Web :${FLUTTER_PORT}`);
});
+93 -7
View File
@@ -32,6 +32,9 @@ BACKEND_PID_FILE="$LOG_DIR/backend.pid"
BACKEND_HASH_FILE="$LOG_DIR/backend.hash"
WEB_LOG="$LOG_DIR/web.log"
WEB_PID_FILE="$LOG_DIR/web.pid"
ELEVENTY_LOG="$LOG_DIR/eleventy.log"
ELEVENTY_PID_FILE="$LOG_DIR/eleventy.pid"
PROXY_PID_FILE="$LOG_DIR/proxy.pid"
mkdir -p "$LOG_DIR"
@@ -115,6 +118,79 @@ RUN_BACKEND=true
RUN_FRONTEND=true # macOS
RUN_WEB=false
cmd_marketing() {
local WEB_DIR="$ROOT/web"
local FLUTTER_WEB_PORT=9090
local ELEVENTY_PORT=3001
local PROXY_PORT=3000
# 检查依赖
if ! command -v node &>/dev/null; then
error "未找到 node,请安装 Node.js"
exit 1
fi
if ! command -v flutter &>/dev/null; then
error "未找到 flutter"
exit 1
fi
# 清理旧进程
for pid_file in "$ELEVENTY_PID_FILE" "$PROXY_PID_FILE" "$WEB_PID_FILE"; do
if [ -f "$pid_file" ]; then
pid=$(cat "$pid_file")
kill "$pid" 2>/dev/null || true
rm -f "$pid_file"
fi
done
for port in $ELEVENTY_PORT $FLUTTER_WEB_PORT $PROXY_PORT; do
pids=$(lsof -ti:"$port" 2>/dev/null || true)
[ -n "$pids" ] && echo "$pids" | xargs kill -9 2>/dev/null || true
done
sleep 0.5
# 启动后端(如已运行则跳过)
if ! curl -s -o /dev/null http://localhost:8080/health 2>/dev/null; then
info "启动后端..."
cd "$BACKEND_DIR"
go run main.go >>"$BACKEND_LOG" 2>&1 &
local bp=$!
echo "$bp" > "$BACKEND_PID_FILE"
wait_backend
cd "$ROOT"
else
success "后端已在运行 → http://localhost:8080"
fi
# 启动 Eleventy(宣传站)在后台,3001 口
info "启动 Eleventy(宣传站后台)→ :${ELEVENTY_PORT}..."
cd "$WEB_DIR"
npx eleventy --serve --port=$ELEVENTY_PORT >>"$ELEVENTY_LOG" 2>&1 &
echo "$!" > "$ELEVENTY_PID_FILE"
cd "$ROOT"
# 启动代理(后台)
info "启动代理(后台)→ http://localhost:${PROXY_PORT}..."
PROXY_PORT=$PROXY_PORT ELEVENTY_PORT=$ELEVENTY_PORT FLUTTER_PORT=$FLUTTER_WEB_PORT \
node "$ROOT/scripts/dev-proxy.js" >>"$LOG_DIR/proxy.log" 2>&1 &
echo "$!" > "$PROXY_PID_FILE"
sleep 1
echo ""
success "代理已就绪:http://localhost:${PROXY_PORT}(宣传站)/ http://localhost:${PROXY_PORT}/app/Flutter"
echo -e " ${CYAN}Eleventy 日志:tail -f $ELEVENTY_LOG${NC}"
echo ""
info "在前台启动 Flutter Web(支持热重载,按 r 重载,按 q 退出)..."
echo ""
# Flutter 在前台运行,支持交互热重载
cd "$CLIENT_DIR"
flutter pub get
flutter run -d chrome --web-port "$FLUTTER_WEB_PORT" \
"--dart-define=BASE_URL=http://localhost:8080" \
"--dart-define=PUBLIC_URL=http://localhost:${PROXY_PORT}" \
"--dart-define=APP_VERSION=dev"
}
show_help() {
echo "用法: sh scripts/dev.sh <命令> [选项]"
echo ""
@@ -126,6 +202,7 @@ show_help() {
echo " --backend-only 仅启动后端"
echo " --frontend-only 仅启动前端(macOS"
echo " --web-only 仅启动前端(Web"
echo " marketing 宣传站 + Flutter Web + 代理(localhost:3000/app/ 路由到 Flutter"
echo ""
echo "数据库命令:"
echo " seed <shop_code> 写入指定门店测试数据(如 S001)"
@@ -139,6 +216,8 @@ show_help() {
}
case "$COMMAND" in
marketing)
;; # 在底部所有函数定义完后执行
run)
case "${2:-}" in
--force) FORCE=true ;;
@@ -223,12 +302,13 @@ esac
cleanup() {
echo ""
info "正在关闭服务..."
if [ -f "$WEB_PID_FILE" ]; then
pid=$(cat "$WEB_PID_FILE")
kill "$pid" 2>/dev/null || true
rm -f "$WEB_PID_FILE"
info "Web 服务已停止"
fi
for pid_file in "$WEB_PID_FILE" "$ELEVENTY_PID_FILE" "$PROXY_PID_FILE"; do
if [ -f "$pid_file" ]; then
pid=$(cat "$pid_file")
kill "$pid" 2>/dev/null || true
rm -f "$pid_file"
fi
done
info "后端继续在后台运行。如需停止:sh scripts/dev.sh stop"
exit 0
}
@@ -284,7 +364,7 @@ wait_backend() {
info "等待后端启动..."
local i=0
while [ $i -lt 30 ]; do
if curl -s -o /dev/null http://localhost:8080/api/v1/auth/login 2>/dev/null; then
if curl -s -o /dev/null http://localhost:8080/health 2>/dev/null; then
success "后端已就绪 → http://localhost:8080"
return 0
fi
@@ -356,6 +436,12 @@ start_web_bg() {
}
# ════════════════════════════════════════════════════════
if [ "$COMMAND" = "marketing" ]; then
cmd_marketing
exit 0
fi
check_deps
if [ "$RUN_BACKEND" = true ]; then
+22
View File
@@ -0,0 +1,22 @@
module.exports = function(cfg) {
// 静态资源直接复制
cfg.addPassthroughCopy("assets");
// scan.html 面向消费者,独立维护,直接复制不经模板
cfg.addPassthroughCopy("scan.html");
cfg.addPassthroughCopy("features");
return {
dir: {
input: ".",
output: "dist",
includes: "_includes",
data: "_data",
layouts: "_includes",
},
// 同时支持 .html 和 .njk 作为模板格式
templateFormats: ["njk", "html", "md"],
htmlTemplateEngine: "njk",
markdownTemplateEngine: "njk",
};
};
+6
View File
@@ -0,0 +1,6 @@
scan.html
content/
features/approval.html
features/inventory.html
node_modules
dist
+44
View File
@@ -0,0 +1,44 @@
const fs = require('fs');
const path = require('path');
module.exports = function() {
const raw = fs.readFileSync(path.join(__dirname, '../../CHANGELOG.md'), 'utf8');
const lines = raw.split('\n');
const versions = [];
let current = null;
let currentSection = null;
for (const line of lines) {
const versionMatch = line.match(/^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})/);
if (versionMatch) {
if (current) versions.push(current);
if (versions.length >= 3) break;
current = { version: versionMatch[1], date: versionMatch[2], sections: [], intro: '' };
currentSection = null;
continue;
}
const sectionMatch = line.match(/^### (.+)/);
if (sectionMatch && current) {
currentSection = { type: sectionMatch[1], items: [] };
current.sections.push(currentSection);
continue;
}
const itemMatch = line.match(/^- (.+)/);
if (itemMatch && current) {
if (currentSection) {
currentSection.items.push(itemMatch[1]);
}
continue;
}
// Free-form text before first section (e.g. v1.0.0 intro paragraph)
if (line.trim() && current && !currentSection && !line.startsWith('#')) {
current.intro = current.intro ? current.intro + ' ' + line.trim() : line.trim();
}
}
if (current && versions.length < 3) versions.push(current);
return versions;
};
+51
View File
@@ -0,0 +1,51 @@
const fs = require('fs');
const path = require('path');
const md = require('markdown-it')({ html: false, breaks: false, linkify: false });
const TOC_GROUPS = [
{ group: '快速开始', nums: [1, 2, 3] },
{ group: '业务操作', nums: [4, 5, 6, 7, 8, 9] },
{ group: '基础设置', nums: [10, 11, 12] },
];
module.exports = function() {
const raw = fs.readFileSync(path.join(__dirname, '../content/docs.md'), 'utf8');
const lines = raw.split('\n');
// Split into chapters by "## {n}. {title}" headings
const chapters = [];
let current = null;
for (const line of lines) {
const match = line.match(/^## (\d+)\. (.+)/);
if (match) {
if (current) chapters.push(current);
const num = parseInt(match[1], 10);
current = { num, id: 'ch-' + num, title: match[2].trim(), bodyLines: [] };
} else if (current) {
current.bodyLines.push(line);
}
}
if (current) chapters.push(current);
// Render each chapter's body to HTML
const rendered = chapters.map(ch => ({
num: ch.num,
id: ch.id,
title: ch.title,
html: md.render(ch.bodyLines.join('\n')),
}));
// Build TOC
const chapterMap = {};
rendered.forEach(ch => { chapterMap[ch.num] = ch; });
const toc = TOC_GROUPS.map(g => ({
group: g.group,
items: g.nums
.filter(n => chapterMap[n])
.map(n => ({ id: chapterMap[n].id, num: n, title: chapterMap[n].title })),
}));
return { toc, chapters: rendered };
};
+12
View File
@@ -0,0 +1,12 @@
{
"name": "岩美酒库管理系统",
"tagline": "为酒行与酒店设计的库存管理平台",
"copyright": "© 2026 岩美科技 · 保留所有权利",
"icp": "沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号",
"support": {
"email": "support@yanmei.app",
"phone": "400-880-8888"
},
"appUrl": "/app/",
"lucideVersion": "0.469.0"
}
+8
View File
@@ -0,0 +1,8 @@
const fs = require('fs');
const path = require('path');
const md = require('markdown-it')({ html: false, breaks: false });
module.exports = function() {
const content = fs.readFileSync(path.join(__dirname, '../content/system-requirements.md'), 'utf8');
return md.render(content);
};
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>{% if title %}{{ title }} · {% endif %}{{ site.name }}</title>
{% if description %}<meta name="description" content="{{ description }}" />{% endif %}
<link rel="stylesheet" href="/assets/color.css" />
<link rel="stylesheet" href="/assets/style.css" />
{% if pageExtraCss %}<link rel="stylesheet" href="{{ pageExtraCss }}" />{% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@{{ site.lucideVersion }}/dist/umd/lucide.min.js"></script>
{% if pageStyle %}<style>{{ pageStyle | safe }}</style>{% endif %}
</head>
<body>
{% include "nav.njk" %}
{{ content | safe }}
{% include "footer.njk" %}
<script src="/assets/nav.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<footer>
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
<div>{{ site.support.email }}</div>
<div>{{ site.support.phone }}</div>
</div>
</div>
<div class="footer-col">
<h5>产品</h5>
<ul>
<li><a href="/#modules">核心模块</a></li>
<li><a href="/#platforms">多端覆盖</a></li>
<li><a href="/#reports">数据洞察</a></li>
<li><a href="/#pricing">价格方案</a></li>
<li><a href="#">版本更新</a></li>
</ul>
</div>
<div class="footer-col">
<h5>资源</h5>
<ul>
<li><a href="/docs/">使用手册</a></li>
<li><a href="#">API 文档</a></li>
<li><a href="#">视频教程</a></li>
<li><a href="#">数据导入模板</a></li>
</ul>
</div>
<div class="footer-col">
<h5>客户支持</h5>
<ul>
<li><a href="/#faq">常见问题</a></li>
<li><a href="#">提交工单</a></li>
<li><a href="#">技术支持</a></li>
<li><a href="#">服务等级</a></li>
</ul>
</div>
<div class="footer-col">
<h5>公司</h5>
<ul>
<li><a href="#">关于岩美</a></li>
<li><a href="#">客户案例</a></li>
<li><a href="#">招贤纳士</a></li>
<li><a href="#">联系我们</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<div>{{ site.copyright }}</div>
<div>{{ site.icp }}</div>
</div>
</div>
</footer>
+17
View File
@@ -0,0 +1,17 @@
<nav class="topnav">
<div class="topnav-inner">
<a href="/" class="topnav-brand">
<img src="/assets/logo-full.svg" alt="岩美" />
</a>
<div class="topnav-links">
<a href="/#modules">产品</a>
<a href="/#pricing">价格</a>
<a href="/download/">下载</a>
<a href="/docs/">文档</a>
<a href="/#faq">支持</a>
</div>
<div class="topnav-cta" id="nav-cta">
<a href="/register/" class="btn btn-primary btn-sm">注册门店</a>
</div>
</div>
</nav>
+261
View File
@@ -0,0 +1,261 @@
/* =========================================================================
岩美 Design System Foundations
Tokens for color, type, spacing, radius, shadow.
Import once at root: <link rel="stylesheet" href="colors_and_type.css">
========================================================================= */
:root {
color-scheme: light;
/* ---------- Brand: Primary (Slate Blue) ---------- */
/* Trustworthy enterprise blue with a slight slate cast. */
--brand-50: #EEF4FB;
--brand-100: #D6E5F5;
--brand-200: #ADC9EA;
--brand-300: #7FA8DA;
--brand-400: #4F86C6;
--brand-500: #2563AC; /* Primary action */
--brand-600: #1B4F8E; /* Hover */
--brand-700: #154072; /* Pressed */
--brand-800: #0F3057;
--brand-900: #0A1F3B; /* Brand ink — headers, logo on light bg */
/* ---------- Neutrals (cool slate gray) ---------- */
--gray-0: #FFFFFF;
--gray-25: #FBFCFD;
--gray-50: #F5F7FA;
--gray-100: #ECEFF4;
--gray-200: #DCE2EB;
--gray-300: #C2CAD6;
--gray-400: #99A3B3;
--gray-500: #6E7888;
--gray-600: #4F5867;
--gray-700: #353C48;
--gray-800: #232934;
--gray-900: #141821;
/* ---------- Accent (bordeaux / 酒红) ---------- */
/* Used sparingly: brand context cue (wine), key highlights, marketing only. */
--accent-50: #FAEEF0;
--accent-100: #F1D2D7;
--accent-300: #C97B86;
--accent-500: #8B2331;
--accent-700: #5F1621;
/* ---------- Semantic ---------- */
--success-50: #E8F5EE;
--success-500: #2E8B57;
--success-700: #1F6B41;
--warning-50: #FFF4DB;
--warning-500: #E08E00;
--warning-700: #A66700;
--danger-50: #FDECEC;
--danger-500: #D14343;
--danger-700: #9E2A2A;
--info-50: #E5F1FB;
--info-500: #2F7BD0;
--info-700: #1F5C9F;
/* ---------- Semantic foreground / background ---------- */
--bg-app: var(--gray-50);
--bg-surface: var(--gray-0);
--bg-raised: var(--gray-0);
--bg-sunken: var(--gray-100);
--bg-overlay: rgba(20, 24, 33, 0.45);
--fg-default: var(--gray-800);
--fg-muted: var(--gray-600);
--fg-subtle: var(--gray-500);
--fg-disabled: var(--gray-400);
--fg-on-brand: #FFFFFF;
--fg-link: var(--brand-500);
--border-subtle: var(--gray-100);
--border-default: var(--gray-200);
--border-strong: var(--gray-300);
--border-focus: var(--brand-500);
/* ---------- Typography ---------- */
/* Chinese-primary stack with PingFang on macOS/iOS, Microsoft YaHei on Windows,
Noto Sans SC as web fallback (loaded via Google Fonts in index files). */
--font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Source Han Sans CN", "Noto Sans SC", -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: var(--font-sans);
--font-mono: "JetBrains Mono", "SF Mono", "Roboto Mono", Menlo, Consolas,
"Microsoft YaHei", monospace;
/* Type scale — mobile-first, scales up for desktop dashboards */
--text-2xs: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-md: 14px; /* dashboard body default */
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--text-3xl: 28px;
--text-4xl: 36px;
--text-5xl: 48px;
--leading-tight: 1.25;
--leading-snug: 1.4;
--leading-normal: 1.55;
--leading-loose: 1.75;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* Tracking — Chinese reads better with subtle positive tracking */
--tracking-tight: -0.01em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-cn-display: 0.04em; /* Chinese display headers */
/* ---------- Spacing (4px base) ---------- */
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* ---------- Radius — restrained, enterprise-grade ---------- */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px; /* default for inputs, buttons, badges */
--radius-lg: 10px; /* cards */
--radius-xl: 14px;
--radius-pill: 999px;
/* ---------- Elevation — soft, neutral, no colored shadows ---------- */
--shadow-xs: 0 1px 2px rgba(20, 24, 33, 0.04);
--shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.06), 0 1px 3px rgba(20, 24, 33, 0.04);
--shadow-md: 0 2px 4px rgba(20, 24, 33, 0.06), 0 4px 8px rgba(20, 24, 33, 0.05);
--shadow-lg: 0 4px 12px rgba(20, 24, 33, 0.08), 0 12px 24px rgba(20, 24, 33, 0.06);
--shadow-xl: 0 8px 20px rgba(20, 24, 33, 0.10), 0 20px 40px rgba(20, 24, 33, 0.08);
--shadow-inset: inset 0 1px 0 rgba(255,255,255,0.6), inset 0 -1px 0 rgba(20,24,33,0.04);
--ring-focus: 0 0 0 3px rgba(37, 99, 172, 0.22);
/* ---------- Motion ---------- */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1.2);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--duration-fast: 120ms;
--duration-base: 180ms;
--duration-slow: 240ms;
/* ---------- Layout ---------- */
--layout-sidebar: 240px;
--layout-sidebar-collapsed: 64px;
--layout-topbar: 56px;
--layout-content-max: 1440px;
}
/* =========================================================================
Semantic element styles apply to base elements within design system docs.
These do NOT bleed into ui_kits / pages with their own styles.
========================================================================= */
.ds-typography {
font-family: var(--font-sans);
color: var(--fg-default);
font-feature-settings: "tnum" 1, "ss01" 1;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.ds-typography h1,
.ds-h1 {
font-size: var(--text-4xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-cn-display);
color: var(--brand-900);
margin: 0 0 var(--space-4);
}
.ds-typography h2,
.ds-h2 {
font-size: var(--text-3xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-cn-display);
color: var(--brand-900);
margin: 0 0 var(--space-3);
}
.ds-typography h3,
.ds-h3 {
font-size: var(--text-2xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--gray-900);
margin: 0 0 var(--space-3);
}
.ds-typography h4,
.ds-h4 {
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--gray-900);
margin: 0 0 var(--space-2);
}
.ds-typography h5,
.ds-h5 {
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--gray-800);
margin: 0 0 var(--space-2);
}
.ds-typography p,
.ds-body {
font-size: var(--text-md);
font-weight: var(--weight-regular);
line-height: var(--leading-normal);
color: var(--fg-default);
margin: 0 0 var(--space-3);
}
.ds-typography small,
.ds-caption {
font-size: var(--text-xs);
color: var(--fg-muted);
line-height: var(--leading-snug);
}
.ds-typography code,
.ds-mono {
font-family: var(--font-mono);
font-size: 0.93em;
background: var(--gray-100);
padding: 1px 6px;
border-radius: var(--radius-sm);
color: var(--gray-800);
}
.ds-num {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
.ds-label {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: none; /* Chinese never uppercases */
color: var(--fg-muted);
}
/* Focus ring shared across the system */
.ds-focusable:focus-visible,
:focus-visible {
outline: none;
box-shadow: var(--ring-focus);
border-color: var(--border-focus);
}
+2
View File
@@ -5,6 +5,8 @@
========================================================================= */
:root {
color-scheme: light;
/* ---------- Brand: Primary (Slate Blue) ---------- */
/* Trustworthy enterprise blue with a slight slate cast. */
--brand-50: #EEF4FB;
+347
View File
@@ -0,0 +1,347 @@
/* ====================================================================
DOCS SUB-HEADER
==================================================================== */
.docs-subnav {
background: var(--gray-25);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 64px;
z-index: 40;
}
.docs-subnav-inner {
max-width: 1440px;
margin: 0 auto;
padding: 12px 32px;
display: flex; align-items: center; gap: 24px;
}
.breadcrumb {
display: flex; align-items: center; gap: 8px;
font-size: var(--text-sm);
color: var(--fg-muted);
}
.breadcrumb .sep { color: var(--gray-300); }
.breadcrumb a { color: var(--fg-muted); }
.breadcrumb a:hover { color: var(--brand-500); }
.breadcrumb .current { color: var(--gray-900); font-weight: 500; }
.docs-search {
flex: 1; max-width: 480px;
position: relative;
}
.docs-search input {
width: 100%;
height: 36px;
padding: 0 12px 0 38px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--gray-0);
font-family: inherit;
font-size: var(--text-sm);
color: var(--gray-800);
outline: none;
transition: border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.docs-search input:focus { border-color: var(--brand-500); box-shadow: var(--ring-focus); }
.docs-search input::placeholder { color: var(--fg-subtle); }
.docs-search .search-icon {
position: absolute;
left: 12px; top: 50%;
transform: translateY(-50%);
width: 16px; height: 16px;
color: var(--fg-subtle);
}
.docs-search .kbd {
position: absolute;
right: 8px; top: 50%;
transform: translateY(-50%);
background: var(--gray-100);
border: 1px solid var(--border-default);
border-radius: 3px;
padding: 2px 6px;
font-size: 11px;
color: var(--fg-muted);
font-family: var(--font-mono);
}
.docs-version {
display: flex; align-items: center; gap: 6px;
background: var(--gray-0);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: 6px 12px;
font-size: var(--text-sm);
color: var(--gray-700);
}
.docs-version .icon { width: 14px; height: 14px; }
.docs-version-label { color: var(--fg-subtle); font-size: var(--text-xs); }
/* ====================================================================
DOCS LAYOUT (three-column)
==================================================================== */
.docs-layout {
max-width: 1440px;
margin: 0 auto;
padding: 32px;
display: grid;
grid-template-columns: 240px 1fr 240px;
gap: 48px;
align-items: start;
}
/* ====================================================================
LEFT TOC
==================================================================== */
.docs-toc {
position: sticky;
top: 132px;
max-height: calc(100vh - 152px);
overflow-y: auto;
font-size: var(--text-sm);
}
.toc-section { margin-bottom: 18px; }
.toc-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--fg-subtle);
text-transform: uppercase;
margin: 0 0 10px;
padding-left: 12px;
}
.toc-link {
display: flex; align-items: center; gap: 10px;
padding: 7px 12px;
border-radius: var(--radius-sm);
color: var(--gray-700);
font-weight: 400;
transition: background var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.toc-link:hover { background: var(--gray-100); color: var(--brand-900); }
.toc-link .toc-num {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-subtle);
min-width: 16px;
font-weight: 500;
}
.toc-link.active {
background: var(--brand-50);
color: var(--brand-700);
font-weight: 500;
}
.toc-link.active .toc-num { color: var(--brand-500); }
/* ====================================================================
MAIN CONTENT
==================================================================== */
.docs-main { min-width: 0; max-width: 760px; }
.docs-chapter { padding-bottom: 48px; border-bottom: 1px solid var(--border-subtle); margin-bottom: 48px; }
.docs-chapter:last-child { border-bottom: none; margin-bottom: 0; }
.docs-main h2 {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--brand-900);
margin: 0 0 16px;
letter-spacing: 0.02em;
scroll-margin-top: 132px;
padding-bottom: 12px;
border-bottom: 2px solid var(--brand-100);
}
.docs-main h3 {
font-size: var(--text-lg);
font-weight: 600;
color: var(--gray-900);
margin: 32px 0 12px;
scroll-margin-top: 132px;
}
.docs-main p {
font-size: var(--text-md);
color: var(--gray-700);
line-height: 1.75;
margin: 0 0 16px;
}
.docs-main strong { color: var(--brand-900); font-weight: 600; }
.docs-main em { color: var(--brand-700); font-style: normal; font-weight: 500; }
.docs-main code {
font-family: var(--font-mono);
font-size: 0.92em;
background: var(--gray-100);
color: var(--brand-700);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.docs-main a {
color: var(--brand-500);
font-weight: 500;
border-bottom: 1px solid var(--brand-200);
}
.docs-main a:hover { color: var(--brand-700); border-color: var(--brand-500); }
.docs-main ol, .docs-main ul {
margin: 0 0 16px;
padding-left: 24px;
}
.docs-main li {
font-size: var(--text-md);
color: var(--gray-700);
line-height: 1.75;
margin-bottom: 6px;
}
.docs-main ol li::marker {
color: var(--brand-500);
font-weight: 600;
}
/* Rendered markdown tables */
.docs-main table {
width: 100%;
border-collapse: collapse;
margin: 16px 0 24px;
font-size: var(--text-sm);
background: var(--gray-0);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
overflow: hidden;
}
.docs-main th, .docs-main td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border-subtle);
line-height: 1.55;
}
.docs-main thead th {
background: var(--gray-50);
font-size: var(--text-xs);
font-weight: 600;
color: var(--gray-700);
letter-spacing: 0.02em;
text-transform: uppercase;
border-bottom: 1px solid var(--border-default);
}
.docs-main tbody tr:last-child td { border-bottom: none; }
.docs-main tbody tr:hover { background: var(--gray-25); }
/* Rendered markdown code blocks */
.docs-main pre {
background: var(--gray-25);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: 16px 20px;
overflow-x: auto;
margin: 16px 0 24px;
}
.docs-main pre code {
background: none;
color: var(--gray-800);
padding: 0;
font-size: var(--text-sm);
line-height: 1.6;
border-radius: 0;
}
/* Blockquote → callout-style */
.docs-main blockquote {
background: var(--info-50);
border-left: 3px solid var(--info-500);
border-radius: var(--radius-md);
padding: 14px 18px;
margin: 16px 0 24px;
color: var(--info-700);
}
.docs-main blockquote p { color: var(--gray-700); margin: 0; font-size: var(--text-sm); line-height: 1.6; }
/* ====================================================================
RIGHT RAIL
==================================================================== */
.docs-rail {
position: sticky;
top: 132px;
font-size: var(--text-sm);
}
.rail-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--fg-subtle);
text-transform: uppercase;
margin: 0 0 12px;
}
.rail-list { display: flex; flex-direction: column; gap: 2px; list-style: none; padding: 0; margin: 0 0 24px; }
.rail-list a {
display: block;
padding: 5px 10px;
border-radius: var(--radius-sm);
color: var(--fg-muted);
font-size: 13px;
border-left: 2px solid transparent;
}
.rail-list a:hover { color: var(--brand-700); }
.rail-list a.active {
color: var(--brand-700);
border-left-color: var(--brand-500);
font-weight: 500;
}
.rail-actions {
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
display: flex; flex-direction: column; gap: 6px;
}
.rail-action {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px;
font-size: 13px;
color: var(--fg-muted);
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
border: none;
font-family: inherit;
width: 100%;
text-align: left;
}
.rail-action:hover { background: var(--gray-50); color: var(--brand-700); }
.rail-action .icon { width: 14px; height: 14px; }
/* ====================================================================
FEEDBACK WIDGET
==================================================================== */
.docs-feedback {
margin-top: 40px;
padding: 24px;
background: var(--gray-25);
border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: space-between;
}
.feedback-q { font-size: var(--text-md); color: var(--gray-800); }
.feedback-actions { display: flex; gap: 8px; }
.feedback-btn {
display: inline-flex; align-items: center; gap: 6px;
background: var(--gray-0);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: 6px 14px;
font-size: var(--text-sm);
color: var(--gray-700);
cursor: pointer;
font-family: inherit;
}
.feedback-btn:hover { border-color: var(--brand-500); color: var(--brand-700); }
.feedback-btn .icon { width: 14px; height: 14px; }
/* ====================================================================
RESPONSIVE
==================================================================== */
@media (max-width: 1200px) {
.docs-layout { grid-template-columns: 220px 1fr; gap: 32px; }
.docs-rail { display: none; }
}
@media (max-width: 880px) {
.docs-layout { grid-template-columns: 1fr; gap: 24px; padding: 16px; }
.docs-toc { position: static; max-height: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; margin-bottom: 8px; }
.docs-subnav-inner { padding: 10px 16px; }
.docs-search { max-width: none; }
}
+90
View File
@@ -0,0 +1,90 @@
(function() {
var KEYS = ['access_token','refresh_token','username','real_name','shop_no','shop_id','role'];
function getAuthUser() {
var token = localStorage.getItem('flutter.access_token');
var username = localStorage.getItem('flutter.username');
if (!token || !username) return null;
return {
username: username,
realName: localStorage.getItem('flutter.real_name') || '',
role: localStorage.getItem('flutter.role') || '',
shopNo: localStorage.getItem('flutter.shop_no') || '',
};
}
function doLogout() {
KEYS.forEach(function(k) { localStorage.removeItem('flutter.' + k); });
location.reload();
}
function toggleUserMenu() {
var el = document.getElementById('userDropdown');
if (el) el.classList.toggle('open');
}
// 点击外部关闭 dropdown
document.addEventListener('click', function(e) {
var menu = document.getElementById('userDropdown');
if (!menu) return;
var navUser = menu.closest('.nav-user');
if (navUser && !navUser.contains(e.target)) menu.classList.remove('open');
});
function renderNav() {
var cta = document.getElementById('nav-cta');
if (!cta) return;
var user = getAuthUser();
if (user) {
var display = user.realName || user.username;
var initial = display.charAt(0).toUpperCase();
var roleMap = { admin: '管理员', user: '成员', readonly: '只读' };
var roleLabel = roleMap[user.role] || user.role;
cta.innerHTML =
'<div class="nav-user">'
+ '<button class="nav-user-btn" onclick="toggleUserMenu()">'
+ '<div class="nav-avatar">' + initial + '</div>'
+ '<span>' + display + '</span>'
+ '<i data-lucide="chevron-down" class="icon"></i>'
+ '</button>'
+ '<div class="nav-dropdown" id="userDropdown">'
+ '<div style="padding:10px 14px 6px;border-bottom:1px solid var(--border-subtle);margin-bottom:4px">'
+ '<div style="font-size:13px;font-weight:600;color:var(--brand-900)">' + display + '</div>'
+ '<div style="font-size:11px;color:var(--fg-muted);margin-top:2px">'
+ (user.shopNo ? user.shopNo + ' · ' : '') + roleLabel
+ '</div>'
+ '</div>'
+ '<a href="/app/"><i data-lucide="layout-dashboard" class="icon"></i>进入管理端</a>'
+ '<div class="nav-dropdown-divider"></div>'
+ '<button class="logout" onclick="doLogout()"><i data-lucide="log-out" class="icon"></i>退出登录</button>'
+ '</div></div>';
} else {
cta.innerHTML =
'<a href="/app/" class="btn btn-ghost">登录</a>'
+ '<a href="/app/" class="btn btn-primary">免费试用</a>';
}
if (window.lucide) lucide.createIcons();
}
// 暴露给 onclick 调用
window.toggleUserMenu = toggleUserMenu;
window.doLogout = doLogout;
document.addEventListener('DOMContentLoaded', function() {
renderNav();
if (window.lucide) lucide.createIcons();
// 页内锚点平滑滚动
document.querySelectorAll('a[href^="#"]').forEach(function(a) {
a.addEventListener('click', function(e) {
var id = a.getAttribute('href');
if (!id || id.length < 2) return;
var target = document.querySelector(id);
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
});
})();
+183
View File
@@ -0,0 +1,183 @@
/* ================================================================
shared.css 跨页面公用样式
设计 token colors_and_type.css 提供
================================================================ */
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: var(--font-sans);
color: var(--fg-default);
background: var(--gray-25);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "tnum" 1;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
/* ----------------------------------------------------------------
Buttons
---------------------------------------------------------------- */
.btn {
display: inline-flex; align-items: center; gap: 8px;
height: 40px; padding: 0 18px;
border-radius: var(--radius-md);
font-size: var(--text-md); font-weight: 500;
border: 1px solid transparent;
transition: background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
white-space: nowrap;
}
.btn-primary { background: var(--brand-500); color: #fff; }
.btn-primary:hover { background: var(--brand-600); }
.btn-primary:active { background: var(--brand-700); }
.btn-secondary { background: var(--gray-0); color: var(--brand-900); border-color: var(--border-default); }
.btn-secondary:hover { background: var(--gray-50); border-color: var(--border-strong); }
.btn-ghost { background: transparent; color: var(--fg-default); }
.btn-ghost:hover { background: var(--gray-100); }
.btn-lg { height: 48px; padding: 0 24px; font-size: var(--text-lg); }
.btn .icon { width: 18px; height: 18px; }
/* ----------------------------------------------------------------
Layout helpers
---------------------------------------------------------------- */
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 32px;
}
.section { padding: 96px 0; }
.section-tight { padding: 64px 0; }
.eyebrow {
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.12em;
color: var(--brand-500);
text-transform: uppercase;
margin: 0 0 12px;
}
.section-title {
font-size: var(--text-4xl);
font-weight: 600;
line-height: 1.18;
letter-spacing: var(--tracking-cn-display);
color: var(--brand-900);
margin: 0 0 16px;
max-width: 720px;
}
.section-sub {
font-size: var(--text-lg);
line-height: 1.6;
color: var(--fg-muted);
margin: 0;
max-width: 640px;
}
/* ----------------------------------------------------------------
Top Nav
---------------------------------------------------------------- */
.topnav {
position: sticky; top: 0; z-index: 50;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
}
.topnav-inner {
height: 64px;
display: flex; align-items: center; justify-content: space-between;
max-width: 1440px; margin: 0 auto; padding: 0 32px;
}
.topnav-brand { display: flex; align-items: center; gap: 10px; }
.topnav-brand img { height: 32px; }
.topnav-links {
display: flex; align-items: center; gap: 4px;
font-size: var(--text-md); color: var(--gray-700);
}
.topnav-links a {
padding: 8px 14px;
border-radius: var(--radius-md);
transition: background var(--duration-fast) var(--ease-standard);
}
.topnav-links a:hover { background: var(--gray-100); color: var(--brand-900); }
.topnav-cta { display: flex; gap: 10px; align-items: center; }
/* Nav user dropdown */
.nav-user { position: relative; }
.nav-user-btn {
display: flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
background: var(--gray-0); border: 1px solid var(--border-default);
border-radius: var(--radius-pill); cursor: pointer;
font-size: var(--text-md); color: var(--gray-800); font-weight: 500;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.nav-user-btn:hover { border-color: var(--border-strong); }
.nav-avatar {
width: 22px; height: 22px; border-radius: 50%;
background: var(--brand-500); color: #fff;
display: grid; place-items: center;
font-size: 10px; font-weight: 700; flex-shrink: 0;
}
.nav-user-btn .icon { width: 14px; height: 14px; color: var(--fg-muted); }
.nav-dropdown {
display: none; position: absolute; top: calc(100% + 8px); right: 0;
min-width: 160px; background: var(--gray-0);
border: 1px solid var(--border-default); border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg); overflow: hidden; z-index: 100;
}
.nav-dropdown.open { display: block; }
.nav-dropdown a, .nav-dropdown button {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 10px 14px;
font-size: var(--text-sm); color: var(--gray-800);
background: none; border: none; cursor: pointer;
text-decoration: none; font-family: inherit;
transition: background var(--duration-fast) var(--ease-standard);
}
.nav-dropdown a:hover, .nav-dropdown button:hover { background: var(--gray-50); }
.nav-dropdown .icon { width: 14px; height: 14px; color: var(--fg-muted); }
.nav-dropdown-divider { height: 1px; background: var(--border-subtle); margin: 4px 0; }
.nav-dropdown .logout { color: var(--danger-600); }
/* ----------------------------------------------------------------
Footer
---------------------------------------------------------------- */
footer {
background: var(--gray-25);
border-top: 1px solid var(--border-subtle);
padding: 64px 0 40px;
}
.footer-grid {
display: grid;
grid-template-columns: 1.5fr repeat(4, 1fr);
gap: 48px;
margin-bottom: 48px;
}
.footer-brand img { height: 32px; margin-bottom: 16px; }
.footer-brand p { margin: 0 0 16px; max-width: 320px; line-height: 1.6; color: var(--fg-muted); }
.footer-contact { font-size: var(--text-sm); color: var(--gray-700); line-height: 1.8; }
.footer-col h5 {
font-size: var(--text-xs); font-weight: 600;
color: var(--brand-900); letter-spacing: 0.06em;
margin: 0 0 16px; text-transform: uppercase;
}
.footer-col ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.footer-col li a { color: var(--gray-600); }
.footer-col li a:hover { color: var(--brand-500); }
.footer-bottom {
border-top: 1px solid var(--border-subtle); padding-top: 24px;
display: flex; justify-content: space-between;
font-size: var(--text-xs); color: var(--fg-subtle);
}
/* ----------------------------------------------------------------
Responsive
---------------------------------------------------------------- */
@media (max-width: 1024px) {
.footer-grid { grid-template-columns: 1fr 1fr; }
.container { padding: 0 20px; }
}
+459
View File
@@ -0,0 +1,459 @@
/* ================================================================
style.css 全局样式
Token color.css 提供
分三层 Reset & 基础 组件 工具类
================================================================ */
/* ----------------------------------------------------------------
Reset & 基础
---------------------------------------------------------------- */
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: var(--font-sans);
color: var(--fg-default);
background: var(--gray-25);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "tnum" 1;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
/* ----------------------------------------------------------------
组件
---------------------------------------------------------------- */
/* -- Buttons -- */
.btn {
display: inline-flex; align-items: center; gap: 8px;
height: 40px; padding: 0 18px;
border-radius: var(--radius-md);
font-size: var(--text-md); font-weight: 500;
border: 1px solid transparent;
transition: background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
white-space: nowrap;
}
.btn-primary { background: var(--brand-500); color: #fff; }
.btn-primary:hover { background: var(--brand-600); }
.btn-primary:active { background: var(--brand-700); }
.btn-secondary { background: var(--gray-0); color: var(--brand-900); border-color: var(--border-default); }
.btn-secondary:hover { background: var(--gray-50); border-color: var(--border-strong); }
.btn-ghost { background: transparent; color: var(--fg-default); }
.btn-ghost:hover { background: var(--gray-100); }
.btn-lg { height: 48px; padding: 0 24px; font-size: var(--text-lg); }
.btn .icon { width: 18px; height: 18px; }
/* -- Layout helpers -- */
.container { max-width: 1280px; margin: 0 auto; padding: 0 32px; }
.section { padding: 96px 0; }
.section-tight { padding: 64px 0; }
.eyebrow {
font-size: var(--text-xs); font-weight: 600;
letter-spacing: 0.12em; color: var(--brand-500);
text-transform: uppercase; margin: 0 0 12px;
}
.section-title {
font-size: var(--text-4xl); font-weight: 600;
line-height: 1.18; letter-spacing: var(--tracking-cn-display);
color: var(--brand-900); margin: 0 0 16px; max-width: 720px;
}
.section-sub {
font-size: var(--text-lg); line-height: 1.6;
color: var(--fg-muted); margin: 0; max-width: 640px;
}
/* -- Top Nav -- */
.topnav {
position: sticky; top: 0; z-index: 50;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
}
.topnav-inner {
height: 64px; display: flex; align-items: center; justify-content: space-between;
max-width: 1440px; margin: 0 auto; padding: 0 32px;
}
.topnav-brand { display: flex; align-items: center; gap: 10px; }
.topnav-brand img { height: 32px; }
.topnav-links {
display: flex; align-items: center; gap: 4px;
font-size: var(--text-md); color: var(--gray-700);
}
.topnav-links a {
padding: 8px 14px; border-radius: var(--radius-md);
transition: background var(--duration-fast) var(--ease-standard);
}
.topnav-links a:hover { background: var(--gray-100); color: var(--brand-900); }
.topnav-cta { display: flex; gap: 10px; align-items: center; }
/* Nav user dropdown */
.nav-user { position: relative; }
.nav-user-btn {
display: flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
background: var(--gray-0); border: 1px solid var(--border-default);
border-radius: var(--radius-pill); cursor: pointer;
font-size: var(--text-md); color: var(--gray-800); font-weight: 500;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.nav-user-btn:hover { border-color: var(--border-strong); }
.nav-avatar {
width: 22px; height: 22px; border-radius: 50%;
background: var(--brand-500); color: #fff;
display: grid; place-items: center;
font-size: 10px; font-weight: 700; flex-shrink: 0;
}
.nav-user-btn .icon { width: 14px; height: 14px; color: var(--fg-muted); }
.nav-dropdown {
display: none; position: absolute; top: calc(100% + 8px); right: 0;
min-width: 160px; background: var(--gray-0);
border: 1px solid var(--border-default); border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg); overflow: hidden; z-index: 100;
}
.nav-dropdown.open { display: block; }
.nav-dropdown a, .nav-dropdown button {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 10px 14px;
font-size: var(--text-sm); color: var(--gray-800);
background: none; border: none; cursor: pointer;
text-decoration: none; font-family: inherit;
transition: background var(--duration-fast) var(--ease-standard);
}
.nav-dropdown a:hover, .nav-dropdown button:hover { background: var(--gray-50); }
.nav-dropdown .icon { width: 14px; height: 14px; color: var(--fg-muted); }
.nav-dropdown-divider { height: 1px; background: var(--border-subtle); margin: 4px 0; }
.nav-dropdown .logout { color: var(--danger-600); }
/* -- Footer -- */
footer {
background: var(--gray-25);
border-top: 1px solid var(--border-subtle);
padding: 64px 0 40px;
}
.footer-grid {
display: grid; grid-template-columns: 1.5fr repeat(4, 1fr);
gap: 48px; margin-bottom: 48px;
}
.footer-brand img { height: 32px; margin-bottom: 16px; }
.footer-brand p { margin: 0 0 16px; max-width: 320px; line-height: 1.6; color: var(--fg-muted); }
.footer-contact { font-size: var(--text-sm); color: var(--gray-700); line-height: 1.8; }
.footer-col h5 {
font-size: var(--text-xs); font-weight: 600;
color: var(--brand-900); letter-spacing: 0.06em;
margin: 0 0 16px; text-transform: uppercase;
}
.footer-col ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.footer-col li a { color: var(--gray-600); }
.footer-col li a:hover { color: var(--brand-500); }
.footer-bottom {
border-top: 1px solid var(--border-subtle); padding-top: 24px;
display: flex; justify-content: space-between;
font-size: var(--text-xs); color: var(--fg-subtle);
}
/* ----------------------------------------------------------------
工具类
命名规则{}-{}
间距值直接对应像素p-8 = padding:8pxgap-16 = gap:16px
---------------------------------------------------------------- */
/* -- Display -- */
.d-flex { display: flex }
.d-inline-flex { display: inline-flex }
.d-grid { display: grid }
.d-block { display: block }
.d-inline { display: inline }
.d-inline-block{ display: inline-block }
.d-none { display: none }
.place-center { display: grid; place-items: center } /* 图标容器常用 */
/* -- Flex -- */
.flex-col { flex-direction: column }
.flex-wrap { flex-wrap: wrap }
.flex-1 { flex: 1 }
.flex-shrink-0 { flex-shrink: 0 }
.items-center { align-items: center }
.items-start { align-items: flex-start }
.items-end { align-items: flex-end }
.items-baseline { align-items: baseline }
.justify-center { justify-content: center }
.justify-between { justify-content: space-between }
.justify-end { justify-content: flex-end }
.justify-start { justify-content: flex-start }
/* -- Grid 列模板 -- */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr) }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr) }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr) }
.grid-5 { display: grid; grid-template-columns: repeat(5, 1fr) }
/* -- Gap -- */
.gap-2 { gap: 2px }
.gap-4 { gap: 4px }
.gap-6 { gap: 6px }
.gap-8 { gap: 8px }
.gap-10 { gap: 10px }
.gap-12 { gap: 12px }
.gap-14 { gap: 14px }
.gap-16 { gap: 16px }
.gap-18 { gap: 18px }
.gap-24 { gap: 24px }
.gap-32 { gap: 32px }
.gap-48 { gap: 48px }
.gap-64 { gap: 64px }
/* -- Padding(全方向) -- */
.p-0 { padding: 0 }
.p-4 { padding: 4px }
.p-6 { padding: 6px }
.p-8 { padding: 8px }
.p-10 { padding: 10px }
.p-12 { padding: 12px }
.p-14 { padding: 14px }
.p-16 { padding: 16px }
.p-18 { padding: 18px }
.p-20 { padding: 20px }
.p-24 { padding: 24px }
.p-28 { padding: 28px }
.p-32 { padding: 32px }
.p-48 { padding: 48px }
.p-56 { padding: 56px }
.p-64 { padding: 64px }
/* px / py */
.px-4 { padding-left: 4px; padding-right: 4px }
.px-6 { padding-left: 6px; padding-right: 6px }
.px-8 { padding-left: 8px; padding-right: 8px }
.px-10 { padding-left: 10px; padding-right: 10px }
.px-12 { padding-left: 12px; padding-right: 12px }
.px-14 { padding-left: 14px; padding-right: 14px }
.px-16 { padding-left: 16px; padding-right: 16px }
.px-20 { padding-left: 20px; padding-right: 20px }
.px-24 { padding-left: 24px; padding-right: 24px }
.px-28 { padding-left: 28px; padding-right: 28px }
.px-32 { padding-left: 32px; padding-right: 32px }
.py-2 { padding-top: 2px; padding-bottom: 2px }
.py-4 { padding-top: 4px; padding-bottom: 4px }
.py-6 { padding-top: 6px; padding-bottom: 6px }
.py-8 { padding-top: 8px; padding-bottom: 8px }
.py-10 { padding-top: 10px; padding-bottom: 10px }
.py-12 { padding-top: 12px; padding-bottom: 12px }
.py-14 { padding-top: 14px; padding-bottom: 14px }
.py-16 { padding-top: 16px; padding-bottom: 16px }
.py-20 { padding-top: 20px; padding-bottom: 20px }
.py-24 { padding-top: 24px; padding-bottom: 24px }
.py-32 { padding-top: 32px; padding-bottom: 32px }
/* pt / pb / pl / pr */
.pt-4 { padding-top: 4px } .pb-4 { padding-bottom: 4px }
.pt-6 { padding-top: 6px } .pb-6 { padding-bottom: 6px }
.pt-8 { padding-top: 8px } .pb-8 { padding-bottom: 8px }
.pt-12 { padding-top: 12px } .pb-12 { padding-bottom: 12px }
.pt-14 { padding-top: 14px } .pb-14 { padding-bottom: 14px }
.pt-16 { padding-top: 16px } .pb-16 { padding-bottom: 16px }
.pt-24 { padding-top: 24px } .pb-24 { padding-bottom: 24px }
.pt-32 { padding-top: 32px } .pb-32 { padding-bottom: 32px }
.pl-12 { padding-left: 12px } .pr-12 { padding-right: 12px }
.pl-16 { padding-left: 16px } .pr-16 { padding-right: 16px }
.pl-24 { padding-left: 24px } .pr-24 { padding-right: 24px }
/* -- Margin -- */
.m-0 { margin: 0 }
.mx-auto { margin-left: auto; margin-right: auto }
.mb-0 { margin-bottom: 0 }
.mb-2 { margin-bottom: 2px }
.mb-4 { margin-bottom: 4px }
.mb-6 { margin-bottom: 6px }
.mb-8 { margin-bottom: 8px }
.mb-10 { margin-bottom: 10px }
.mb-12 { margin-bottom: 12px }
.mb-14 { margin-bottom: 14px }
.mb-16 { margin-bottom: 16px }
.mb-18 { margin-bottom: 18px }
.mb-24 { margin-bottom: 24px }
.mb-32 { margin-bottom: 32px }
.mb-48 { margin-bottom: 48px }
.mt-2 { margin-top: 2px }
.mt-4 { margin-top: 4px }
.mt-6 { margin-top: 6px }
.mt-8 { margin-top: 8px }
.mt-12 { margin-top: 12px }
.mt-16 { margin-top: 16px }
.mt-24 { margin-top: 24px }
.mt-32 { margin-top: 32px }
.mt-48 { margin-top: 48px }
.ml-4 { margin-left: 4px }
.ml-8 { margin-left: 8px }
.mr-4 { margin-right: 4px }
.mr-8 { margin-right: 8px }
/* -- Font size(复用 token -- */
.fs-xs { font-size: var(--text-xs) }
.fs-sm { font-size: var(--text-sm) }
.fs-md { font-size: var(--text-md) }
.fs-lg { font-size: var(--text-lg) }
.fs-xl { font-size: var(--text-xl) }
.fs-2xl { font-size: var(--text-2xl) }
.fs-3xl { font-size: var(--text-3xl) }
.fs-4xl { font-size: var(--text-4xl) }
/* -- Font weight -- */
.fw-4 { font-weight: 400 }
.fw-5 { font-weight: 500 }
.fw-6 { font-weight: 600 }
.fw-7 { font-weight: 700 }
/* -- Line height -- */
.lh-1 { line-height: 1 }
.lh-12 { line-height: 1.2 }
.lh-14 { line-height: 1.4 }
.lh-15 { line-height: 1.5 }
.lh-16 { line-height: 1.6 }
.lh-17 { line-height: 1.7 }
.lh-18 { line-height: 1.8 }
/* -- Letter spacing -- */
.ls-1 { letter-spacing: 0.04em }
.ls-2 { letter-spacing: 0.06em }
.ls-3 { letter-spacing: 0.10em }
.ls-4 { letter-spacing: 0.12em }
.ls-disp { letter-spacing: var(--tracking-cn-display) }
/* -- Text color -- */
.text-brand { color: var(--brand-500) }
.text-brand-hi { color: var(--brand-700) }
.text-brand-900{ color: var(--brand-900) }
.text-default { color: var(--fg-default) }
.text-muted { color: var(--fg-muted) }
.text-subtle { color: var(--fg-subtle) }
.text-gray-6 { color: var(--gray-600) }
.text-gray-7 { color: var(--gray-700) }
.text-gray-8 { color: var(--gray-800) }
.text-gray-9 { color: var(--gray-900) }
.text-white { color: #fff }
.text-success { color: var(--success-700) }
.text-warning { color: var(--warning-700) }
.text-danger { color: var(--danger-700) }
.text-info { color: var(--info-700) }
/* -- Background -- */
.bg-0 { background: var(--gray-0) }
.bg-25 { background: var(--gray-25) }
.bg-50 { background: var(--gray-50) }
.bg-100 { background: var(--gray-100) }
.bg-brand { background: var(--brand-500) }
.bg-brand-50 { background: var(--brand-50) }
.bg-brand-900{ background: var(--brand-900) }
.bg-success-50 { background: var(--success-50) }
.bg-warning-50 { background: var(--warning-50) }
.bg-danger-50 { background: var(--danger-50) }
.bg-info-50 { background: var(--info-50) }
/* -- Border -- */
.border { border: 1px solid var(--border-default) }
.border-top { border-top: 1px solid var(--border-default) }
.border-bottom { border-bottom: 1px solid var(--border-default) }
.border-left { border-left: 1px solid var(--border-default) }
.border-subtle { border-color: var(--border-subtle) }
.border-strong { border-color: var(--border-strong) }
.border-brand { border-color: var(--brand-500) }
.border-0 { border: none }
/* -- Border radius -- */
.rounded-sm { border-radius: var(--radius-sm) }
.rounded-md { border-radius: var(--radius-md) }
.rounded-lg { border-radius: var(--radius-lg) }
.rounded-xl { border-radius: var(--radius-xl) }
.rounded-pill { border-radius: var(--radius-pill) }
.rounded-full { border-radius: 50% }
/* -- Shadow -- */
.shadow-sm { box-shadow: var(--shadow-sm) }
.shadow-md { box-shadow: var(--shadow-md) }
.shadow-lg { box-shadow: var(--shadow-lg) }
.shadow-xl { box-shadow: var(--shadow-xl) }
/* -- Text helpers -- */
.text-upper { text-transform: uppercase }
.text-center { text-align: center }
.text-right { text-align: right }
.text-nowrap { white-space: nowrap }
.font-mono { font-family: var(--font-mono) }
.tabular { font-variant-numeric: tabular-nums }
/* -- Sizing & position -- */
.w-full { width: 100% }
.h-full { height: 100% }
.min-w-0 { min-width: 0 }
.pos-relative { position: relative }
.pos-absolute { position: absolute }
.pos-sticky { position: sticky }
.overflow-hidden { overflow: hidden }
.overflow-x-auto { overflow-x: auto }
/* -- Transition(常用属性组合) -- */
.transition {
transition: background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
/* ----------------------------------------------------------------
Card & Badge 高频组合
---------------------------------------------------------------- */
/* cardbg-0 + border + rounded-lg,三个页面共用 15+ 次 */
.card {
background: var(--gray-0);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
/* badge:状态标签小胶囊 */
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: var(--text-xs);
font-weight: 600;
}
.badge-brand { background: var(--brand-50); color: var(--brand-700) }
.badge-success { background: var(--success-50); color: var(--success-700) }
.badge-warning { background: var(--warning-50); color: var(--warning-700) }
.badge-danger { background: var(--danger-50); color: var(--danger-700) }
.badge-info { background: var(--info-50); color: var(--info-700) }
.badge-gray { background: var(--gray-100); color: var(--gray-700) }
.badge-solid { background: var(--brand-500); color: #fff }
/* icon 尺寸 */
.icon-sm { width: 14px; height: 14px }
.icon-md { width: 18px; height: 18px }
.icon-lg { width: 22px; height: 22px }
.icon-xl { width: 28px; height: 28px }
.icon-2xl { width: 36px; height: 36px }
/* icon-box:方形图标容器(带背景色时使用) */
.icon-box-sm { width: 28px; height: 28px; border-radius: var(--radius-sm); display: grid; place-items: center }
.icon-box-md { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center }
.icon-box-lg { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center }
.icon-box-xl { width: 56px; height: 56px; border-radius: var(--radius-md); display: grid; place-items: center }
/* ----------------------------------------------------------------
Responsive
---------------------------------------------------------------- */
@media (max-width: 1024px) {
.footer-grid { grid-template-columns: 1fr 1fr; }
.container { padding: 0 20px; }
}
@media (max-width: 768px) {
.grid-3, .grid-4, .grid-5 { grid-template-columns: repeat(2, 1fr); }
}

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