feat: 公开商品页重设计 + web 上传修复 + 二维码配置
- 公开商品页(/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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -28,6 +28,9 @@ var allModels = []any{
|
|||||||
&model.License{},
|
&model.License{},
|
||||||
&model.ProductCategory{},
|
&model.ProductCategory{},
|
||||||
&model.Product{},
|
&model.Product{},
|
||||||
|
&model.ProductNameOption{},
|
||||||
|
&model.ProductSeriesOption{},
|
||||||
|
&model.ProductSpecOption{},
|
||||||
&model.Warehouse{},
|
&model.Warehouse{},
|
||||||
&model.Partner{},
|
&model.Partner{},
|
||||||
&model.StockInOrder{},
|
&model.StockInOrder{},
|
||||||
@@ -50,7 +53,7 @@ var truncateOrder = []string{
|
|||||||
"stock_in_items", "stock_in_orders",
|
"stock_in_items", "stock_in_orders",
|
||||||
"finance_records", "number_rules",
|
"finance_records", "number_rules",
|
||||||
"partners", "warehouses",
|
"partners", "warehouses",
|
||||||
"products", "product_categories",
|
"products", "product_spec_options", "product_series_options", "product_name_options", "product_categories",
|
||||||
"licenses", "users", "shops",
|
"licenses", "users", "shops",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +189,14 @@ func main() {
|
|||||||
remark string
|
remark string
|
||||||
}
|
}
|
||||||
prodSeeds := []prodSeed{
|
prodSeeds := []prodSeed{
|
||||||
{"MT-001", "6901234567890", "飞天茅台 53度 500ml", "茅台", "500ml/瓶", "瓶", "贵州茅台", &catBaijiu.ID, 2350, 2800, 10, "酱香型白酒,53度,飞天系列"},
|
{"MT-001", "6901234567890", "飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", &catBaijiu.ID, 2350, 2800, 10, "酱香型白酒,53度,飞天系列"},
|
||||||
{"WLY-001", "6902345678901", "五粮液 52度 500ml", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", &catBaijiu.ID, 950, 1200, 6, "浓香型白酒,52度,普五系列"},
|
{"WLY-001", "6902345678901", "五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", &catBaijiu.ID, 950, 1200, 6, "浓香型白酒,52度,普五系列"},
|
||||||
{"YH-001", "6903456789012", "洋河梦之蓝 M6 500ml", "洋河", "500ml/瓶", "瓶", "江苏洋河", &catBaijiu.ID, 560, 680, 6, "浓香型,绵柔苏酒代表"},
|
{"YH-001", "6903456789012", "洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", &catBaijiu.ID, 560, 680, 6, "浓香型,绵柔苏酒代表"},
|
||||||
{"LZ-001", "6904567890123", "泸州老窖 特曲 500ml", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", &catBaijiu.ID, 420, 520, 6, "浓香鼻祖,特曲系列"},
|
{"LZ-001", "6904567890123", "泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", &catBaijiu.ID, 420, 520, 6, "浓香鼻祖,特曲系列"},
|
||||||
{"JNC-001", "6905678901234", "剑南春 水晶剑 500ml", "剑南春", "500ml/瓶", "瓶", "剑南春", &catBaijiu.ID, 390, 480, 6, "浓香型,绵竹名酒"},
|
{"JNC-001", "6905678901234", "剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", &catBaijiu.ID, 390, 480, 6, "浓香型,绵竹名酒"},
|
||||||
{"LJ-001", "6906789012345", "郎酒 红花郎10 500ml", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", &catBaijiu.ID, 320, 420, 6, "酱香型,赤水河畔酿造"},
|
{"LJ-001", "6906789012345", "郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", &catBaijiu.ID, 320, 420, 6, "酱香型,赤水河畔酿造"},
|
||||||
{"LF-001", "3760093550058", "拉菲古堡 2018 750ml", "波尔多", "750ml/瓶", "瓶", "Château Lafite", &catImport.ID, 3800, 5200, 3, "波尔多一级名庄,2018年份"},
|
{"LF-001", "3760093550058", "拉菲古堡 2018", "波尔多", "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级别"},
|
{"RTM-001", "3021691010008", "人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", &catImport.ID, 480, 680, 3, "法国干邑,VSOP级别"},
|
||||||
}
|
}
|
||||||
var prods []model.Product
|
var prods []model.Product
|
||||||
for _, s := range prodSeeds {
|
for _, s := range prodSeeds {
|
||||||
@@ -209,6 +212,52 @@ func main() {
|
|||||||
prods = append(prods, p)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
// 往来单位
|
// 往来单位
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type LicenseConfig struct {
|
|||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
UploadDir string `mapstructure:"upload_dir"`
|
UploadDir string `mapstructure:"upload_dir"`
|
||||||
BaseURL string `mapstructure:"base_url"`
|
BaseURL string `mapstructure:"base_url"`
|
||||||
|
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
|
||||||
}
|
}
|
||||||
|
|
||||||
var C Config
|
var C Config
|
||||||
@@ -58,6 +59,7 @@ func Load() {
|
|||||||
_ = viper.BindEnv("license.hmac_secret", "LICENSE_HMAC_SECRET")
|
_ = viper.BindEnv("license.hmac_secret", "LICENSE_HMAC_SECRET")
|
||||||
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
|
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
|
||||||
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
||||||
|
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
|
||||||
|
|
||||||
// 默认值
|
// 默认值
|
||||||
viper.SetDefault("server.port", "8080")
|
viper.SetDefault("server.port", "8080")
|
||||||
@@ -67,6 +69,7 @@ func Load() {
|
|||||||
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
|
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
|
||||||
viper.SetDefault("storage.upload_dir", "./uploads/images")
|
viper.SetDefault("storage.upload_dir", "./uploads/images")
|
||||||
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
|
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
|
||||||
|
viper.SetDefault("storage.public_url", "http://localhost:8081")
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
log.Println("[config] no config file found, using defaults and env vars")
|
log.Println("[config] no config file found, using defaults and env vars")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
qrcode "github.com/skip2/go-qrcode"
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
@@ -181,7 +182,12 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
|
|||||||
return
|
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)
|
png, err := qrcode.Encode(url, qrcode.Medium, 256)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -62,15 +62,15 @@ INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updat
|
|||||||
-- ── 商品 ────────────────────────────────────────────────────
|
-- ── 商品 ────────────────────────────────────────────────────
|
||||||
-- id=1~6 白酒, id=7~8 进口烈酒
|
-- id=1~6 白酒, id=7~8 进口烈酒
|
||||||
INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
|
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
|
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度,飞天系列', NOW(), NOW()),
|
(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()),
|
(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()),
|
(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, '浓香鼻祖,特曲系列', 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()),
|
(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()),
|
(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年份', 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级别', 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
|
INSERT INTO product_name_options (shop_id, code, name, created_at, updated_at) VALUES
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ class AppConfig {
|
|||||||
defaultValue: 'http://localhost:8080',
|
defaultValue: 'http://localhost:8080',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const _publicUrl = String.fromEnvironment(
|
||||||
|
'PUBLIC_URL',
|
||||||
|
defaultValue: 'http://localhost:8081',
|
||||||
|
);
|
||||||
|
|
||||||
static String get baseUrl => _baseUrl;
|
static String get baseUrl => _baseUrl;
|
||||||
static String get apiBaseUrl => '$_baseUrl/api/v1';
|
static String get apiBaseUrl => '$_baseUrl/api/v1';
|
||||||
static String get healthUrl => '$_baseUrl/health';
|
static String get healthUrl => '$_baseUrl/health';
|
||||||
static String get versionUrl => '$_baseUrl/version';
|
static String get versionUrl => '$_baseUrl/version';
|
||||||
|
static String get publicBaseUrl => _publicUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-42
@@ -1,13 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
import 'core/auth/auth_state.dart';
|
import 'core/auth/auth_state.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';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
usePathUrlStrategy();
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
FlutterError.presentError(details);
|
FlutterError.presentError(details);
|
||||||
debugPrint('═══ FlutterError ════════════════════════════');
|
debugPrint('═══ FlutterError ════════════════════════════');
|
||||||
@@ -25,65 +26,34 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class JiuApp extends ConsumerWidget {
|
class JiuApp extends ConsumerStatefulWidget {
|
||||||
const JiuApp({super.key});
|
const JiuApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<JiuApp> createState() => _JiuAppState();
|
||||||
return MaterialApp(
|
|
||||||
title: '酒库管理系统',
|
|
||||||
theme: AppTheme.light(),
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
home: const _AppBootstrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restores persisted auth before handing off to the router.
|
class _JiuAppState extends ConsumerState<JiuApp> {
|
||||||
class _AppBootstrap extends ConsumerStatefulWidget {
|
|
||||||
const _AppBootstrap();
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<_AppBootstrap> createState() => _AppBootstrapState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AppBootstrapState extends ConsumerState<_AppBootstrap> {
|
|
||||||
bool _ready = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_init();
|
// 异步初始化:恢复 auth + 连通性检测
|
||||||
}
|
// 路由 redirect 在 initialized=false 时返回 null(不重定向),
|
||||||
|
// 初始化完成后 authState 变化触发 router refresh,redirect 重新执行
|
||||||
Future<void> _init() async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
await ref.read(authStateProvider.notifier).restore();
|
await ref.read(authStateProvider.notifier).restore();
|
||||||
// 启动时立即做一次连通性检测,确保 connectivityProvider 状态在路由跳转前已就绪
|
await ref.read(connectivityProvider.notifier).forceCheck();
|
||||||
await ref.read(connectivityProvider.notifier).forceCheck();
|
});
|
||||||
if (mounted) setState(() => _ready = true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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);
|
final router = ref.watch(appRouterProvider);
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: '酒库管理系统',
|
title: '酒库管理系统',
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
builder: (context, child) => SelectionArea(child: child!),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,11 +87,16 @@ class ProductRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ProductImage> uploadImage(int productId, String filePath) async {
|
Future<ProductImage> uploadImage(int productId, String? filePath,
|
||||||
|
{Uint8List? bytes, String? fileName}) async {
|
||||||
try {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final MultipartFile file;
|
||||||
'file': await MultipartFile.fromFile(filePath),
|
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);
|
final resp = await _client.post('/products/$productId/images', data: formData);
|
||||||
return ProductImage.fromJson(
|
return ProductImage.fromJson(
|
||||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -91,16 +92,20 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
|||||||
final result = await FilePicker.platform.pickFiles(
|
final result = await FilePicker.platform.pickFiles(
|
||||||
type: FileType.image,
|
type: FileType.image,
|
||||||
allowMultiple: false,
|
allowMultiple: false,
|
||||||
|
withData: true,
|
||||||
);
|
);
|
||||||
if (result == null || result.files.isEmpty) return;
|
if (result == null || result.files.isEmpty) return;
|
||||||
final path = result.files.first.path;
|
final file = result.files.first;
|
||||||
if (path == null) return;
|
final String? filePath = kIsWeb ? null : file.path;
|
||||||
|
final fileBytes = file.bytes;
|
||||||
|
if (filePath == null && fileBytes == null) return;
|
||||||
|
|
||||||
setState(() => _uploading = true);
|
setState(() => _uploading = true);
|
||||||
try {
|
try {
|
||||||
final img = await ref
|
final img = await ref
|
||||||
.read(productRepositoryProvider)
|
.read(productRepositoryProvider)
|
||||||
.uploadImage(_product!.id, path);
|
.uploadImage(_product!.id, filePath,
|
||||||
|
bytes: fileBytes, fileName: file.name);
|
||||||
_updateImages([..._product!.images, img]);
|
_updateImages([..._product!.images, img]);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -357,7 +362,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPublicLinkSection(Product p) {
|
Widget _buildPublicLinkSection(Product p) {
|
||||||
final publicUrl = 'https://jiu.51yanmei.com/product/${p.publicId}';
|
final publicUrl = '${AppConfig.publicBaseUrl}/product/${p.publicId}';
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|||||||
@@ -30,35 +30,40 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
|||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() { _loading = true; _error = null; });
|
setState(() { _loading = true; _error = null; });
|
||||||
try {
|
try {
|
||||||
final url = '${AppConfig.apiBaseUrl}/public/products/${widget.publicId}';
|
final resp = await _dio.get(
|
||||||
final resp = await _dio.get(url);
|
'${AppConfig.apiBaseUrl}/public/products/${widget.publicId}',
|
||||||
|
);
|
||||||
|
final data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||||
|
debugPrint('[public] description=${data['description']}');
|
||||||
setState(() {
|
setState(() {
|
||||||
_data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
_data = data;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() { _error = e.toString(); _loading = false; });
|
||||||
_error = e.toString();
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
return const Scaffold(
|
||||||
|
backgroundColor: Color(0xFFF5F5F5),
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (_error != null || _data == null) {
|
if (_error != null || _data == null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, size: 40, color: AppTheme.danger),
|
const Icon(Icons.error_outline, size: 48, color: AppTheme.danger),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
const Text('商品不存在或已下架'),
|
const Text('商品不存在或已下架',
|
||||||
const SizedBox(height: 12),
|
style: TextStyle(fontSize: 16, color: Color(0xFF333333))),
|
||||||
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(onPressed: _load, child: const Text('重试')),
|
ElevatedButton(onPressed: _load, child: const Text('重试')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -67,8 +72,8 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final d = _data!;
|
final d = _data!;
|
||||||
final images = (d['images'] as List<dynamic>? ?? [])
|
final images = (d['images'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||||
.cast<Map<String, dynamic>>();
|
final imageUrls = images.map((img) => AppConfig.baseUrl + (img['url'] as String)).toList();
|
||||||
final name = d['name'] as String? ?? '';
|
final name = d['name'] as String? ?? '';
|
||||||
final series = d['series'] as String? ?? '';
|
final series = d['series'] as String? ?? '';
|
||||||
final spec = d['spec'] as String? ?? '';
|
final spec = d['spec'] as String? ?? '';
|
||||||
@@ -77,87 +82,400 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
|||||||
final description = d['description'] as String? ?? '';
|
final description = d['description'] as String? ?? '';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
slivers: [
|
body: SafeArea(
|
||||||
// Image carousel or placeholder
|
child: CustomScrollView(
|
||||||
SliverToBoxAdapter(
|
slivers: [
|
||||||
child: images.isEmpty
|
SliverToBoxAdapter(
|
||||||
? Container(
|
child: _ImageGallery(imageUrls: imageUrls),
|
||||||
height: 240,
|
),
|
||||||
color: const Color(0xFFF5F5F5),
|
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||||
child: const Center(
|
SliverToBoxAdapter(
|
||||||
child: Icon(Icons.wine_bar, size: 80, color: Color(0xFFCCCCCC)),
|
child: _TitleCard(name: name, series: series, spec: spec, brand: brand),
|
||||||
),
|
),
|
||||||
)
|
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||||
: SizedBox(
|
SliverToBoxAdapter(
|
||||||
height: 300,
|
child: _ParamsCard(spec: spec, brand: brand, unit: unit, description: description),
|
||||||
child: PageView(
|
),
|
||||||
children: images.map((img) {
|
const SliverFillRemaining(
|
||||||
final url = AppConfig.baseUrl + (img['url'] as String);
|
hasScrollBody: false,
|
||||||
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),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [_FooterBrand()],
|
||||||
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),
|
// ── 图片画廊 ─────────────────────────────────────────────
|
||||||
|
class _ImageGallery extends StatefulWidget {
|
||||||
|
final List<String> 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<String> 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))),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ JWT_REFRESH_EXPIRE_H=168
|
|||||||
LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
|
LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
|
||||||
STORAGE_UPLOAD_DIR=/opt/jiu/images
|
STORAGE_UPLOAD_DIR=/opt/jiu/images
|
||||||
STORAGE_BASE_URL=https://jiu.51yanmei.com/images
|
STORAGE_BASE_URL=https://jiu.51yanmei.com/images
|
||||||
|
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
|
||||||
DB_PASSWORD=CHANGE_ME_DB_PASS
|
DB_PASSWORD=CHANGE_ME_DB_PASS
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 已废弃:此文件为 GitHub Actions self-hosted runner 配置
|
||||||
|
# 当前使用 Gitea Actions,请参考 docker-compose.gitea.yml
|
||||||
@@ -272,6 +272,13 @@ start_backend() {
|
|||||||
|
|
||||||
# ── 启动 Web 前端(后台) ─────────────────────────────────
|
# ── 启动 Web 前端(后台) ─────────────────────────────────
|
||||||
start_web_bg() {
|
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,后台)..."
|
info "启动 Flutter Web (Chrome,后台)..."
|
||||||
flutter run -d chrome --web-port 8081 >>"$WEB_LOG" 2>&1 &
|
flutter run -d chrome --web-port 8081 >>"$WEB_LOG" 2>&1 &
|
||||||
local pid=$!
|
local pid=$!
|
||||||
@@ -308,6 +315,9 @@ if [ "$RUN_WEB" = true ] && [ "$RUN_FRONTEND" = true ]; then
|
|||||||
|
|
||||||
elif [ "$RUN_WEB" = true ]; then
|
elif [ "$RUN_WEB" = true ]; then
|
||||||
cd "$CLIENT_DIR" && flutter pub get
|
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 ""
|
echo ""
|
||||||
success "Chrome 启动中,稍候自动打开..."
|
success "Chrome 启动中,稍候自动打开..."
|
||||||
echo -e "${CYAN}提示:按 r 热重载,按 q 退出,后端继续在后台运行${NC}"
|
echo -e "${CYAN}提示:按 r 热重载,按 q 退出,后端继续在后台运行${NC}"
|
||||||
|
|||||||
Reference in New Issue
Block a user