From 655c1d366ef08c7fb1561b3fac29d32e27c85cf9 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 30 Apr 2026 00:31:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E5=BC=80=E5=95=86=E5=93=81?= =?UTF-8?q?=E9=A1=B5=E9=87=8D=E8=AE=BE=E8=AE=A1=20+=20web=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=BF=AE=E5=A4=8D=20+=20=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公开商品页(/product/:uuid)全面重设计:全宽正方形图片轮播、 左右滑动切图、点击放大全屏查看、商品参数含描述、页脚贴底 - 修复 Flutter web 文件上传无反应(path→bytes) - 修复 web 路由空白页(usePathUrlStrategy + 单层 MaterialApp.router) - 二维码 URL 改为从 STORAGE_PUBLIC_URL 环境变量读取 - 新增 PUBLIC_URL dart-define → AppConfig.publicBaseUrl - 新增 CI/CD workflows + NAS runner compose 配置 - seed S001 补充商品描述字段 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/backup.yml | 36 ++ .github/workflows/ci.yml | 29 + .github/workflows/deploy.yml | 70 +++ backend/cmd/seed/main.go | 67 ++- backend/config/config.go | 3 + backend/internal/handler/product.go | 8 +- backend/seeds/S001.sql | 18 +- client/lib/core/config/app_config.dart | 6 + client/lib/main.dart | 54 +- .../lib/repositories/product_repository.dart | 13 +- .../products/product_detail_screen.dart | 13 +- .../screens/public/public_product_screen.dart | 494 ++++++++++++++---- deploy/production.env.template | 1 + deploy/runner/act-runner-config.yaml | 21 + deploy/runner/docker-compose.gitea.yml | 50 ++ deploy/runner/docker-compose.runner.yml | 2 + scripts/dev.sh | 10 + 17 files changed, 738 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/backup.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 deploy/runner/act-runner-config.yaml create mode 100644 deploy/runner/docker-compose.gitea.yml create mode 100644 deploy/runner/docker-compose.runner.yml diff --git a/.github/workflows/backup.yml b/.github/workflows/backup.yml new file mode 100644 index 0000000..8fa10f7 --- /dev/null +++ b/.github/workflows/backup.yml @@ -0,0 +1,36 @@ +name: DB Backup + +on: + schedule: + - cron: '0 18 * * *' # UTC 18:00 = 北京时间 02:00 + workflow_dispatch: + +jobs: + backup: + runs-on: ubuntu-latest + steps: + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/ec2.pem + chmod 600 ~/.ssh/ec2.pem + ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts + + - name: Dump MySQL to NAS + env: + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_USER: ${{ secrets.EC2_USER }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + run: | + BACKUP_DIR=/volume1/backups/jiu-db + mkdir -p $BACKUP_DIR + FILENAME="jiu_db_$(date +%Y%m%d_%H%M%S).sql.gz" + ssh -i ~/.ssh/ec2.pem ${EC2_USER}@${EC2_HOST} \ + "docker exec jiu_mysql mysqldump -uroot -p${DB_PASSWORD} jiu_db" \ + | gzip > ${BACKUP_DIR}/${FILENAME} + find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete + echo "Saved: ${BACKUP_DIR}/${FILENAME}" + + - name: Cleanup SSH key + if: always() + run: rm -f ~/.ssh/ec2.pem diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c8acc1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches-ignore: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Go tests + working-directory: backend + run: go test ./... + + - uses: subosito/flutter-action@v2 + with: + cache: true + + - name: Flutter tests + working-directory: client + run: flutter test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..5cbff82 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,70 @@ +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Go tests + working-directory: backend + run: go test ./... + + - uses: subosito/flutter-action@v2 + with: + cache: true + + - name: Flutter tests + working-directory: client + run: flutter test + + - name: Build backend (linux/amd64) + 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 --dart-define=BASE_URL=https://jiu.51yanmei.com --dart-define=PUBLIC_URL=https://jiu.51yanmei.com + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/ec2.pem + chmod 600 ~/.ssh/ec2.pem + ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy to EC2 + env: + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_USER: ${{ secrets.EC2_USER }} + run: | + scp -i ~/.ssh/ec2.pem backend/jiu-server ${EC2_USER}@${EC2_HOST}:/tmp/jiu-server + rsync -avz --delete -e "ssh -i ~/.ssh/ec2.pem" \ + client/build/web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/ + ssh -i ~/.ssh/ec2.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 + sudo nginx -s reload + ENDSSH + + - name: Cleanup SSH key + if: always() + run: rm -f ~/.ssh/ec2.pem diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index ce48e1e..1f6c5cd 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -28,6 +28,9 @@ var allModels = []any{ &model.License{}, &model.ProductCategory{}, &model.Product{}, + &model.ProductNameOption{}, + &model.ProductSeriesOption{}, + &model.ProductSpecOption{}, &model.Warehouse{}, &model.Partner{}, &model.StockInOrder{}, @@ -50,7 +53,7 @@ var truncateOrder = []string{ "stock_in_items", "stock_in_orders", "finance_records", "number_rules", "partners", "warehouses", - "products", "product_categories", + "products", "product_spec_options", "product_series_options", "product_name_options", "product_categories", "licenses", "users", "shops", } @@ -186,14 +189,14 @@ func main() { remark string } prodSeeds := []prodSeed{ - {"MT-001", "6901234567890", "飞天茅台 53度 500ml", "茅台", "500ml/瓶", "瓶", "贵州茅台", &catBaijiu.ID, 2350, 2800, 10, "酱香型白酒,53度,飞天系列"}, - {"WLY-001", "6902345678901", "五粮液 52度 500ml", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", &catBaijiu.ID, 950, 1200, 6, "浓香型白酒,52度,普五系列"}, - {"YH-001", "6903456789012", "洋河梦之蓝 M6 500ml", "洋河", "500ml/瓶", "瓶", "江苏洋河", &catBaijiu.ID, 560, 680, 6, "浓香型,绵柔苏酒代表"}, - {"LZ-001", "6904567890123", "泸州老窖 特曲 500ml", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", &catBaijiu.ID, 420, 520, 6, "浓香鼻祖,特曲系列"}, - {"JNC-001", "6905678901234", "剑南春 水晶剑 500ml", "剑南春", "500ml/瓶", "瓶", "剑南春", &catBaijiu.ID, 390, 480, 6, "浓香型,绵竹名酒"}, - {"LJ-001", "6906789012345", "郎酒 红花郎10 500ml", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", &catBaijiu.ID, 320, 420, 6, "酱香型,赤水河畔酿造"}, - {"LF-001", "3760093550058", "拉菲古堡 2018 750ml", "波尔多", "750ml/瓶", "瓶", "Château Lafite", &catImport.ID, 3800, 5200, 3, "波尔多一级名庄,2018年份"}, - {"RTM-001", "3021691010008", "人头马 VSOP 700ml", "人头马", "700ml/瓶", "瓶", "Rémy Martin", &catImport.ID, 480, 680, 3, "法国干邑,VSOP级别"}, + {"MT-001", "6901234567890", "飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", &catBaijiu.ID, 2350, 2800, 10, "酱香型白酒,53度,飞天系列"}, + {"WLY-001", "6902345678901", "五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", &catBaijiu.ID, 950, 1200, 6, "浓香型白酒,52度,普五系列"}, + {"YH-001", "6903456789012", "洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", &catBaijiu.ID, 560, 680, 6, "浓香型,绵柔苏酒代表"}, + {"LZ-001", "6904567890123", "泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", &catBaijiu.ID, 420, 520, 6, "浓香鼻祖,特曲系列"}, + {"JNC-001", "6905678901234", "剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", &catBaijiu.ID, 390, 480, 6, "浓香型,绵竹名酒"}, + {"LJ-001", "6906789012345", "郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", &catBaijiu.ID, 320, 420, 6, "酱香型,赤水河畔酿造"}, + {"LF-001", "3760093550058", "拉菲古堡 2018", "波尔多", "750ml/瓶", "瓶", "Château Lafite", &catImport.ID, 3800, 5200, 3, "波尔多一级名庄,2018年份"}, + {"RTM-001", "3021691010008", "人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", &catImport.ID, 480, 680, 3, "法国干邑,VSOP级别"}, } var prods []model.Product for _, s := range prodSeeds { @@ -209,6 +212,52 @@ func main() { prods = append(prods, p) } + // ═══════════════════════════════════════════════════ + // 商品名称/系列/规格选项 + // ═══════════════════════════════════════════════════ + type dimSeed struct{ code, name string } + + for _, s := range []dimSeed{ + {"NA001", "飞天茅台 53度"}, {"NA002", "五粮液 52度"}, {"NA003", "洋河梦之蓝 M6"}, + {"NA004", "泸州老窖 特曲"}, {"NA005", "剑南春 水晶剑"}, {"NA006", "郎酒 红花郎10"}, + {"NA007", "拉菲古堡 2018"}, {"NA008", "人头马 VSOP"}, + } { + var opt model.ProductNameOption + if db.Where("shop_id = ? AND name = ?", shop.ID, s.name).First(&opt).Error != nil { + opt = model.ProductNameOption{TenantBase: model.TenantBase{ShopID: shop.ID}, Code: s.code, Name: s.name} + db.Create(&opt) + fmt.Printf("✅ 名称选项:%s %s\n", s.code, s.name) + } + } + + for _, s := range []dimSeed{ + {"SE001", "茅台"}, {"SE002", "五粮液"}, {"SE003", "洋河"}, {"SE004", "泸州老窖"}, + {"SE005", "剑南春"}, {"SE006", "郎酒"}, {"SE007", "波尔多"}, {"SE008", "人头马"}, + } { + var opt model.ProductSeriesOption + if db.Where("shop_id = ? AND name = ?", shop.ID, s.name).First(&opt).Error != nil { + opt = model.ProductSeriesOption{TenantBase: model.TenantBase{ShopID: shop.ID}, Code: s.code, Name: s.name} + db.Create(&opt) + fmt.Printf("✅ 系列选项:%s %s\n", s.code, s.name) + } + } + + for _, s := range []struct{ code, name string; quantity int }{ + {"GG001", "500ml/瓶", 1}, {"GG002", "750ml/瓶", 1}, {"GG003", "700ml/瓶", 1}, + } { + var opt model.ProductSpecOption + if db.Where("shop_id = ? AND name = ?", shop.ID, s.name).First(&opt).Error != nil { + opt = model.ProductSpecOption{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Code: s.code, + Name: s.name, + Quantity: s.quantity, + } + db.Create(&opt) + fmt.Printf("✅ 规格选项:%s\n", s.name) + } + } + // ═══════════════════════════════════════════════════ // 往来单位 // ═══════════════════════════════════════════════════ diff --git a/backend/config/config.go b/backend/config/config.go index 207efe7..7762747 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -38,6 +38,7 @@ type LicenseConfig struct { type StorageConfig struct { UploadDir string `mapstructure:"upload_dir"` BaseURL string `mapstructure:"base_url"` + PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码 } var C Config @@ -58,6 +59,7 @@ func Load() { _ = viper.BindEnv("license.hmac_secret", "LICENSE_HMAC_SECRET") _ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR") _ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL") + _ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL") // 默认值 viper.SetDefault("server.port", "8080") @@ -67,6 +69,7 @@ func Load() { viper.SetDefault("jwt.refresh_expire_h", 168) // 7天 viper.SetDefault("storage.upload_dir", "./uploads/images") viper.SetDefault("storage.base_url", "http://localhost:8080/images") + viper.SetDefault("storage.public_url", "http://localhost:8081") if err := viper.ReadInConfig(); err != nil { log.Println("[config] no config file found, using defaults and env vars") diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index 73a0010..f131505 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -12,6 +12,7 @@ import ( qrcode "github.com/skip2/go-qrcode" "gorm.io/gorm" + "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" ) @@ -181,7 +182,12 @@ func (h *ProductHandler) QRCode(c *gin.Context) { return } - url := "https://jiu.51yanmei.com/product/" + product.PublicID + if product.PublicID == "" { + product.PublicID = uuid.New().String() + h.db.Model(&product).Update("public_id", product.PublicID) + } + + url := config.C.Storage.PublicURL + "/product/" + product.PublicID png, err := qrcode.Encode(url, qrcode.Medium, 256) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/seeds/S001.sql b/backend/seeds/S001.sql index c654052..98ed345 100644 --- a/backend/seeds/S001.sql +++ b/backend/seeds/S001.sql @@ -62,15 +62,15 @@ INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updat -- ── 商品 ──────────────────────────────────────────────────── -- id=1~6 白酒, id=7~8 进口烈酒 INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand, - purchase_price, sale_price, min_stock, remark, created_at, updated_at) VALUES -(1, 1, 'a1b2c3d4-0001-0001-0001-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()), -(2, 1, 'a1b2c3d4-0001-0001-0001-000000000002', 'WLY-001', '6902345678901', '五粮液 52度', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()), -(3, 1, 'a1b2c3d4-0001-0001-0001-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()), -(4, 1, 'a1b2c3d4-0001-0001-0001-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()), -(5, 1, 'a1b2c3d4-0001-0001-0001-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()), -(6, 1, 'a1b2c3d4-0001-0001-0001-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()), -(7, 1, 'a1b2c3d4-0001-0001-0001-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()), -(8, 1, 'a1b2c3d4-0001-0001-0001-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW()); + purchase_price, sale_price, min_stock, remark, description, created_at, updated_at) VALUES +(1, 1, 'a1b2c3d4-0001-0001-0001-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', '飞天茅台采用优质高粱、小麦,经传统酱香工艺精心酿制。酒体醇厚丰满,酱香突出,幽雅细腻,空杯留香持久。53度黄金度数,是馈赠佳品的首选。', NOW(), NOW()), +(2, 1, 'a1b2c3d4-0001-0001-0001-000000000002', 'WLY-001', '6902345678901', '五粮液 52度', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', '五粮液以高粱、大米、糯米、小麦、玉米五种粮食为原料,经地窖发酵精酿而成。香气悠久,味醇厚,入口甘美,入喉净爽,浓香典范。', NOW(), NOW()), +(3, 1, 'a1b2c3d4-0001-0001-0001-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', '梦之蓝M6以"绵、柔、甜、净、香"五大特点著称,是洋河旗舰产品。选用苏北优质小麦制曲,宿迁软水酿造,入口柔顺舒适,回味悠长。', NOW(), NOW()), +(4, 1, 'a1b2c3d4-0001-0001-0001-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', '泸州老窖特曲拥有400余年历史的国宝窖池酿制,被誉为"浓香鼻祖"。酒体醇厚净爽,窖香优雅,尾净余长,是中国浓香型白酒的典型代表。', NOW(), NOW()), +(5, 1, 'a1b2c3d4-0001-0001-0001-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', '剑南春产自四川绵竹,素有"唐时宫廷酒,盛世剑南春"之美誉。水晶剑系列香气浓郁,味道甘洌,风格典雅,是商务宴请和收藏的佳品。', NOW(), NOW()), +(6, 1, 'a1b2c3d4-0001-0001-0001-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', '红花郎10产自赤水河畔二郎镇,坤沙工艺酿造,两年窖藏。酒体酱香突出,醇厚细腻,回味悠长,性价比极高的优质酱香白酒。', NOW(), NOW()), +(7, 1, 'a1b2c3d4-0001-0001-0001-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', '拉菲古堡是法国波尔多五大一级名庄之首,2018年份得益于该年出色的气候条件,酒体结构完美,单宁细腻,黑醋栗与雪松木香交织,陈年潜力达50年以上。', NOW(), NOW()), +(8, 1, 'a1b2c3d4-0001-0001-0001-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', '人头马VSOP精选法国干邑地区Fine Champagne产区葡萄,经二次蒸馏后在法国橡木桶中陈年至少四年。口感丝滑,带有香草、杏干与蜂蜜的复杂香气,是干邑入门经典。', NOW(), NOW()); -- ── 商品名称选项 ──────────────────────────────────────────── INSERT INTO product_name_options (shop_id, code, name, created_at, updated_at) VALUES diff --git a/client/lib/core/config/app_config.dart b/client/lib/core/config/app_config.dart index d8bb204..616eb9c 100644 --- a/client/lib/core/config/app_config.dart +++ b/client/lib/core/config/app_config.dart @@ -10,8 +10,14 @@ class AppConfig { defaultValue: 'http://localhost:8080', ); + static const _publicUrl = String.fromEnvironment( + 'PUBLIC_URL', + defaultValue: 'http://localhost:8081', + ); + static String get baseUrl => _baseUrl; static String get apiBaseUrl => '$_baseUrl/api/v1'; static String get healthUrl => '$_baseUrl/health'; static String get versionUrl => '$_baseUrl/version'; + static String get publicBaseUrl => _publicUrl; } diff --git a/client/lib/main.dart b/client/lib/main.dart index 4c7ce69..d0075e2 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,13 +1,14 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; import 'core/auth/auth_state.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; import 'providers/connectivity_provider.dart'; void main() { + usePathUrlStrategy(); FlutterError.onError = (details) { FlutterError.presentError(details); debugPrint('═══ FlutterError ════════════════════════════'); @@ -25,65 +26,34 @@ void main() { ); } -class JiuApp extends ConsumerWidget { +class JiuApp extends ConsumerStatefulWidget { const JiuApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - return MaterialApp( - title: '酒库管理系统', - theme: AppTheme.light(), - debugShowCheckedModeBanner: false, - home: const _AppBootstrap(), - ); - } + ConsumerState createState() => _JiuAppState(); } -/// Restores persisted auth before handing off to the router. -class _AppBootstrap extends ConsumerStatefulWidget { - const _AppBootstrap(); - - @override - ConsumerState<_AppBootstrap> createState() => _AppBootstrapState(); -} - -class _AppBootstrapState extends ConsumerState<_AppBootstrap> { - bool _ready = false; - +class _JiuAppState extends ConsumerState { @override void initState() { super.initState(); - _init(); - } - - Future _init() async { - await ref.read(authStateProvider.notifier).restore(); - // 启动时立即做一次连通性检测,确保 connectivityProvider 状态在路由跳转前已就绪 - await ref.read(connectivityProvider.notifier).forceCheck(); - if (mounted) setState(() => _ready = true); + // 异步初始化:恢复 auth + 连通性检测 + // 路由 redirect 在 initialized=false 时返回 null(不重定向), + // 初始化完成后 authState 变化触发 router refresh,redirect 重新执行 + WidgetsBinding.instance.addPostFrameCallback((_) async { + await ref.read(authStateProvider.notifier).restore(); + await ref.read(connectivityProvider.notifier).forceCheck(); + }); } @override Widget build(BuildContext context) { - if (!_ready) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } - return _RouterApp(); - } -} - -class _RouterApp extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(appRouterProvider); return MaterialApp.router( title: '酒库管理系统', theme: AppTheme.light(), routerConfig: router, debugShowCheckedModeBanner: false, - builder: (context, child) => SelectionArea(child: child!), ); } } diff --git a/client/lib/repositories/product_repository.dart b/client/lib/repositories/product_repository.dart index 1a30628..353223b 100644 --- a/client/lib/repositories/product_repository.dart +++ b/client/lib/repositories/product_repository.dart @@ -87,11 +87,16 @@ class ProductRepository { } } - Future uploadImage(int productId, String filePath) async { + Future uploadImage(int productId, String? filePath, + {Uint8List? bytes, String? fileName}) async { try { - final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile(filePath), - }); + final MultipartFile file; + if (bytes != null) { + file = MultipartFile.fromBytes(bytes, filename: fileName ?? 'image.jpg'); + } else { + file = await MultipartFile.fromFile(filePath!); + } + final formData = FormData.fromMap({'file': file}); final resp = await _client.post('/products/$productId/images', data: formData); return ProductImage.fromJson( (resp.data as Map)['data'] as Map); diff --git a/client/lib/screens/products/product_detail_screen.dart b/client/lib/screens/products/product_detail_screen.dart index f3ff311..c49b6bf 100644 --- a/client/lib/screens/products/product_detail_screen.dart +++ b/client/lib/screens/products/product_detail_screen.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -91,16 +92,20 @@ class _ProductDetailScreenState extends ConsumerState { final result = await FilePicker.platform.pickFiles( type: FileType.image, allowMultiple: false, + withData: true, ); if (result == null || result.files.isEmpty) return; - final path = result.files.first.path; - if (path == null) return; + final file = result.files.first; + final String? filePath = kIsWeb ? null : file.path; + final fileBytes = file.bytes; + if (filePath == null && fileBytes == null) return; setState(() => _uploading = true); try { final img = await ref .read(productRepositoryProvider) - .uploadImage(_product!.id, path); + .uploadImage(_product!.id, filePath, + bytes: fileBytes, fileName: file.name); _updateImages([..._product!.images, img]); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -357,7 +362,7 @@ class _ProductDetailScreenState extends ConsumerState { } Widget _buildPublicLinkSection(Product p) { - final publicUrl = 'https://jiu.51yanmei.com/product/${p.publicId}'; + final publicUrl = '${AppConfig.publicBaseUrl}/product/${p.publicId}'; return Card( elevation: 0, shape: RoundedRectangleBorder( diff --git a/client/lib/screens/public/public_product_screen.dart b/client/lib/screens/public/public_product_screen.dart index a34d860..747ad34 100644 --- a/client/lib/screens/public/public_product_screen.dart +++ b/client/lib/screens/public/public_product_screen.dart @@ -30,35 +30,40 @@ class _PublicProductScreenState extends State { Future _load() async { setState(() { _loading = true; _error = null; }); try { - final url = '${AppConfig.apiBaseUrl}/public/products/${widget.publicId}'; - final resp = await _dio.get(url); + final resp = await _dio.get( + '${AppConfig.apiBaseUrl}/public/products/${widget.publicId}', + ); + final data = (resp.data as Map)['data'] as Map; + debugPrint('[public] description=${data['description']}'); setState(() { - _data = (resp.data as Map)['data'] as Map; + _data = data; _loading = false; }); } catch (e) { - setState(() { - _error = e.toString(); - _loading = false; - }); + setState(() { _error = e.toString(); _loading = false; }); } } @override Widget build(BuildContext context) { if (_loading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold( + backgroundColor: Color(0xFFF5F5F5), + body: Center(child: CircularProgressIndicator()), + ); } if (_error != null || _data == null) { return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error_outline, size: 40, color: AppTheme.danger), - const SizedBox(height: 12), - const Text('商品不存在或已下架'), - const SizedBox(height: 12), + const Icon(Icons.error_outline, size: 48, color: AppTheme.danger), + const SizedBox(height: 16), + const Text('商品不存在或已下架', + style: TextStyle(fontSize: 16, color: Color(0xFF333333))), + const SizedBox(height: 16), ElevatedButton(onPressed: _load, child: const Text('重试')), ], ), @@ -67,8 +72,8 @@ class _PublicProductScreenState extends State { } final d = _data!; - final images = (d['images'] as List? ?? []) - .cast>(); + final images = (d['images'] as List? ?? []).cast>(); + final imageUrls = images.map((img) => AppConfig.baseUrl + (img['url'] as String)).toList(); final name = d['name'] as String? ?? ''; final series = d['series'] as String? ?? ''; final spec = d['spec'] as String? ?? ''; @@ -77,87 +82,400 @@ class _PublicProductScreenState extends State { final description = d['description'] as String? ?? ''; return Scaffold( - body: CustomScrollView( - slivers: [ - // Image carousel or placeholder - SliverToBoxAdapter( - child: images.isEmpty - ? Container( - height: 240, - color: const Color(0xFFF5F5F5), - child: const Center( - child: Icon(Icons.wine_bar, size: 80, color: Color(0xFFCCCCCC)), - ), - ) - : SizedBox( - height: 300, - child: PageView( - children: images.map((img) { - final url = AppConfig.baseUrl + (img['url'] as String); - return Image.network( - url, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - color: const Color(0xFFF5F5F5), - child: const Icon(Icons.broken_image, - size: 48, color: Color(0xFFCCCCCC)), - ), - ); - }).toList(), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(20), + backgroundColor: const Color(0xFFF5F5F5), + body: SafeArea( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _ImageGallery(imageUrls: imageUrls), + ), + const SliverToBoxAdapter(child: SizedBox(height: 8)), + SliverToBoxAdapter( + child: _TitleCard(name: name, series: series, spec: spec, brand: brand), + ), + const SliverToBoxAdapter(child: SizedBox(height: 8)), + SliverToBoxAdapter( + child: _ParamsCard(spec: spec, brand: brand, unit: unit, description: description), + ), + const SliverFillRemaining( + hasScrollBody: false, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, - style: const TextStyle( - fontSize: 22, fontWeight: FontWeight.w700)), - const SizedBox(height: 6), - if (series.isNotEmpty || spec.isNotEmpty) - Text( - [if (series.isNotEmpty) series, if (spec.isNotEmpty) spec] - .join(' · '), - style: const TextStyle( - fontSize: 15, color: AppTheme.textSecondary), + mainAxisAlignment: MainAxisAlignment.end, + children: [_FooterBrand()], + ), + ), + ], + ), + ), + ); + } +} + +// ── 图片画廊 ───────────────────────────────────────────── +class _ImageGallery extends StatefulWidget { + final List imageUrls; + const _ImageGallery({required this.imageUrls}); + + @override + State<_ImageGallery> createState() => _ImageGalleryState(); +} + +class _ImageGalleryState extends State<_ImageGallery> { + int _current = 0; + final PageController _ctrl = PageController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + void _openFullscreen(int index) { + Navigator.of(context).push(PageRouteBuilder( + opaque: false, + barrierColor: Colors.black87, + pageBuilder: (_, __, ___) => _FullscreenViewer( + urls: widget.imageUrls, + initialIndex: index, + ), + )); + } + + @override + Widget build(BuildContext context) { + final urls = widget.imageUrls; + + return LayoutBuilder(builder: (context, constraints) { + final size = constraints.maxWidth; + + if (urls.isEmpty) { + return Container( + width: size, + height: size, + color: const Color(0xFFEEEEEE), + child: const Center( + child: Icon(Icons.wine_bar, size: 96, color: Color(0xFFCCCCCC)), + ), + ); + } + + return Column( + children: [ + // 主图区(正方形) + SizedBox( + width: size, + height: size, + child: Stack( + children: [ + PageView.builder( + controller: _ctrl, + itemCount: urls.length, + onPageChanged: (i) => setState(() => _current = i), + itemBuilder: (_, i) => GestureDetector( + onTap: () => _openFullscreen(i), + child: Image.network( + urls[i], + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (_, __, ___) => Container( + color: const Color(0xFFEEEEEE), + child: const Center( + child: Icon(Icons.broken_image, size: 64, color: Color(0xFFCCCCCC)), + ), ), - if (brand.isNotEmpty) ...[ - const SizedBox(height: 4), - Text('品牌:$brand', - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - ], - if (unit.isNotEmpty) ...[ - const SizedBox(height: 4), - Text('单位:$unit', - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - ], - if (description.isNotEmpty) ...[ - const SizedBox(height: 20), - const Text('关于这款酒', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 8), - Text(description, - style: const TextStyle( - fontSize: 14, height: 1.7, - color: AppTheme.textPrimary)), - ], - const SizedBox(height: 40), - const Center( - child: Text('酒库管理系统', - style: TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), ), - ], + ), + ), + if (urls.length > 1) + Positioned( + right: 12, + bottom: 12, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${_current + 1} / ${urls.length}', + style: const TextStyle(fontSize: 12, color: Colors.white), + ), + ), + ), + Positioned( + right: 12, + top: 12, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Colors.black38, + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.zoom_in, size: 14, color: Colors.white), + SizedBox(width: 3), + Text('点击放大', style: TextStyle(fontSize: 11, color: Colors.white)), + ], + ), + ), + ), + ], + ), + ), + // 缩略图条 + if (urls.length > 1) + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SizedBox( + height: 60, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: urls.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final selected = i == _current; + return GestureDetector( + onTap: () => _ctrl.animateToPage(i, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 60, + height: 60, + decoration: BoxDecoration( + border: Border.all( + color: selected ? AppTheme.primary : const Color(0xFFDDDDDD), + width: selected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(4), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Image.network( + urls[i], + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon( + Icons.broken_image, + size: 24, + color: Color(0xFFCCCCCC), + ), + ), + ), + ), + ); + }, ), ), ), ], + ); + }); + } +} + +// ── 全屏查看器 ──────────────────────────────────────────── +class _FullscreenViewer extends StatefulWidget { + final List urls; + final int initialIndex; + const _FullscreenViewer({required this.urls, required this.initialIndex}); + + @override + State<_FullscreenViewer> createState() => _FullscreenViewerState(); +} + +class _FullscreenViewerState extends State<_FullscreenViewer> { + late int _current; + late PageController _ctrl; + + @override + void initState() { + super.initState(); + _current = widget.initialIndex; + _ctrl = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + PageView.builder( + controller: _ctrl, + itemCount: widget.urls.length, + onPageChanged: (i) => setState(() => _current = i), + itemBuilder: (_, i) => Center( + child: InteractiveViewer( + child: Image.network( + widget.urls[i], + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon( + Icons.broken_image, + size: 64, + color: Colors.white54, + ), + ), + ), + ), + ), + Positioned( + top: 40, + right: 16, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon(Icons.close, color: Colors.white, size: 20), + ), + ), + ), + if (widget.urls.length > 1) + Positioned( + bottom: 40, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.urls.length, (i) => AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: i == _current ? 16 : 6, + height: 6, + decoration: BoxDecoration( + color: i == _current ? Colors.white : Colors.white38, + borderRadius: BorderRadius.circular(3), + ), + )), + ), + ), + ], + ), + ), + ); + } +} + +// ── 标题卡 ──────────────────────────────────────────────── +class _TitleCard extends StatelessWidget { + final String name, series, spec, brand; + const _TitleCard({required this.name, required this.series, required this.spec, required this.brand}); + + @override + Widget build(BuildContext context) { + final subtitle = [if (series.isNotEmpty) series, if (spec.isNotEmpty) spec].join(' · '); + return Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF212121), height: 1.4)), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(subtitle, style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), + ], + if (brand.isNotEmpty) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFE8F0FE), + borderRadius: BorderRadius.circular(4), + ), + child: Text('品牌:$brand', + style: const TextStyle(fontSize: 12, color: AppTheme.primary, fontWeight: FontWeight.w500)), + ), + ], + ], + ), + ); + } +} + +// ── 商品参数卡 ──────────────────────────────────────────── +class _ParamsCard extends StatelessWidget { + final String spec, brand, unit, description; + const _ParamsCard({required this.spec, required this.brand, required this.unit, required this.description}); + + @override + Widget build(BuildContext context) { + final rows = <({String label, String value})>[]; + if (spec.isNotEmpty) rows.add((label: '规格', value: spec)); + if (brand.isNotEmpty) rows.add((label: '品牌', value: brand)); + if (unit.isNotEmpty) rows.add((label: '单位', value: unit)); + if (description.isNotEmpty) rows.add((label: '描述', value: description)); + if (rows.isEmpty) return const SizedBox.shrink(); + + return Container( + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 14, 16, 10), + child: Text('商品参数', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF212121))), + ), + const Divider(height: 1, color: Color(0xFFF0F0F0)), + ...rows.map((r) => Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + child: Row( + children: [ + SizedBox( + width: 72, + child: Text(r.label, + style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), + ), + Expanded( + child: Text(r.value, + style: const TextStyle(fontSize: 13, color: Color(0xFF212121))), + ), + ], + ), + ), + if (r != rows.last) const Divider(height: 1, indent: 16, color: Color(0xFFF5F5F5)), + ], + )), + const SizedBox(height: 4), + ], + ), + ); + } +} + +// ── 页脚 ────────────────────────────────────────────────── +class _FooterBrand extends StatelessWidget { + const _FooterBrand(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 20), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.wine_bar, size: 14, color: Color(0xFFBBBBBB)), + SizedBox(width: 6), + Text('酒库管理系统提供', style: TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))), + ], ), ); } diff --git a/deploy/production.env.template b/deploy/production.env.template index 798f475..5ddf348 100644 --- a/deploy/production.env.template +++ b/deploy/production.env.template @@ -8,4 +8,5 @@ JWT_REFRESH_EXPIRE_H=168 LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS STORAGE_UPLOAD_DIR=/opt/jiu/images STORAGE_BASE_URL=https://jiu.51yanmei.com/images +STORAGE_PUBLIC_URL=https://jiu.51yanmei.com DB_PASSWORD=CHANGE_ME_DB_PASS diff --git a/deploy/runner/act-runner-config.yaml b/deploy/runner/act-runner-config.yaml new file mode 100644 index 0000000..b423300 --- /dev/null +++ b/deploy/runner/act-runner-config.yaml @@ -0,0 +1,21 @@ +log: + level: info + +runner: + file: .runner + capacity: 1 + timeout: 3h + labels: + - ubuntu-latest:docker://catthehacker/ubuntu:act-latest + +cache: + enabled: true + dir: /data/cache + +container: + network: bridge + privileged: false + # 备份目录挂载到每个 job 容器,使 backup workflow 可以写 NAS 磁盘 + options: -v /volume1/backups:/volume1/backups + valid_volumes: + - /volume1/backups diff --git a/deploy/runner/docker-compose.gitea.yml b/deploy/runner/docker-compose.gitea.yml new file mode 100644 index 0000000..4600ed8 --- /dev/null +++ b/deploy/runner/docker-compose.gitea.yml @@ -0,0 +1,50 @@ +version: '3.8' + +# 群晖 NAS 自托管 Gitea + act_runner +# 使用方法: +# 1. 在同目录创建 .env,填入 RUNNER_TOKEN(见下方说明) +# 2. 在 Container Manager 导入此文件并启动 +# 3. 首次启动后访问 http://NAS-IP:3000 完成 Gitea 初始化 +# 4. 管理后台 > 站点管理 > 配置 > 开启 Actions +# 5. 仓库 Settings > Actions > Runners > 获取注册 Token 填入 .env +# 6. 重启 act-runner 容器完成注册 +# +# 本地访问:http://NAS-IP:3000 +# Git SSH: ssh://git@NAS-IP:2222/用户名/jiu.git +# +# .env 格式: +# RUNNER_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx + +services: + gitea: + image: gitea/gitea:latest + container_name: gitea + restart: always + environment: + USER_UID: 1000 + USER_GID: 1000 + GITEA__actions__ENABLED: "true" + GITEA__server__ROOT_URL: http://gitea:3000 + ports: + - "3000:3000" + - "2222:22" + volumes: + - /volume1/docker/gitea/data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + + act-runner: + image: gitea/act_runner:latest + container_name: gitea-act-runner + restart: always + depends_on: + - gitea + environment: + CONFIG_FILE: /data/config.yaml + GITEA_INSTANCE_URL: http://gitea:3000 + GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN} + GITEA_RUNNER_NAME: nas-runner + volumes: + - /volume1/docker/gitea/runner:/data + - /var/run/docker.sock:/var/run/docker.sock + - /volume1/backups:/volume1/backups diff --git a/deploy/runner/docker-compose.runner.yml b/deploy/runner/docker-compose.runner.yml new file mode 100644 index 0000000..b9bde0e --- /dev/null +++ b/deploy/runner/docker-compose.runner.yml @@ -0,0 +1,2 @@ +# 已废弃:此文件为 GitHub Actions self-hosted runner 配置 +# 当前使用 Gitea Actions,请参考 docker-compose.gitea.yml diff --git a/scripts/dev.sh b/scripts/dev.sh index 2350b7a..b99c2d7 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -272,6 +272,13 @@ start_backend() { # ── 启动 Web 前端(后台) ───────────────────────────────── start_web_bg() { + # 清理占用 8081 的旧进程 + local old_pid + old_pid=$(lsof -ti:8081 2>/dev/null | head -1) + if [ -n "$old_pid" ]; then + kill -9 "$old_pid" 2>/dev/null || true + sleep 1 + fi info "启动 Flutter Web (Chrome,后台)..." flutter run -d chrome --web-port 8081 >>"$WEB_LOG" 2>&1 & local pid=$! @@ -308,6 +315,9 @@ if [ "$RUN_WEB" = true ] && [ "$RUN_FRONTEND" = true ]; then elif [ "$RUN_WEB" = true ]; then cd "$CLIENT_DIR" && flutter pub get + # 清理占用 8081 的旧进程 + _old=$(lsof -ti:8081 2>/dev/null | head -1) + [ -n "$_old" ] && kill -9 "$_old" 2>/dev/null && sleep 1 echo "" success "Chrome 启动中,稍候自动打开..." echo -e "${CYAN}提示:按 r 热重载,按 q 退出,后端继续在后台运行${NC}"