Compare commits
24 Commits
v20260524.1435
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5282eb57c9 | |||
| f59bdf056f | |||
| 01946282ed | |||
| 0d4c9cced2 | |||
| e9a6543a8b | |||
| 5f2248001d | |||
| 2dbc89b902 | |||
| 44ffedb56a | |||
| 93878511e5 | |||
| 503ee7372e | |||
| 62dc54fe1c | |||
| 626efa9c34 | |||
| b69c23c88a | |||
| 53f56ce086 | |||
| d48f9f4bd0 | |||
| b591bc522d | |||
| b15e422953 | |||
| 634162e0d9 | |||
| fbb4f90ebd | |||
| a05f9bd4ec | |||
| 831dbc5959 | |||
| d5dc499c6b | |||
| 5725e84971 | |||
| 86baaf3a1c |
+21
-81
@@ -2,7 +2,8 @@ name: Deploy
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
tags:
|
||||||
|
- 'v[0-9]*.[0-9]*.[0-9]*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
@@ -11,97 +12,36 @@ jobs:
|
|||||||
GOPROXY: https://goproxy.cn,direct
|
GOPROXY: https://goproxy.cn,direct
|
||||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Go tests
|
- name: Compile
|
||||||
working-directory: backend
|
run: sh scripts/ci/compile.sh "${{ gitea.ref_name }}"
|
||||||
run: go test ./...
|
|
||||||
|
|
||||||
- name: Flutter tests
|
- name: Test
|
||||||
working-directory: client
|
run: sh scripts/ci/test.sh
|
||||||
run: flutter test
|
|
||||||
|
|
||||||
- name: Build backend (linux/amd64)
|
- name: Release
|
||||||
working-directory: backend
|
|
||||||
run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- name: Package & Create Forgejo Release
|
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
|
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
|
||||||
run: |
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
tar -czf web.tar.gz -C client/build web
|
run: sh scripts/ci/release.sh "${{ gitea.ref_name }}"
|
||||||
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}")
|
|
||||||
|
|
||||||
RESP=$(curl -s -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases" \
|
- name: Deploy
|
||||||
-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
|
|
||||||
env:
|
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_HOST: ${{ secrets.EC2_HOST }}
|
||||||
EC2_USER: ${{ secrets.EC2_USER }}
|
EC2_USER: ${{ secrets.EC2_USER }}
|
||||||
run: |
|
run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Cleanup SSH key
|
- name: Notify
|
||||||
if: always()
|
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 }}"
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.1] - 2026-05-25
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 库存搜索支持拼音和拼音首字母(例:搜"mt"或"maotai"可匹配"茅台")
|
||||||
|
- 库存搜索改为回车/点击触发,减少无效请求
|
||||||
|
- 新增异常自动上报机制,线上错误可实时追踪
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-05-24
|
||||||
|
|
||||||
|
初始版本发布,完成核心库存管理功能。
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 商品管理:多规格商品档案、图片、公开二维码扫码展示
|
||||||
|
- 库存管理:入库审核、出库审核、库存盘点、流水记录
|
||||||
|
- 财务管理:往来账目、对账单
|
||||||
|
- 用户权限:管理员 / 成员 / 只读三级权限控制
|
||||||
|
- 多租户隔离:门店数据完全独立
|
||||||
|
- Flutter Web 管理端(支持 PC 浏览器)
|
||||||
|
- 商品扫码公开展示页
|
||||||
|
- Excel 批量导入商品与库存
|
||||||
@@ -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. 静态分析无 error(warning/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 发版。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 代码质量门禁
|
## 代码质量门禁
|
||||||
|
|
||||||
以下情况**不得提交**:
|
以下情况**不得提交**:
|
||||||
|
|||||||
@@ -132,21 +132,21 @@ func main() {
|
|||||||
hash := mustHash("password123")
|
hash := mustHash("password123")
|
||||||
admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any {
|
admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
ShopID: shop.ID,
|
||||||
Username: "admin", PasswordHash: hash, RealName: "张三(管理员)",
|
Username: "admin", PasswordHash: hash, RealName: "张三(管理员)",
|
||||||
Phone: "13800000001", Role: "admin", IsActive: true,
|
Phone: "13800000001", Role: "admin", IsActive: true,
|
||||||
}
|
}
|
||||||
}).(model.User)
|
}).(model.User)
|
||||||
operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any {
|
operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
ShopID: shop.ID,
|
||||||
Username: "operator", PasswordHash: hash, RealName: "李四(操作员)",
|
Username: "operator", PasswordHash: hash, RealName: "李四(操作员)",
|
||||||
Phone: "13800000002", Role: "operator", IsActive: true,
|
Phone: "13800000002", Role: "operator", IsActive: true,
|
||||||
}
|
}
|
||||||
}).(model.User)
|
}).(model.User)
|
||||||
upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any {
|
upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
ShopID: shop.ID,
|
||||||
Username: "test", PasswordHash: hash, RealName: "王五(只读)",
|
Username: "test", PasswordHash: hash, RealName: "王五(只读)",
|
||||||
Phone: "13800000003", Role: "readonly", IsActive: true,
|
Phone: "13800000003", Role: "readonly", IsActive: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
version: "1.1.1"
|
version: "1.0.0"
|
||||||
build_number: 2
|
build_number: 1
|
||||||
force_update: false
|
force_update: false
|
||||||
release_notes: "修复了离线模式问题,优化状态栏显示"
|
release_notes: "初始版本发布,完成核心库存管理功能。"
|
||||||
download_urls:
|
download_urls:
|
||||||
macos: ""
|
macos: ""
|
||||||
windows: ""
|
windows: ""
|
||||||
ios: ""
|
ios: ""
|
||||||
android: ""
|
android: ""
|
||||||
web: ""
|
web: "https://jiu.51yanmei.com/app"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -512,66 +513,126 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type importResult struct {
|
type importResult struct {
|
||||||
|
total int
|
||||||
imported int
|
imported int
|
||||||
updated int
|
updated int
|
||||||
skipped int
|
|
||||||
errors []string
|
errors []string
|
||||||
}
|
}
|
||||||
var res importResult
|
var res importResult
|
||||||
|
|
||||||
// 仓库缓存,只查找不创建
|
// 预加载仓库(1 次查询)
|
||||||
warehouseCache := map[string]*uint64{}
|
var warehouses []model.Warehouse
|
||||||
|
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
|
||||||
|
warehouseByName := make(map[string]uint64, len(warehouses))
|
||||||
|
for _, wh := range warehouses {
|
||||||
|
warehouseByName[wh.Name] = wh.ID
|
||||||
|
}
|
||||||
findWarehouse := func(name string) *uint64 {
|
findWarehouse := func(name string) *uint64 {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if idPtr, ok := warehouseCache[name]; ok {
|
if id, ok := warehouseByName[name]; ok {
|
||||||
return idPtr
|
return &id
|
||||||
}
|
}
|
||||||
var wh model.Warehouse
|
return nil
|
||||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
|
}
|
||||||
warehouseCache[name] = nil
|
|
||||||
return nil
|
// 预加载商品(1 次查询)
|
||||||
|
var allProducts []model.Product
|
||||||
|
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&allProducts)
|
||||||
|
productByCode := make(map[string]*model.Product, len(allProducts))
|
||||||
|
productByNSS := make(map[string]*model.Product, len(allProducts))
|
||||||
|
for i := range allProducts {
|
||||||
|
p := &allProducts[i]
|
||||||
|
if p.Code != "" {
|
||||||
|
productByCode[p.Code] = p
|
||||||
}
|
}
|
||||||
id := wh.ID
|
productByNSS[p.Name+"|"+p.Series+"|"+p.Spec] = p
|
||||||
warehouseCache[name] = &id
|
}
|
||||||
return &id
|
|
||||||
|
// 预加载已有导入库存(1 次查询)
|
||||||
|
// 同时建两套索引:编号索引 + 名称|系列|规格索引,供后续双路查找
|
||||||
|
var allInvs []model.Inventory
|
||||||
|
h.db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID).Find(&allInvs)
|
||||||
|
invByCode := make(map[string]*model.Inventory, len(allInvs)) // key: productCode|warehouseID
|
||||||
|
invByNSS := make(map[string]*model.Inventory, len(allInvs)) // key: name|series|spec|warehouseID
|
||||||
|
for i := range allInvs {
|
||||||
|
inv := &allInvs[i]
|
||||||
|
whID := uint64(0)
|
||||||
|
if inv.WarehouseID != nil {
|
||||||
|
whID = *inv.WarehouseID
|
||||||
|
}
|
||||||
|
if inv.ProductCode != "" {
|
||||||
|
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
|
||||||
|
}
|
||||||
|
nssKey := fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whID)
|
||||||
|
invByNSS[nssKey] = inv
|
||||||
|
}
|
||||||
|
lookupInv := func(productCode, name, series, spec string, whID uint64) *model.Inventory {
|
||||||
|
if productCode != "" {
|
||||||
|
if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok {
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return invByNSS[fmt.Sprintf("%s|%s|%s|%d", name, series, spec, whID)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic column detection from header row
|
// Dynamic column detection from header row
|
||||||
|
colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4
|
||||||
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
|
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
|
||||||
if len(rows) > 0 {
|
if len(rows) > 0 {
|
||||||
|
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
|
||||||
for j, h := range rows[0] {
|
for j, h := range rows[0] {
|
||||||
switch strings.TrimSpace(h) {
|
switch strings.TrimSpace(h) {
|
||||||
case "库存数量", "数量":
|
case "商品编号", "商品编码", "编号", "编码", "商品条码":
|
||||||
|
colProductCode = j
|
||||||
|
case "商品名称", "品名", "名称", "货品名称":
|
||||||
|
colProductName = j
|
||||||
|
case "系列", "品牌系列":
|
||||||
|
colSeries = j
|
||||||
|
case "规格", "规格型号":
|
||||||
|
colSpec = j
|
||||||
|
case "单位":
|
||||||
|
colUnit = j
|
||||||
|
case "库存数量", "数量", "库存":
|
||||||
colQty = j
|
colQty = j
|
||||||
case "单价":
|
case "单价", "进价", "采购单价":
|
||||||
colPrice = j
|
colPrice = j
|
||||||
case "生产日期":
|
case "生产日期", "生产年月":
|
||||||
colProductionDate = j
|
colProductionDate = j
|
||||||
case "批次", "批次号":
|
case "批次", "批次号":
|
||||||
colBatchNo = j
|
colBatchNo = j
|
||||||
case "所在仓库", "仓库":
|
case "所在仓库", "仓库", "库位":
|
||||||
colWarehouse = j
|
colWarehouse = j
|
||||||
case "供应商":
|
case "供应商", "供应商名称":
|
||||||
colSupplier = j
|
colSupplier = j
|
||||||
case "备注":
|
case "备注":
|
||||||
colRemark = j
|
colRemark = j
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.Printf("[import-inv] total rows=%d, colProductName=%d, colProductCode=%d, colQty=%d",
|
||||||
|
len(rows), colProductName, colProductCode, colQty)
|
||||||
|
|
||||||
|
// 如果没有匹配到任何列头,返回诊断信息
|
||||||
|
detectedHeader := strings.Join(rows[0], " | ")
|
||||||
|
|
||||||
|
var logsToCreate []model.InventoryLog
|
||||||
|
|
||||||
for i, row := range rows[1:] {
|
for i, row := range rows[1:] {
|
||||||
productName := cell(row, 1)
|
productName := cell(row, colProductName)
|
||||||
|
if i < 5 {
|
||||||
|
log.Printf("[import-inv] row[%d] len=%d | productName=%q productCode=%q qty=%q",
|
||||||
|
i+2, len(row), productName, cell(row, colProductCode), cell(row, colQty))
|
||||||
|
}
|
||||||
if productName == "" {
|
if productName == "" {
|
||||||
res.skipped++
|
continue // 空行(文件末尾填充行),不计入 total
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
productCode := cell(row, 0)
|
productCode := cell(row, colProductCode)
|
||||||
series := cell(row, 2)
|
series := cell(row, colSeries)
|
||||||
spec := cell(row, 3)
|
spec := cell(row, colSpec)
|
||||||
unit := cell(row, 4)
|
unit := cell(row, colUnit)
|
||||||
qtyStr := cell(row, colQty)
|
qtyStr := cell(row, colQty)
|
||||||
priceStr := cell(row, colPrice)
|
priceStr := cell(row, colPrice)
|
||||||
productionDateStr := cell(row, colProductionDate)
|
productionDateStr := cell(row, colProductionDate)
|
||||||
@@ -586,20 +647,32 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
price, _ := strconv.ParseFloat(priceStr, 64)
|
price, _ := strconv.ParseFloat(priceStr, 64)
|
||||||
|
|
||||||
// 只查找商品,不强制创建
|
res.total++ // 有商品名称的行才计入总数
|
||||||
var prod model.Product
|
|
||||||
if h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))",
|
// 从缓存查商品,找不到才创建
|
||||||
shopID, productCode, productName, series, spec).First(&prod).Error != nil {
|
var prod *model.Product
|
||||||
// 若找不到则创建
|
if productCode != "" {
|
||||||
|
prod = productByCode[productCode]
|
||||||
|
}
|
||||||
|
if prod == nil {
|
||||||
|
prod = productByNSS[productName+"|"+series+"|"+spec]
|
||||||
|
}
|
||||||
|
if prod == nil {
|
||||||
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
|
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
prod = newProd
|
allProducts = append(allProducts, newProd)
|
||||||
|
prod = &allProducts[len(allProducts)-1]
|
||||||
|
if prod.Code != "" {
|
||||||
|
productByCode[prod.Code] = prod
|
||||||
|
}
|
||||||
|
productByNSS[prod.Name+"|"+prod.Series+"|"+prod.Spec] = prod
|
||||||
}
|
}
|
||||||
if unit != "" && prod.Unit == "" {
|
if unit != "" && prod.Unit == "" {
|
||||||
h.db.Model(&prod).Update("unit", unit)
|
h.db.Model(prod).Update("unit", unit)
|
||||||
|
prod.Unit = unit
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析生产日期
|
// 解析生产日期
|
||||||
@@ -609,7 +682,6 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
productionDate = &d
|
productionDate = &d
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找仓库(只查,不创建)
|
|
||||||
whIDPtr := findWarehouse(warehouseName)
|
whIDPtr := findWarehouse(warehouseName)
|
||||||
|
|
||||||
var unitPricePtr *float64
|
var unitPricePtr *float64
|
||||||
@@ -617,30 +689,24 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
unitPricePtr = &price
|
unitPricePtr = &price
|
||||||
}
|
}
|
||||||
|
|
||||||
productIDCopy := prod.ID
|
whIDVal := uint64(0)
|
||||||
|
|
||||||
// Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建
|
|
||||||
var existing model.Inventory
|
|
||||||
q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL",
|
|
||||||
shopID, prod.Code)
|
|
||||||
if whIDPtr != nil {
|
if whIDPtr != nil {
|
||||||
q = q.Where("warehouse_id = ?", *whIDPtr)
|
whIDVal = *whIDPtr
|
||||||
} else {
|
|
||||||
q = q.Where("warehouse_id IS NULL")
|
|
||||||
}
|
}
|
||||||
found := q.First(&existing).Error == nil
|
|
||||||
|
|
||||||
if found {
|
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"quantity": qty,
|
"quantity": qty,
|
||||||
"product_name": prod.Name,
|
"product_name": prod.Name,
|
||||||
"series": prod.Series,
|
"series": prod.Series,
|
||||||
"spec": prod.Spec,
|
"spec": prod.Spec,
|
||||||
"unit": prod.Unit,
|
"unit": prod.Unit,
|
||||||
"warehouse_name": warehouseName,
|
"warehouse_name": warehouseName,
|
||||||
"supplier_name": supplierName,
|
"supplier_name": supplierName,
|
||||||
"remark": remark,
|
"remark": remark,
|
||||||
"deleted_at": nil,
|
"deleted_at": nil,
|
||||||
}
|
}
|
||||||
if unitPricePtr != nil {
|
if unitPricePtr != nil {
|
||||||
updates["unit_price"] = *unitPricePtr
|
updates["unit_price"] = *unitPricePtr
|
||||||
@@ -651,11 +717,18 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
if batchNo != "" {
|
if batchNo != "" {
|
||||||
updates["batch_no"] = batchNo
|
updates["batch_no"] = batchNo
|
||||||
}
|
}
|
||||||
if err := h.db.Model(&existing).Updates(updates).Error; err != nil {
|
if err := h.db.Model(existing).Updates(updates).Error; err != nil {
|
||||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
logsToCreate = append(logsToCreate, model.InventoryLog{
|
||||||
|
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
|
||||||
|
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty,
|
||||||
|
RefType: "import", RefID: 0,
|
||||||
|
})
|
||||||
|
res.updated++
|
||||||
} else {
|
} else {
|
||||||
|
productIDCopy := prod.ID
|
||||||
inv := model.Inventory{
|
inv := model.Inventory{
|
||||||
ShopID: shopID,
|
ShopID: shopID,
|
||||||
WarehouseID: whIDPtr,
|
WarehouseID: whIDPtr,
|
||||||
@@ -678,38 +751,38 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
// 写入两套缓存,防止同文件后续行重复插入
|
||||||
|
if inv.ProductCode != "" {
|
||||||
// 写流水
|
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whIDVal)] = &inv
|
||||||
warehouseID := uint64(0)
|
}
|
||||||
if whIDPtr != nil {
|
invByNSS[fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whIDVal)] = &inv
|
||||||
warehouseID = *whIDPtr
|
logsToCreate = append(logsToCreate, model.InventoryLog{
|
||||||
}
|
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
|
||||||
qtyBefore := 0.0
|
Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty,
|
||||||
if found {
|
RefType: "import", RefID: 0,
|
||||||
qtyBefore = existing.Quantity
|
})
|
||||||
res.updated++
|
|
||||||
} else {
|
|
||||||
res.imported++
|
res.imported++
|
||||||
}
|
}
|
||||||
log := model.InventoryLog{
|
|
||||||
ShopID: shopID,
|
|
||||||
WarehouseID: warehouseID,
|
|
||||||
ProductID: prod.ID,
|
|
||||||
Direction: "in",
|
|
||||||
Quantity: qty,
|
|
||||||
QtyBefore: qtyBefore,
|
|
||||||
QtyAfter: qty,
|
|
||||||
RefType: "import",
|
|
||||||
RefID: 0,
|
|
||||||
}
|
|
||||||
h.db.Create(&log)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量写流水
|
||||||
|
if len(logsToCreate) > 0 {
|
||||||
|
h.db.CreateInBatches(&logsToCreate, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// total=0 说明列格式不匹配,没有解析到任何有效行
|
||||||
|
if res.total == 0 {
|
||||||
|
res.errors = append(res.errors,
|
||||||
|
fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d",
|
||||||
|
res.total, res.imported, res.updated, len(res.errors))
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"total": res.total,
|
||||||
"imported": res.imported,
|
"imported": res.imported,
|
||||||
"updated": res.updated,
|
"updated": res.updated,
|
||||||
"skipped": res.skipped,
|
|
||||||
"errors": res.errors,
|
"errors": res.errors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
|||||||
inStock := c.Query("in_stock")
|
inStock := c.Query("in_stock")
|
||||||
|
|
||||||
if keyword != "" {
|
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 + "%"
|
like := "%" + keyword + "%"
|
||||||
args = append(args, like, like)
|
args = append(args, like, like, like, like)
|
||||||
}
|
}
|
||||||
if warehouseIDStr != "" {
|
if warehouseIDStr != "" {
|
||||||
baseWhere += " AND inv.warehouse_id = ?"
|
baseWhere += " AND inv.warehouse_id = ?"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/wangjia/jiu/backend/config"
|
"github.com/wangjia/jiu/backend/config"
|
||||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||||
"github.com/wangjia/jiu/backend/internal/model"
|
"github.com/wangjia/jiu/backend/internal/model"
|
||||||
|
"github.com/wangjia/jiu/backend/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProductHandler struct {
|
type ProductHandler struct {
|
||||||
@@ -70,6 +71,7 @@ func (h *ProductHandler) Create(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
product.ShopID = shopID
|
product.ShopID = shopID
|
||||||
product.PublicID = uuid.New().String()
|
product.PublicID = uuid.New().String()
|
||||||
|
product.NamePinyin, product.NameInitials = util.ToPinyin(product.Name)
|
||||||
|
|
||||||
// Auto-generate product code if not provided (e.g. P001, P002)
|
// Auto-generate product code if not provided (e.g. P001, P002)
|
||||||
// Retry up to 5 times on duplicate key to handle concurrent creates
|
// Retry up to 5 times on duplicate key to handle concurrent creates
|
||||||
@@ -122,6 +124,7 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namePinyin, nameInitials := util.ToPinyin(req.Name)
|
||||||
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
|
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
|
||||||
if err := h.db.Model(&product).Updates(map[string]interface{}{
|
if err := h.db.Model(&product).Updates(map[string]interface{}{
|
||||||
"code": req.Code,
|
"code": req.Code,
|
||||||
@@ -138,6 +141,8 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
|||||||
"description": req.Description,
|
"description": req.Description,
|
||||||
"remark": req.Remark,
|
"remark": req.Remark,
|
||||||
"custom_fields": req.CustomFields,
|
"custom_fields": req.CustomFields,
|
||||||
|
"name_pinyin": namePinyin,
|
||||||
|
"name_initials": nameInitials,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -226,13 +231,16 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
|
|||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count)
|
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{
|
product = model.Product{
|
||||||
TenantBase: model.TenantBase{ShopID: shopID},
|
TenantBase: model.TenantBase{ShopID: shopID},
|
||||||
PublicID: uuid.New().String(),
|
PublicID: uuid.New().String(),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Series: req.Series,
|
Series: req.Series,
|
||||||
Spec: req.Spec,
|
Spec: req.Spec,
|
||||||
Code: fmt.Sprintf("P%03d", count+1),
|
Code: fmt.Sprintf("P%03d", count+1),
|
||||||
|
NamePinyin: namePinyin,
|
||||||
|
NameInitials: nameInitials,
|
||||||
}
|
}
|
||||||
if createErr := h.db.Create(&product).Error; createErr != nil {
|
if createErr := h.db.Create(&product).Error; createErr != nil {
|
||||||
// Race condition: try to find the record created by another request
|
// Race condition: try to find the record created by another request
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -80,23 +78,18 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
|
|||||||
|
|
||||||
// GetRelease GET /api/v1/public/release (no auth)
|
// GetRelease GET /api/v1/public/release (no auth)
|
||||||
func (h *PublicHandler) GetRelease(c *gin.Context) {
|
func (h *PublicHandler) GetRelease(c *gin.Context) {
|
||||||
version := os.Getenv("APP_VERSION")
|
cfg, err := loadVersionConfig()
|
||||||
if version == "" {
|
if err != nil {
|
||||||
version = "1.0.0"
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"version": version,
|
"version": cfg.Version,
|
||||||
"build_date": buildDate,
|
"release_notes": cfg.ReleaseNotes,
|
||||||
"download_urls": gin.H{
|
"download_urls": cfg.DownloadURLs,
|
||||||
"macos": os.Getenv("DOWNLOAD_URL_MACOS"),
|
|
||||||
"windows": os.Getenv("DOWNLOAD_URL_WINDOWS"),
|
|
||||||
"web": "https://jiu.51yanmei.com/app",
|
|
||||||
},
|
|
||||||
"changelog": []gin.H{},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/wangjia/jiu/backend/config"
|
||||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||||
"github.com/wangjia/jiu/backend/internal/model"
|
"github.com/wangjia/jiu/backend/internal/model"
|
||||||
)
|
)
|
||||||
@@ -38,6 +46,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
|||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
ManagerName string `json:"manager_name"`
|
ManagerName string `json:"manager_name"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -50,6 +59,9 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
|||||||
"phone": req.Phone,
|
"phone": req.Phone,
|
||||||
"manager_name": req.ManagerName,
|
"manager_name": req.ManagerName,
|
||||||
}
|
}
|
||||||
|
if req.LogoURL != "" {
|
||||||
|
updates["logo_url"] = req.LogoURL
|
||||||
|
}
|
||||||
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil {
|
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -59,3 +71,48 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
|||||||
h.db.First(&shop, shopID)
|
h.db.First(&shop, shopID)
|
||||||
c.JSON(http.StatusOK, shop)
|
c.JSON(http.StatusOK, shop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadLogo POST /api/v1/shop/logo (admin only)
|
||||||
|
func (h *ShopHandler) UploadLogo(c *gin.Context) {
|
||||||
|
shopID := middleware.GetShopID(c)
|
||||||
|
|
||||||
|
if err := c.Request.ParseMultipartForm(2 << 20); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 2MB 限制"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, _, err := image.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 裁剪为正方形后缩放到 256×256
|
||||||
|
resized := imaging.Fill(img, 256, 256, imaging.Center, imaging.Lanczos)
|
||||||
|
|
||||||
|
subdir := filepath.Join(config.C.Storage.UploadDir, "shops", fmt.Sprintf("%d", shopID))
|
||||||
|
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(subdir, "logo.jpg")
|
||||||
|
|
||||||
|
if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(90)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logoURL := fmt.Sprintf("/images/shops/%d/logo.jpg", shopID)
|
||||||
|
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Update("logo_url", logoURL).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"logo_url": logoURL})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (h *UserHandler) Create(c *gin.Context) {
|
|||||||
role = "operator"
|
role = "operator"
|
||||||
}
|
}
|
||||||
u := model.User{
|
u := model.User{
|
||||||
TenantBase: model.TenantBase{ShopID: shopID},
|
ShopID: shopID,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
RealName: req.RealName,
|
RealName: req.RealName,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ type Product struct {
|
|||||||
Description string `gorm:"type:text" json:"description"`
|
Description string `gorm:"type:text" json:"description"`
|
||||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||||
Remark string `gorm:"size:500" json:"remark"`
|
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"`
|
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||||
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
|
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Shop struct {
|
|||||||
Phone string `gorm:"size:30" json:"phone"`
|
Phone string `gorm:"size:30" json:"phone"`
|
||||||
BusinessHours string `gorm:"size:100" json:"business_hours"`
|
BusinessHours string `gorm:"size:100" json:"business_hours"`
|
||||||
ManagerName string `gorm:"size:50" json:"manager_name"`
|
ManagerName string `gorm:"size:50" json:"manager_name"`
|
||||||
|
LogoURL string `gorm:"column:logo_url;size:500" json:"logo_url"`
|
||||||
BusinessLicense string `gorm:"size:500" json:"business_license"`
|
BusinessLicense string `gorm:"size:500" json:"business_license"`
|
||||||
ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"`
|
ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"`
|
||||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
TenantBase
|
Base
|
||||||
|
ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"`
|
||||||
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
|
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
|
||||||
PasswordHash string `gorm:"size:255" json:"-"`
|
PasswordHash string `gorm:"size:255" json:"-"`
|
||||||
RealName string `gorm:"size:50" json:"real_name"`
|
RealName string `gorm:"size:50" json:"real_name"`
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
publicH := handler.NewPublicHandler(db)
|
publicH := handler.NewPublicHandler(db)
|
||||||
adminH := handler.NewAdminHandler(db)
|
adminH := handler.NewAdminHandler(db)
|
||||||
shopH := handler.NewShopHandler(db)
|
shopH := handler.NewShopHandler(db)
|
||||||
|
errorReportH := handler.NewErrorReportHandler(db)
|
||||||
|
|
||||||
// 健康检查(无需认证,用于前端连通性探测)
|
// 健康检查(无需认证,用于前端连通性探测)
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
@@ -56,6 +57,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
{
|
{
|
||||||
public.GET("/products/:public_id", publicH.GetProduct)
|
public.GET("/products/:public_id", publicH.GetProduct)
|
||||||
public.GET("/release", publicH.GetRelease)
|
public.GET("/release", publicH.GetRelease)
|
||||||
|
public.POST("/errors", errorReportH.Submit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
||||||
@@ -165,6 +167,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
{
|
{
|
||||||
shop.GET("/info", shopH.GetInfo)
|
shop.GET("/info", shopH.GetInfo)
|
||||||
shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo)
|
shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo)
|
||||||
|
shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编号规则
|
// 编号规则
|
||||||
@@ -212,6 +215,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
superAdmin.Use(middleware.SuperAdminOnly())
|
superAdmin.Use(middleware.SuperAdminOnly())
|
||||||
{
|
{
|
||||||
superAdmin.POST("/clear-data", adminH.ClearData)
|
superAdmin.POST("/clear-data", adminH.ClearData)
|
||||||
|
superAdmin.GET("/errors", errorReportH.List)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/wangjia/jiu/backend/config"
|
"github.com/wangjia/jiu/backend/config"
|
||||||
"github.com/wangjia/jiu/backend/internal/model"
|
"github.com/wangjia/jiu/backend/internal/model"
|
||||||
"github.com/wangjia/jiu/backend/internal/router"
|
"github.com/wangjia/jiu/backend/internal/router"
|
||||||
|
"github.com/wangjia/jiu/backend/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -27,6 +28,9 @@ func main() {
|
|||||||
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
|
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
|
||||||
autoMigrate(db)
|
autoMigrate(db)
|
||||||
|
|
||||||
|
// 回填存量商品的拼音索引(一次性,已有值的跳过)
|
||||||
|
backfillPinyin(db)
|
||||||
|
|
||||||
// 启动 Gin
|
// 启动 Gin
|
||||||
gin.SetMode(config.C.Server.Mode)
|
gin.SetMode(config.C.Server.Mode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -104,9 +108,26 @@ func autoMigrate(db *gorm.DB) {
|
|||||||
&model.ProductSeriesOption{},
|
&model.ProductSeriesOption{},
|
||||||
&model.ProductSpecOption{},
|
&model.ProductSpecOption{},
|
||||||
&model.ProductImage{},
|
&model.ProductImage{},
|
||||||
|
&model.ErrorReport{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("auto migrate failed: %v", err)
|
log.Fatalf("auto migrate failed: %v", err)
|
||||||
}
|
}
|
||||||
log.Println("AutoMigrate completed")
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `shops` (
|
|||||||
`address` VARCHAR(255) DEFAULT NULL,
|
`address` VARCHAR(255) DEFAULT NULL,
|
||||||
`phone` VARCHAR(30) DEFAULT NULL,
|
`phone` VARCHAR(30) DEFAULT NULL,
|
||||||
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
|
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
|
||||||
|
`logo_url` VARCHAR(500) DEFAULT '' COMMENT '门店 logo URL',
|
||||||
`business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL',
|
`business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL',
|
||||||
`shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组',
|
`shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组',
|
||||||
`custom_fields` JSON DEFAULT NULL COMMENT '扩展字段',
|
`custom_fields` JSON DEFAULT NULL COMMENT '扩展字段',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func SetupTestDB() *gorm.DB {
|
|||||||
address TEXT,
|
address TEXT,
|
||||||
phone TEXT,
|
phone TEXT,
|
||||||
manager_name TEXT,
|
manager_name TEXT,
|
||||||
|
logo_url TEXT DEFAULT '',
|
||||||
business_license TEXT,
|
business_license TEXT,
|
||||||
business_hours TEXT,
|
business_hours TEXT,
|
||||||
shop_photos TEXT,
|
shop_photos TEXT,
|
||||||
@@ -120,7 +121,9 @@ func SetupTestDB() *gorm.DB {
|
|||||||
min_stock INTEGER DEFAULT 0,
|
min_stock INTEGER DEFAULT 0,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
custom_fields TEXT,
|
custom_fields TEXT,
|
||||||
remark TEXT
|
remark TEXT,
|
||||||
|
name_pinyin TEXT DEFAULT '',
|
||||||
|
name_initials TEXT DEFAULT ''
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS warehouses (
|
`CREATE TABLE IF NOT EXISTS warehouses (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -349,10 +352,7 @@ func hashPassword(plain string) string {
|
|||||||
func CreateTestUser(db *gorm.DB, shopID uint64, username, password, role string) *model.User {
|
func CreateTestUser(db *gorm.DB, shopID uint64, username, password, role string) *model.User {
|
||||||
hash := hashPassword(password)
|
hash := hashPassword(password)
|
||||||
user := &model.User{
|
user := &model.User{
|
||||||
TenantBase: model.TenantBase{
|
ShopID: shopID,
|
||||||
Base: model.Base{},
|
|
||||||
ShopID: shopID,
|
|
||||||
},
|
|
||||||
Username: username,
|
Username: username,
|
||||||
PasswordHash: hash,
|
PasswordHash: hash,
|
||||||
RealName: "Test User " + username,
|
RealName: "Test User " + username,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../auth/auth_state.dart';
|
import '../auth/auth_state.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
|
import '../errors/error_reporter.dart';
|
||||||
import '../../providers/connectivity_provider.dart';
|
import '../../providers/connectivity_provider.dart';
|
||||||
|
|
||||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||||
@@ -74,6 +75,16 @@ class ApiClient {
|
|||||||
return handler.next(e);
|
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) {
|
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
|
||||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../errors/error_reporter.dart';
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
@@ -189,7 +190,12 @@ Future<void> printProductLabelImpl({
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pw.Widget _label2ColRow(
|
pw.Widget _label2ColRow(
|
||||||
@@ -424,7 +430,12 @@ Future<void> printStockInOrderImpl(StockInOrder 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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||||
@@ -474,5 +485,10 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
));
|
));
|
||||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
try {
|
||||||
|
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||||
|
} catch (e, st) {
|
||||||
|
reportError(e, st);
|
||||||
|
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
import 'core/auth/auth_state.dart';
|
import 'core/auth/auth_state.dart';
|
||||||
|
import 'core/errors/error_reporter.dart';
|
||||||
import 'core/router/app_router.dart';
|
import 'core/router/app_router.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'providers/connectivity_provider.dart';
|
import 'providers/connectivity_provider.dart';
|
||||||
@@ -15,6 +16,11 @@ void main() {
|
|||||||
debugPrint('═══ FlutterError ════════════════════════════');
|
debugPrint('═══ FlutterError ════════════════════════════');
|
||||||
debugPrint(details.exceptionAsString());
|
debugPrint(details.exceptionAsString());
|
||||||
debugPrint(details.stack.toString());
|
debugPrint(details.stack.toString());
|
||||||
|
ErrorReporter.instance.report(
|
||||||
|
errorType: ErrorType.flutterError,
|
||||||
|
errorMsg: details.exceptionAsString(),
|
||||||
|
stackTrace: details.stack,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
runZonedGuarded(
|
runZonedGuarded(
|
||||||
@@ -23,6 +29,11 @@ void main() {
|
|||||||
debugPrint('═══ Zone Error ═══════════════════════════════');
|
debugPrint('═══ Zone Error ═══════════════════════════════');
|
||||||
debugPrint(error.toString());
|
debugPrint(error.toString());
|
||||||
debugPrint(stack.toString());
|
debugPrint(stack.toString());
|
||||||
|
ErrorReporter.instance.report(
|
||||||
|
errorType: ErrorType.zoneError,
|
||||||
|
errorMsg: error.toString(),
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class ShopInfo {
|
|||||||
final String address;
|
final String address;
|
||||||
final String phone;
|
final String phone;
|
||||||
final String managerName;
|
final String managerName;
|
||||||
|
final String logoUrl;
|
||||||
|
|
||||||
const ShopInfo({
|
const ShopInfo({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -13,6 +14,7 @@ class ShopInfo {
|
|||||||
required this.address,
|
required this.address,
|
||||||
required this.phone,
|
required this.phone,
|
||||||
required this.managerName,
|
required this.managerName,
|
||||||
|
this.logoUrl = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
|
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
|
||||||
@@ -22,5 +24,6 @@ class ShopInfo {
|
|||||||
address: json['address'] as String? ?? '',
|
address: json['address'] as String? ?? '',
|
||||||
phone: json['phone'] as String? ?? '',
|
phone: json['phone'] as String? ?? '',
|
||||||
managerName: json['manager_name'] as String? ?? '',
|
managerName: json['manager_name'] as String? ?? '',
|
||||||
|
logoUrl: json['logo_url'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import '../core/api/api_client.dart';
|
import '../core/api/api_client.dart';
|
||||||
import '../core/exceptions.dart';
|
import '../core/exceptions.dart';
|
||||||
import '../models/shop.dart';
|
import '../models/shop.dart';
|
||||||
@@ -30,4 +31,19 @@ class ShopRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> uploadLogo(Uint8List bytes, String filename) async {
|
||||||
|
try {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': MultipartFile.fromBytes(bytes, filename: filename),
|
||||||
|
});
|
||||||
|
final resp = await _client.post('/shop/logo', data: formData);
|
||||||
|
return (resp.data as Map<String, dynamic>)['logo_url'] as String? ?? '';
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(
|
||||||
|
e.response?.data?['error'] as String? ?? 'Logo 上传失败',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,21 +28,16 @@ class InventoryListScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||||
final _searchCtrl = TextEditingController();
|
final _searchCtrl = TextEditingController();
|
||||||
Timer? _debounce;
|
|
||||||
Set<String> _filterWarehouse = {};
|
Set<String> _filterWarehouse = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
_debounce?.cancel();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSearchChanged(String value) {
|
void _triggerSearch() {
|
||||||
_debounce?.cancel();
|
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
|
||||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
|
||||||
ref.read(inventoryListProvider.notifier).setKeyword(value);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _editRemark(BuildContext context, Inventory item) async {
|
Future<void> _editRemark(BuildContext context, Inventory item) async {
|
||||||
@@ -99,42 +94,97 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
final file = result.files.first;
|
final file = result.files.first;
|
||||||
final bytes = file.bytes;
|
final bytes = file.bytes;
|
||||||
if (bytes == null) return;
|
if (bytes == null) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
||||||
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
|
final dio = Dio(BaseOptions(
|
||||||
|
baseUrl: AppConfig.apiBaseUrl,
|
||||||
|
sendTimeout: const Duration(seconds: 120),
|
||||||
|
receiveTimeout: const Duration(seconds: 300),
|
||||||
|
));
|
||||||
dio.options.headers['Authorization'] = 'Bearer $token';
|
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
|
||||||
if (!context.mounted) return;
|
final stateNotifier = ValueNotifier<_ImportState>(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
const _ImportState(stage: _ImportStage.uploading, uploadPercent: 0),
|
||||||
const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
BuildContext? dialogCtx;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) {
|
||||||
|
dialogCtx = ctx;
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: _ImportProgressDialog(stateNotifier: stateNotifier),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Timer? processingTimer;
|
||||||
|
bool uploadDone = false;
|
||||||
|
int elapsed = 0;
|
||||||
|
|
||||||
|
void closeDialog() {
|
||||||
|
processingTimer?.cancel();
|
||||||
|
if (dialogCtx != null && dialogCtx!.mounted) {
|
||||||
|
Navigator.of(dialogCtx!).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': MultipartFile.fromBytes(bytes, filename: file.name),
|
'file': MultipartFile.fromBytes(bytes, filename: file.name),
|
||||||
});
|
});
|
||||||
final resp = await dio.post('/import/inventory', data: formData);
|
|
||||||
final data = resp.data as Map<String, dynamic>;
|
|
||||||
final imported = data['imported'] ?? 0;
|
|
||||||
final skipped = data['skipped'] ?? 0;
|
|
||||||
final errors = (data['errors'] as List?)?.cast<String>() ?? [];
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
final resp = await dio.post(
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
'/import/inventory',
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
data: formData,
|
||||||
content: Text('导入完成:$imported 条成功,$skipped 条跳过${errors.isNotEmpty ? ',${errors.length} 条失败' : ''}'),
|
onSendProgress: (sent, total) {
|
||||||
backgroundColor: errors.isEmpty ? Colors.green : AppTheme.accent,
|
if (total <= 0) return;
|
||||||
duration: const Duration(seconds: 4),
|
if (sent >= total && !uploadDone) {
|
||||||
));
|
uploadDone = true;
|
||||||
ref.read(inventoryListProvider.notifier).reload();
|
elapsed = 0;
|
||||||
|
stateNotifier.value = const _ImportState(
|
||||||
|
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: 0,
|
||||||
|
);
|
||||||
|
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
elapsed++;
|
||||||
|
stateNotifier.value = _ImportState(
|
||||||
|
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: elapsed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else if (!uploadDone) {
|
||||||
|
final pct = (sent / total * 100).round().clamp(0, 99);
|
||||||
|
stateNotifier.value = _ImportState(stage: _ImportStage.uploading, uploadPercent: pct);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
processingTimer?.cancel();
|
||||||
|
final data = resp.data as Map<String, dynamic>;
|
||||||
|
stateNotifier.value = _ImportState(
|
||||||
|
stage: _ImportStage.done,
|
||||||
|
uploadPercent: 100,
|
||||||
|
total: data['total'] ?? 0,
|
||||||
|
imported: data['imported'] ?? 0,
|
||||||
|
updated: data['updated'] ?? 0,
|
||||||
|
errors: (data['errors'] as List?)?.cast<String>() ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final hasErrors = (data['errors'] as List?)?.isNotEmpty ?? false;
|
||||||
|
if (!hasErrors) {
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
if (context.mounted) ref.read(inventoryListProvider.notifier).reload();
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? e.message ?? '未知错误';
|
processingTimer?.cancel();
|
||||||
if (!context.mounted) return;
|
final msg = (e.response?.data is Map ? e.response!.data['error'] : null)
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
?? e.message ?? '未知错误';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
stateNotifier.value = _ImportState(
|
||||||
content: Text('导入失败:$msg'),
|
stage: _ImportStage.error, uploadPercent: 0, errorMsg: msg,
|
||||||
backgroundColor: AppTheme.danger,
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,15 +328,20 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 200,
|
width: 220,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchCtrl,
|
controller: _searchCtrl,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: '搜索商品名/编码',
|
hintText: '名称/编码/拼音,回车搜索',
|
||||||
prefixIcon: Icon(Icons.search, size: 16),
|
prefixIcon: const Icon(Icons.search, size: 16),
|
||||||
hintStyle: TextStyle(fontSize: 13),
|
hintStyle: const TextStyle(fontSize: 12),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.search, size: 16),
|
||||||
|
tooltip: '搜索',
|
||||||
|
onPressed: _triggerSearch,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onChanged: _onSearchChanged,
|
onSubmitted: (_) => _triggerSearch(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -865,3 +920,163 @@ class _DirectionBadge extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 导入进度对话框 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
enum _ImportStage { uploading, processing, done, error }
|
||||||
|
|
||||||
|
class _ImportState {
|
||||||
|
final _ImportStage stage;
|
||||||
|
final int uploadPercent;
|
||||||
|
final int processingSeconds;
|
||||||
|
final int total;
|
||||||
|
final int imported;
|
||||||
|
final int updated;
|
||||||
|
final List<String> errors;
|
||||||
|
final String? errorMsg;
|
||||||
|
|
||||||
|
const _ImportState({
|
||||||
|
required this.stage,
|
||||||
|
required this.uploadPercent,
|
||||||
|
this.processingSeconds = 0,
|
||||||
|
this.total = 0,
|
||||||
|
this.imported = 0,
|
||||||
|
this.updated = 0,
|
||||||
|
this.errors = const [],
|
||||||
|
this.errorMsg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportProgressDialog extends StatelessWidget {
|
||||||
|
final ValueNotifier<_ImportState> stateNotifier;
|
||||||
|
const _ImportProgressDialog({required this.stateNotifier});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('导入库存'),
|
||||||
|
content: ValueListenableBuilder<_ImportState>(
|
||||||
|
valueListenable: stateNotifier,
|
||||||
|
builder: (_, state, __) => SizedBox(
|
||||||
|
width: 320,
|
||||||
|
child: _buildContent(context, state),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
ValueListenableBuilder<_ImportState>(
|
||||||
|
valueListenable: stateNotifier,
|
||||||
|
builder: (ctx, state, __) {
|
||||||
|
if (state.stage == _ImportStage.done || state.stage == _ImportStage.error) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('关闭'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, _ImportState state) {
|
||||||
|
switch (state.stage) {
|
||||||
|
case _ImportStage.uploading:
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('上传文件...', style: TextStyle(fontSize: 14)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: state.uploadPercent / 100,
|
||||||
|
backgroundColor: Colors.grey[200],
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('${state.uploadPercent}%',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
case _ImportStage.processing:
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 16, height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'导入数据中...${state.processingSeconds > 0 ? "(${state.processingSeconds}秒)" : ""}',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
backgroundColor: Colors.grey[200],
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
case _ImportStage.done:
|
||||||
|
final allFailed = state.total == 0 && state.errors.isNotEmpty;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
Icon(
|
||||||
|
allFailed ? Icons.warning_amber_rounded : Icons.check_circle,
|
||||||
|
color: allFailed ? AppTheme.danger : AppTheme.success,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
allFailed ? '导入失败' : '导入完成',
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text('共 ${state.total} 行', style: const TextStyle(fontSize: 13)),
|
||||||
|
Text('成功插入:${state.imported} 行', style: const TextStyle(fontSize: 13)),
|
||||||
|
Text('重复更新:${state.updated} 行',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||||
|
if (state.errors.isNotEmpty)
|
||||||
|
Text('失败:${state.errors.length} 行',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||||
|
if (state.errors.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
...state.errors.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(e,
|
||||||
|
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
case _ImportStage.error:
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: AppTheme.danger, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'导入失败:${state.errorMsg}',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.danger),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
|||||||
slivers: [
|
slivers: [
|
||||||
// 1. Gallery
|
// 1. Gallery
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _HeroGallery(imageUrls: imageUrls),
|
child: _HeroGallery(imageUrls: imageUrls, shopName: shop?['name'] as String? ?? ''),
|
||||||
),
|
),
|
||||||
// 2. Verified ribbon
|
// 2. Verified ribbon
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -227,7 +227,8 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
|||||||
|
|
||||||
class _HeroGallery extends StatefulWidget {
|
class _HeroGallery extends StatefulWidget {
|
||||||
final List<String> imageUrls;
|
final List<String> imageUrls;
|
||||||
const _HeroGallery({required this.imageUrls});
|
final String shopName;
|
||||||
|
const _HeroGallery({required this.imageUrls, this.shopName = ''});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_HeroGallery> createState() => _HeroGalleryState();
|
State<_HeroGallery> createState() => _HeroGalleryState();
|
||||||
@@ -280,7 +281,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
|
|||||||
),
|
),
|
||||||
// Image or placeholder
|
// Image or placeholder
|
||||||
if (urls.isEmpty)
|
if (urls.isEmpty)
|
||||||
_EmptyGalleryContent()
|
_EmptyGalleryContent(shopName: widget.shopName)
|
||||||
else
|
else
|
||||||
PageView.builder(
|
PageView.builder(
|
||||||
controller: _ctrl,
|
controller: _ctrl,
|
||||||
@@ -291,7 +292,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
|
|||||||
child: Image.network(
|
child: Image.network(
|
||||||
urls[i],
|
urls[i],
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, __, ___) => _EmptyGalleryContent(),
|
errorBuilder: (_, __, ___) => _EmptyGalleryContent(shopName: widget.shopName),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -409,8 +410,12 @@ class _HeroGalleryState extends State<_HeroGallery> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _EmptyGalleryContent extends StatelessWidget {
|
class _EmptyGalleryContent extends StatelessWidget {
|
||||||
|
final String shopName;
|
||||||
|
const _EmptyGalleryContent({this.shopName = ''});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -421,9 +426,9 @@ class _EmptyGalleryContent extends StatelessWidget {
|
|||||||
color: const Color(0xFF0F3057),
|
color: const Color(0xFF0F3057),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text('岩美',
|
child: Text(initial,
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 18, fontWeight: FontWeight.w700)),
|
style: const TextStyle(color: Colors.white70, fontSize: 28, fontWeight: FontWeight.w700)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import '../../core/utils/dialog_util.dart';
|
import '../../core/utils/dialog_util.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -126,14 +127,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_ShopInfoRow(
|
// Logo 预览行
|
||||||
label: '门店编号',
|
Row(
|
||||||
value: shop.code.isNotEmpty ? shop.code : '—'),
|
children: [
|
||||||
const Divider(height: 16),
|
_ShopLogoPreview(logoUrl: shop.logoUrl, shopName: shop.name, size: 64),
|
||||||
_ShopInfoRow(
|
const SizedBox(width: 16),
|
||||||
label: '门店名称',
|
Column(
|
||||||
value: shop.name.isNotEmpty ? shop.name : '—'),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const Divider(height: 16),
|
children: [
|
||||||
|
Text(shop.name.isNotEmpty ? shop.name : '—',
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(shop.code.isNotEmpty ? shop.code : '—',
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
_ShopInfoRow(
|
_ShopInfoRow(
|
||||||
label: '门店地址',
|
label: '门店地址',
|
||||||
value: shop.address.isNotEmpty ? shop.address : '—'),
|
value: shop.address.isNotEmpty ? shop.address : '—'),
|
||||||
@@ -1524,10 +1535,23 @@ class _ImportSlot {
|
|||||||
int total = 0;
|
int total = 0;
|
||||||
int imported = 0;
|
int imported = 0;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
|
int updated = 0;
|
||||||
|
// 进度
|
||||||
|
int uploadPercent = 0;
|
||||||
|
bool isProcessing = false;
|
||||||
|
int processingSeconds = 0;
|
||||||
|
|
||||||
_ImportSlot(this.title, this.endpoint, this.hint);
|
_ImportSlot(this.title, this.endpoint, this.hint);
|
||||||
|
|
||||||
bool get hasResult => success != null;
|
bool get hasResult => success != null;
|
||||||
|
|
||||||
|
void resetProgress() {
|
||||||
|
uploadPercent = 0;
|
||||||
|
isProcessing = false;
|
||||||
|
processingSeconds = 0;
|
||||||
|
success = null;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BatchImportWidget extends ConsumerStatefulWidget {
|
class _BatchImportWidget extends ConsumerStatefulWidget {
|
||||||
@@ -1657,14 +1681,15 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
for (final s in _slots) {
|
for (final s in _slots) {
|
||||||
if (s.file != null) {
|
if (s.file != null) s.resetProgress();
|
||||||
s.success = null;
|
|
||||||
s.error = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
|
final dio = Dio(BaseOptions(
|
||||||
|
baseUrl: AppConfig.apiBaseUrl,
|
||||||
|
sendTimeout: const Duration(seconds: 120),
|
||||||
|
receiveTimeout: const Duration(seconds: 300),
|
||||||
|
));
|
||||||
dio.options.headers['Authorization'] = 'Bearer $token';
|
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
|
||||||
for (final slot in _slots) {
|
for (final slot in _slots) {
|
||||||
@@ -1674,29 +1699,57 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer? processingTimer;
|
||||||
|
bool uploadDone = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
|
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
|
||||||
});
|
});
|
||||||
final resp = await dio.post(slot.endpoint, data: formData);
|
final resp = await dio.post(
|
||||||
|
slot.endpoint,
|
||||||
|
data: formData,
|
||||||
|
onSendProgress: (sent, total) {
|
||||||
|
if (total <= 0 || !mounted) return;
|
||||||
|
if (sent >= total && !uploadDone) {
|
||||||
|
uploadDone = true;
|
||||||
|
setState(() { slot.isProcessing = true; slot.processingSeconds = 0; });
|
||||||
|
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => slot.processingSeconds++);
|
||||||
|
});
|
||||||
|
} else if (!uploadDone) {
|
||||||
|
final pct = (sent / total * 100).round().clamp(0, 99);
|
||||||
|
if (mounted) setState(() => slot.uploadPercent = pct);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
processingTimer?.cancel();
|
||||||
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = true;
|
slot.success = true;
|
||||||
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
|
|
||||||
slot.imported = (data['imported'] ?? 0) as int;
|
slot.imported = (data['imported'] ?? 0) as int;
|
||||||
slot.skipped = (data['skipped'] ?? 0) as int;
|
slot.skipped = (data['skipped'] ?? 0) as int;
|
||||||
|
slot.updated = (data['updated'] ?? 0) as int;
|
||||||
|
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int;
|
||||||
|
slot.isProcessing = false;
|
||||||
});
|
});
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
processingTimer?.cancel();
|
||||||
final raw = e.response?.data;
|
final raw = e.response?.data;
|
||||||
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = false;
|
slot.success = false;
|
||||||
slot.error = msg.toString();
|
slot.error = msg.toString();
|
||||||
|
slot.isProcessing = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
processingTimer?.cancel();
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = false;
|
slot.success = false;
|
||||||
slot.error = e.toString();
|
slot.error = e.toString();
|
||||||
|
slot.isProcessing = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2017,10 +2070,11 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
Widget? statusWidget;
|
Widget? statusWidget;
|
||||||
if (slot.hasResult) {
|
if (slot.hasResult) {
|
||||||
if (slot.success == true) {
|
if (slot.success == true) {
|
||||||
|
final duplicate = slot.skipped + slot.updated;
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
'共 ${slot.total} 条',
|
'共 ${slot.total} 条',
|
||||||
'新增 ${slot.imported} 条',
|
'新增 ${slot.imported} 条',
|
||||||
if (slot.skipped > 0) '重复跳过 ${slot.skipped} 条',
|
if (duplicate > 0) '重复 $duplicate 条',
|
||||||
];
|
];
|
||||||
statusWidget = Row(
|
statusWidget = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -2047,10 +2101,42 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (_loading && slot.file != null) {
|
} else if (_loading && slot.file != null) {
|
||||||
statusWidget = const SizedBox(
|
if (slot.isProcessing) {
|
||||||
width: 14, height: 14,
|
statusWidget = Row(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
mainAxisSize: MainAxisSize.min,
|
||||||
);
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 12, height: 12,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'导入数据${slot.processingSeconds > 0 ? "(${slot.processingSeconds}秒)" : ""}',
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
statusWidget = Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: slot.uploadPercent / 100,
|
||||||
|
backgroundColor: Colors.grey[200],
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||||
|
minHeight: 6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'上传 ${slot.uploadPercent}%',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -2104,6 +2190,54 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 门店 Logo 预览 ────────────────────────────────────────
|
||||||
|
class _ShopLogoPreview extends StatelessWidget {
|
||||||
|
final String logoUrl;
|
||||||
|
final String shopName;
|
||||||
|
final double size;
|
||||||
|
const _ShopLogoPreview({required this.logoUrl, required this.shopName, this.size = 64});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final radius = BorderRadius.circular(size * 0.19);
|
||||||
|
if (logoUrl.isNotEmpty) {
|
||||||
|
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: Image.network(
|
||||||
|
fullUrl,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => _initial(radius),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _initial(radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _initial(BorderRadius radius) {
|
||||||
|
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF0F3057),
|
||||||
|
borderRadius: radius,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: size * 0.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 酒行信息行 ────────────────────────────────────────────
|
// ── 酒行信息行 ────────────────────────────────────────────
|
||||||
class _ShopInfoRow extends StatelessWidget {
|
class _ShopInfoRow extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
@@ -2149,6 +2283,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
|||||||
late final TextEditingController _phoneCtrl;
|
late final TextEditingController _phoneCtrl;
|
||||||
late final TextEditingController _managerCtrl;
|
late final TextEditingController _managerCtrl;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
bool _uploadingLogo = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -2168,6 +2303,37 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadLogo() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['jpg', 'jpeg', 'png'],
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.bytes == null) return;
|
||||||
|
setState(() => _uploadingLogo = true);
|
||||||
|
try {
|
||||||
|
await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name);
|
||||||
|
ref.invalidate(shopInfoProvider);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('Logo 已更新'),
|
||||||
|
backgroundColor: AppTheme.success,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('上传失败:$e'),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _uploadingLogo = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
@@ -2206,6 +2372,25 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// Logo 上传行
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ShopLogoPreview(
|
||||||
|
logoUrl: ref.watch(shopInfoProvider).valueOrNull?.logoUrl ?? widget.shop.logoUrl,
|
||||||
|
shopName: _nameCtrl.text,
|
||||||
|
size: 56,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _uploadingLogo ? null : _uploadLogo,
|
||||||
|
icon: _uploadingLogo
|
||||||
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Icons.upload_outlined, size: 16),
|
||||||
|
label: const Text('更换 Logo'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nameCtrl,
|
controller: _nameCtrl,
|
||||||
decoration: const InputDecoration(labelText: '门店名称'),
|
decoration: const InputDecoration(labelText: '门店名称'),
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../../core/auth/auth_state.dart';
|
import '../../core/auth/auth_state.dart';
|
||||||
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../providers/connectivity_provider.dart';
|
import '../../providers/connectivity_provider.dart';
|
||||||
|
import '../../providers/shop_provider.dart';
|
||||||
import '../../providers/update_provider.dart';
|
import '../../providers/update_provider.dart';
|
||||||
|
|
||||||
class AppShell extends ConsumerStatefulWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
@@ -575,27 +577,31 @@ void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ShopButton extends StatelessWidget {
|
class _ShopButton extends ConsumerWidget {
|
||||||
final AuthUser? user;
|
final AuthUser? user;
|
||||||
final String version;
|
final String version;
|
||||||
const _ShopButton({this.user, this.version = 'v1.0.0'});
|
const _ShopButton({this.user, this.version = 'v1.0.0'});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final shopAsync = ref.watch(shopInfoProvider);
|
||||||
|
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
|
||||||
|
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (user != null) _showShopPanel(context, user!, version: version);
|
if (user != null) _showShopPanel(context, user!, version: version);
|
||||||
},
|
},
|
||||||
child: const Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_YanmeiMark(size: 28),
|
_ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28),
|
||||||
SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
'岩美',
|
shopName.isEmpty ? '—' : shopName,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -608,70 +614,54 @@ class _ShopButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Brand mark widget — approximates the 岩美 logo SVG without flutter_svg.
|
/// 门店 Logo:有图片显示网络图片,无图片显示店名首字文字头像。
|
||||||
/// Dark blue rounded rect, white mountain/wave strokes, bordeaux dot.
|
class _ShopLogo extends StatelessWidget {
|
||||||
class _YanmeiMark extends StatelessWidget {
|
final String logoUrl;
|
||||||
|
final String shopName;
|
||||||
final double size;
|
final double size;
|
||||||
const _YanmeiMark({this.size = 32});
|
const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final radius = BorderRadius.circular(size * 0.19);
|
||||||
|
if (logoUrl.isNotEmpty) {
|
||||||
|
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: Image.network(
|
||||||
|
fullUrl,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => _initial(radius),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _initial(radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _initial(BorderRadius radius) {
|
||||||
|
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||||||
return Container(
|
return Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF0F3057),
|
color: const Color(0xFF0F3057),
|
||||||
borderRadius: BorderRadius.circular(size * 0.19),
|
borderRadius: radius,
|
||||||
),
|
),
|
||||||
child: CustomPaint(
|
alignment: Alignment.center,
|
||||||
painter: _YanmeiMarkPainter(),
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: size * 0.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _YanmeiMarkPainter extends CustomPainter {
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final w = size.width;
|
|
||||||
final h = size.height;
|
|
||||||
final paint = Paint()
|
|
||||||
..color = Colors.white
|
|
||||||
..strokeWidth = w * 0.055
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeCap = StrokeCap.round
|
|
||||||
..strokeJoin = StrokeJoin.round;
|
|
||||||
|
|
||||||
// Mountain/wave path: M14 38 L22 22 L32 32 L42 22 L50 38 (on 64px grid)
|
|
||||||
final path = Path();
|
|
||||||
path.moveTo(w * 0.219, h * 0.594);
|
|
||||||
path.lineTo(w * 0.344, h * 0.344);
|
|
||||||
path.lineTo(w * 0.500, h * 0.500);
|
|
||||||
path.lineTo(w * 0.656, h * 0.344);
|
|
||||||
path.lineTo(w * 0.781, h * 0.594);
|
|
||||||
canvas.drawPath(path, paint);
|
|
||||||
|
|
||||||
// Horizontal baseline: M12 46 L52 46 (on 64px grid)
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(w * 0.1875, h * 0.719),
|
|
||||||
Offset(w * 0.8125, h * 0.719),
|
|
||||||
paint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bordeaux dot: circle cx=32 cy=52 r=2.4 (on 64px grid)
|
|
||||||
canvas.drawCircle(
|
|
||||||
Offset(w * 0.500, h * 0.859),
|
|
||||||
w * 0.042,
|
|
||||||
Paint()
|
|
||||||
..color = const Color(0xFFC97B86)
|
|
||||||
..style = PaintingStyle.fill,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoRow extends StatelessWidget {
|
class _InfoRow extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
@@ -247,8 +247,16 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
try {
|
||||||
await printStockInOrder(order);
|
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||||
|
await printStockInOrder(order);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: const Text('打印',
|
child: const Text('打印',
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||||
@@ -690,7 +698,17 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
||||||
tooltip: '打印',
|
tooltip: '打印',
|
||||||
onPressed: () => printStockInOrder(_loadedOrder!),
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await printStockInOrder(_loadedOrder!);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
|||||||
@@ -254,8 +254,16 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
try {
|
||||||
await printStockOutOrder(order);
|
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||||
|
await printStockOutOrder(order);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: const Text('打印',
|
child: const Text('打印',
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||||
@@ -682,7 +690,17 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
||||||
tooltip: '打印',
|
tooltip: '打印',
|
||||||
onPressed: () => printStockOutOrder(_loadedOrder!),
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await printStockOutOrder(_loadedOrder!);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="岩美酒库管理系统 — 酒店饮品库存与采购管理平台">
|
<meta name="description" content="岩美酒库管理系统 — 酒店饮品库存与采购管理平台">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ server {
|
|||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
# 商品图片静态文件
|
# 商品图片静态文件
|
||||||
location ^~ /images/ {
|
location ^~ /images/ {
|
||||||
alias /opt/jiu/images/;
|
alias /opt/jiu/images/;
|
||||||
@@ -14,6 +16,14 @@ server {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 文件导入接口(超时更长)
|
||||||
|
location ~ ^/api/v1/import/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
# API 反向代理
|
# API 反向代理
|
||||||
location ~ ^/(api|health|version) {
|
location ~ ^/(api|health|version) {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
@@ -29,6 +39,20 @@ server {
|
|||||||
try_files $uri $uri/ /app/index.html;
|
try_files $uri $uri/ /app/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# index.html 不缓存,确保每次发版后浏览器加载最新版
|
||||||
|
location = /app/index.html {
|
||||||
|
alias /opt/jiu/web/index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理
|
||||||
|
location ~ ^/product/ {
|
||||||
|
root /opt/jiu/web;
|
||||||
|
try_files $uri /app/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
# 扫码页(/scan/<id> → scan.html,JS 从 URL 读取 id)
|
# 扫码页(/scan/<id> → scan.html,JS 从 URL 读取 id)
|
||||||
location /scan/ {
|
location /scan/ {
|
||||||
root /opt/jiu/marketing;
|
root /opt/jiu/marketing;
|
||||||
|
|||||||
+46
-118
@@ -10,9 +10,9 @@
|
|||||||
|
|
||||||
| 服务 | 地址 |
|
| 服务 | 地址 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Gitea Web UI | `http://192.168.3.200:3000` |
|
| Gitea Web UI | `https://git.51yanmei.com` |
|
||||||
| Gitea SSH | `ssh://192.168.3.200:2222` |
|
| Gitea SSH | `ssh://git@git.51yanmei.com:2222` |
|
||||||
| Actions 面板 | `http://192.168.3.200:3000/wangjia/jiu/actions` |
|
| Actions 面板 | `https://git.51yanmei.com/wangjia/jiu/actions` |
|
||||||
| 线上应用 | `https://jiu.51yanmei.com` |
|
| 线上应用 | `https://jiu.51yanmei.com` |
|
||||||
| 管理端 Web | `https://jiu.51yanmei.com/app/` |
|
| 管理端 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` | act_runner 二进制(v0.2.11) |
|
||||||
| `/Users/wangjia/bin/act_runner_start.sh` | 启动脚本(含 TCP 中继逻辑) |
|
| `/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/bin/tcp_relay.py` | Python3 TCP 中继(旧内网方案,现已通过域名直连) |
|
||||||
| `/Users/wangjia/.act_runner` | Runner 注册信息(地址指向 `127.0.0.1:13000`) |
|
| `/Users/wangjia/.act_runner` | Runner 注册信息 |
|
||||||
| `/Users/wangjia/.act_runner_config.yaml` | Runner 配置(label: `mac:host`,capacity: 1) |
|
| `/Users/wangjia/.act_runner_config.yaml` | Runner 配置(label: `mac:host`,capacity: 1) |
|
||||||
| `~/Library/LaunchAgents/com.jiu.act-runner.plist` | LaunchAgent plist,开机自启 |
|
| `~/Library/LaunchAgents/com.jiu.act-runner.plist` | LaunchAgent plist,开机自启 |
|
||||||
| `/tmp/act_runner.log` | 运行日志 |
|
| `/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`。
|
`.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 状态
|
### 检查 Runner 状态
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -51,7 +45,7 @@ Shadowrocket(代理工具)的 Network Extension 会拦截 LaunchAgent 启动
|
|||||||
tail -f /tmp/act_runner.log
|
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 会自动重启,一般不需要)
|
# 手动重启(LaunchAgent 会自动重启,一般不需要)
|
||||||
kill $(pgrep -f act_runner_start.sh)
|
kill $(pgrep -f act_runner_start.sh)
|
||||||
@@ -59,12 +53,10 @@ kill $(pgrep -f act_runner_start.sh)
|
|||||||
|
|
||||||
### Runner 不工作时的排查步骤
|
### Runner 不工作时的排查步骤
|
||||||
|
|
||||||
1. **确认 NAS 可达**:`curl -sf http://192.168.3.200:3000/api/v1/version`
|
1. **确认 Gitea 可达**:`curl -sf https://git.51yanmei.com/api/v1/version`
|
||||||
2. **确认 relay 在跑**:`ps aux | grep tcp_relay`
|
2. **查看最近日志**:`tail -30 /tmp/act_runner.log`
|
||||||
3. **确认 relay 端口监听**:`nc -zv 127.0.0.1 13000`
|
3. **手动测试**:`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功)
|
||||||
4. **查看最近日志**:`tail -30 /tmp/act_runner.log`
|
4. **强制重启**:`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉
|
||||||
5. **手动测试**:`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功)
|
|
||||||
6. **强制重启**:`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉
|
|
||||||
|
|
||||||
### 重新注册 Runner(token 失效时)
|
### 重新注册 Runner(token 失效时)
|
||||||
|
|
||||||
@@ -81,15 +73,13 @@ rm ~/.act_runner
|
|||||||
# 4. 注册(从终端运行,不要从 LaunchAgent)
|
# 4. 注册(从终端运行,不要从 LaunchAgent)
|
||||||
/Users/wangjia/bin/act_runner register \
|
/Users/wangjia/bin/act_runner register \
|
||||||
--no-interactive \
|
--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> \
|
--token <新token> \
|
||||||
--name mac-runner \
|
--name mac-runner \
|
||||||
--labels mac:host
|
--labels mac:host
|
||||||
|
|
||||||
# 5. 把注册文件里的地址改回 127.0.0.1:13000
|
# 5. 重启(LaunchAgent 自动接管)
|
||||||
sed -i '' 's|http://192.168.3.200:3000|http://127.0.0.1:13000|' ~/.act_runner
|
|
||||||
|
|
||||||
# 6. 重启(LaunchAgent 自动接管)
|
|
||||||
kill $(pgrep -f act_runner_start.sh)
|
kill $(pgrep -f act_runner_start.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -101,19 +91,19 @@ kill $(pgrep -f act_runner_start.sh)
|
|||||||
|
|
||||||
### `deploy.yml` — 主部署流程
|
### `deploy.yml` — 主部署流程
|
||||||
|
|
||||||
**触发条件:** push 到 `main` 分支
|
**触发条件:** push `v*.*.*` tag
|
||||||
|
|
||||||
**步骤:**
|
**步骤:**
|
||||||
1. `go test ./...` — 跑后端测试
|
1. `compile.sh` — 编译 Go 后端(linux/amd64)+ Flutter Web,打包到 `dist/`
|
||||||
2. `flutter test` — 跑前端测试
|
2. `test.sh` — `go test` + `flutter analyze`
|
||||||
3. 编译后端:`GOOS=linux GOARCH=amd64 go build` 生成 `jiu-server`
|
3. `release.sh` — 解析 CHANGELOG.md,更新 version.yaml,创建 Forgejo Release,上传 3 个 asset
|
||||||
4. 编译 Flutter Web:`flutter build web --release --base-href=/app/`
|
4. `deploy.sh` — SSH 到 EC2 部署,完成后发 Telegram 通知
|
||||||
5. 创建 Forgejo Release(版本号格式 `vYYYYMMDD.HHMM`),上传 `jiu-server` 和 `web.tar.gz`
|
|
||||||
6. SSH 到 EC2 部署:
|
### `manual.yml` — 手动部署 / 回滚
|
||||||
- 停止 jiu 服务 → 替换二进制 → 启动 → 等待健康检查通过
|
|
||||||
- 替换 Flutter Web 静态文件到 `/opt/jiu/web/`
|
**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入目标 tag(如 `v1.0.0`)
|
||||||
- 同步营销站点到 `/opt/jiu/marketing/`
|
|
||||||
- `nginx -s reload`
|
**步骤:** 只运行 `deploy.sh`,从 Forgejo Release 下载已有产物直接部署,跳过编译和测试。
|
||||||
|
|
||||||
### `ci.yml` — PR 测试流程
|
### `ci.yml` — PR 测试流程
|
||||||
|
|
||||||
@@ -121,12 +111,6 @@ kill $(pgrep -f act_runner_start.sh)
|
|||||||
|
|
||||||
**步骤:** 只跑测试(`go test` + `flutter test`),不构建不部署。
|
**步骤:** 只跑测试(`go test` + `flutter test`),不构建不部署。
|
||||||
|
|
||||||
### `backup.yml` — 每日备份
|
|
||||||
|
|
||||||
**触发条件:** 每天凌晨 2:00(cron)
|
|
||||||
|
|
||||||
**步骤:** SSH 到 EC2,导出 MySQL 数据库备份,上传到指定目录。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Secrets 配置
|
## Secrets 配置
|
||||||
@@ -138,26 +122,23 @@ kill $(pgrep -f act_runner_start.sh)
|
|||||||
| `EC2_SSH_KEY` | EC2 服务器的 SSH 私钥(PEM 格式,完整内容) |
|
| `EC2_SSH_KEY` | EC2 服务器的 SSH 私钥(PEM 格式,完整内容) |
|
||||||
| `EC2_HOST` | EC2 服务器 IP 或域名 |
|
| `EC2_HOST` | EC2 服务器 IP 或域名 |
|
||||||
| `EC2_USER` | EC2 SSH 用户名(如 `ec2-user`、`ubuntu`) |
|
| `EC2_USER` | EC2 SSH 用户名(如 `ec2-user`、`ubuntu`) |
|
||||||
| `DB_PASSWORD` | MySQL root 密码(备份 workflow 使用) |
|
| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release、下载 asset) |
|
||||||
| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release) |
|
| `FORGEJO_URL` | Gitea 地址:`https://git.51yanmei.com` |
|
||||||
| `FORGEJO_URL` | Gitea 内网地址,如 `http://192.168.3.200:3000` |
|
| `TELEGRAM_TOKEN` | Telegram Bot Token(部署通知) |
|
||||||
|
| `TELEGRAM_CHAT_ID` | Telegram Chat ID(接收通知的对话) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 触发部署方式
|
## 触发部署方式
|
||||||
|
|
||||||
日常开发流程:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 开发 → 本地测试
|
# 开发完成,确认测试通过后打 tag
|
||||||
go test ./...
|
git tag -a v1.1.0 -m "v1.1.0"
|
||||||
flutter test
|
git push nas main && git push nas v1.1.0
|
||||||
|
# → CI 自动:compile → test → release → deploy → Telegram 通知
|
||||||
# 用户确认后推送到 NAS Gitea(触发自动部署)
|
|
||||||
git push nas main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
推送到 `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/
|
/opt/jiu/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ └── jiu-server # Go 可执行文件
|
│ ├── jiu-server # Go 可执行文件
|
||||||
|
│ └── config/
|
||||||
|
│ └── version.yaml # 版本信息(由 CI 自动更新)
|
||||||
├── web/ # Flutter Web 构建产物(/app/ 路径)
|
├── web/ # Flutter Web 构建产物(/app/ 路径)
|
||||||
├── marketing/ # 营销站点静态 HTML
|
├── marketing/ # 营销站点静态 HTML
|
||||||
│ ├── assets/ # CSS / SVG
|
│ ├── assets/
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ ├── docs.html
|
│ ├── docs.html
|
||||||
│ ├── download.html
|
│ ├── download.html
|
||||||
│ ├── scan.html
|
│ └── scan.html
|
||||||
│ └── features/
|
|
||||||
│ ├── inventory.html
|
|
||||||
│ └── approval.html
|
|
||||||
├── images/ # 商品图片上传目录
|
├── images/ # 商品图片上传目录
|
||||||
└── web-old/ # 上一版本 Flutter Web(回滚备用)
|
└── web-old/ # 上一版本 Flutter Web(回滚备用)
|
||||||
```
|
```
|
||||||
@@ -188,64 +168,12 @@ Systemd 服务名:`jiu`
|
|||||||
|
|
||||||
## Nginx 路由说明
|
## Nginx 路由说明
|
||||||
|
|
||||||
配置文件:`deploy/nginx-jiu.conf`(部署时手动 scp 至服务器)
|
配置文件:`deploy/nginx-jiu.conf`(CI 部署时自动更新)
|
||||||
|
|
||||||
| 路径 | 服务 | 目录 |
|
| 路径 | 服务 |
|
||||||
|------|------|------|
|
|------|------|
|
||||||
| `/api/*`, `/health`, `/version` | Go 后端 API | proxy → `localhost:8080` |
|
| `/api/*`, `/health`, `/version` | Go 后端 API |
|
||||||
| `/images/*` | 商品图片 | `/opt/jiu/images/` |
|
| `/images/*` | 商品图片静态文件 |
|
||||||
| `/app/` | Flutter 管理端 SPA | `/opt/jiu/web/` |
|
| `/app/` | Flutter 管理端 SPA |
|
||||||
| `/` | 营销首页 | `/opt/jiu/marketing/index.html` |
|
| `/` | 营销首页 |
|
||||||
| `/docs` | 文档页 | `/opt/jiu/marketing/docs.html` |
|
| `/scan/<id>` | 商品扫码页 |
|
||||||
| `/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)。
|
|
||||||
|
|||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/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 ..
|
||||||
|
|
||||||
|
# Package artifacts
|
||||||
|
mkdir -p dist
|
||||||
|
mv backend/jiu-server dist/jiu-server
|
||||||
|
tar -czf dist/web.tar.gz -C client/build web
|
||||||
|
|
||||||
|
echo "==> compile: done — dist/ contents:"
|
||||||
|
ls -lh dist/
|
||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/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 ]; 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
|
||||||
|
|
||||||
|
# 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 (from repo checkout)
|
||||||
|
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
|
||||||
|
web/ "${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 config
|
||||||
|
mkdir -p /opt/jiu/backend/config
|
||||||
|
cp /tmp/version.yaml /opt/jiu/backend/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"
|
||||||
Executable
+24
@@ -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})"
|
||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/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
|
||||||
|
python3 - "${VER}" "${RELEASE_NOTES}" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
ver = sys.argv[1]
|
||||||
|
notes = sys.argv[2]
|
||||||
|
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')
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
with open('backend/config/version.yaml', 'w') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
print('version.yaml updated to', ver)
|
||||||
|
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/configs.tar.gz
|
||||||
|
|
||||||
|
echo "==> release: done — Release ${TAG} created"
|
||||||
Executable
+20
@@ -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
@@ -6,7 +6,7 @@ cd "$(dirname "$0")/.."
|
|||||||
EC2_HOST="${EC2_HOST:-$(grep EC2_HOST ~/.env 2>/dev/null | cut -d= -f2)}"
|
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_USER="${EC2_USER:-$(grep EC2_USER ~/.env 2>/dev/null | cut -d= -f2)}"
|
||||||
EC2_KEY="${EC2_KEY:-~/.ssh/wangjia.pem}"
|
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)}"
|
FORGEJO_TOKEN="${FORGEJO_TOKEN:-$(grep FORGEJO_TOKEN ~/.env 2>/dev/null | cut -d= -f2)}"
|
||||||
|
|
||||||
echo "==> 1/5 Go 测试"
|
echo "==> 1/5 Go 测试"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
/* ---------- Brand: Primary (Slate Blue) ---------- */
|
/* ---------- Brand: Primary (Slate Blue) ---------- */
|
||||||
/* Trustworthy enterprise blue with a slight slate cast. */
|
/* Trustworthy enterprise blue with a slight slate cast. */
|
||||||
--brand-50: #EEF4FB;
|
--brand-50: #EEF4FB;
|
||||||
|
|||||||
+19
-4
@@ -954,15 +954,15 @@ footer {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
<!-- v1.6.2 -->
|
<!-- 当前版本(由 JS 动态更新) -->
|
||||||
<div class="changelog-entry current">
|
<div class="changelog-entry current">
|
||||||
<div class="changelog-meta">
|
<div class="changelog-meta">
|
||||||
<div class="changelog-version">v1.6.2</div>
|
<div class="changelog-version" id="version-badge">...</div>
|
||||||
<div class="changelog-date">2026-05-15</div>
|
<div class="changelog-date" id="release-date">-</div>
|
||||||
<span class="changelog-tag">当前版本</span>
|
<span class="changelog-tag">当前版本</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="changelog-body">
|
<div class="changelog-body">
|
||||||
<h3>多仓库联动盘点与权限增强</h3>
|
<h3 id="release-notes">加载中...</h3>
|
||||||
<div class="changelog-cat">
|
<div class="changelog-cat">
|
||||||
<div class="changelog-cat-label"><span class="dot feature"></span>新功能</div>
|
<div class="changelog-cat-label"><span class="dot feature"></span>新功能</div>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -1193,8 +1193,23 @@ function renderNav() {
|
|||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadReleaseInfo() {
|
||||||
|
fetch('https://jiu.51yanmei.com/api/v1/public/release')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var badge = document.getElementById('version-badge');
|
||||||
|
var notes = document.getElementById('release-notes');
|
||||||
|
var date = document.getElementById('release-date');
|
||||||
|
if (badge && data.version) badge.textContent = 'v' + data.version;
|
||||||
|
if (notes && data.release_notes) notes.textContent = data.release_notes;
|
||||||
|
if (date) date.textContent = new Date().toISOString().slice(0, 10);
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
renderNav();
|
renderNav();
|
||||||
|
loadReleaseInfo();
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
<title>岩美酒库管理系统 — 为酒行与酒店设计的库存管理平台</title>
|
<title>岩美酒库管理系统 — 为酒行与酒店设计的库存管理平台</title>
|
||||||
<link rel="stylesheet" href="assets/colors_and_type.css" />
|
<link rel="stylesheet" href="assets/colors_and_type.css" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
|||||||
Reference in New Issue
Block a user