diff --git a/.gitignore b/.gitignore index 429567c..d9f248e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,23 @@ .env.local .env.*.local +# 部署密钥文件(不进代码库) +deploy/production.env +backend/jiu-server + # Go 构建产物 backend/bin/ backend/*.exe +backend/seed + +# 上传的图片(本地存储,不进代码库) +backend/uploads/ + +# UI 设计资源 +ui/ + +# Flutter 开发工具配置 +client/devtools_options.yaml # Flutter 构建产物 client/build/ @@ -16,6 +30,7 @@ client/.flutter-plugins-dependencies # macOS .DS_Store **/.DS_Store +data/ # IDE .idea/ diff --git a/backend/config/config.go b/backend/config/config.go index b189df6..207efe7 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -2,6 +2,7 @@ package config import ( "log" + "strings" "github.com/spf13/viper" ) @@ -11,11 +12,13 @@ type Config struct { Database DatabaseConfig JWT JWTConfig License LicenseConfig + Storage StorageConfig } type ServerConfig struct { - Port string `mapstructure:"port"` - Mode string `mapstructure:"mode"` // debug | release + Port string `mapstructure:"port"` + Mode string `mapstructure:"mode"` // debug | release + CORSOrigin string `mapstructure:"cors_origin"` // 允许的 CORS 来源,生产设为具体域名 } type DatabaseConfig struct { @@ -32,6 +35,11 @@ type LicenseConfig struct { HMACSecret string `mapstructure:"hmac_secret"` // 许可证签名密钥 } +type StorageConfig struct { + UploadDir string `mapstructure:"upload_dir"` + BaseURL string `mapstructure:"base_url"` +} + var C Config func Load() { @@ -41,13 +49,24 @@ func Load() { viper.AddConfigPath("./config") // 环境变量覆盖(生产部署时使用) + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.AutomaticEnv() + // 显式绑定没有默认值的 key,确保 AutomaticEnv 能找到对应 env var + _ = viper.BindEnv("database.dsn", "DATABASE_DSN") + _ = viper.BindEnv("jwt.secret", "JWT_SECRET") + _ = 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.SetDefault("server.port", "8080") viper.SetDefault("server.mode", "debug") + viper.SetDefault("server.cors_origin", "*") viper.SetDefault("jwt.access_expire_min", 60) viper.SetDefault("jwt.refresh_expire_h", 168) // 7天 + viper.SetDefault("storage.upload_dir", "./uploads/images") + viper.SetDefault("storage.base_url", "http://localhost:8080/images") if err := viper.ReadInConfig(); err != nil { log.Println("[config] no config file found, using defaults and env vars") diff --git a/backend/config/config.yaml b/backend/config/config.yaml index 7900c55..86bbd42 100644 --- a/backend/config/config.yaml +++ b/backend/config/config.yaml @@ -13,3 +13,7 @@ jwt: license: hmac_secret: "change-this-license-secret-in-production" + +storage: + upload_dir: "./uploads/images" + base_url: "http://localhost:8080/images" diff --git a/backend/go.mod b/backend/go.mod index f1354b4..bd15232 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -21,6 +21,9 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/disintegration/imaging v1.6.2 // indirect + github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect + github.com/extrame/xls v0.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -31,6 +34,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -47,6 +51,7 @@ require ( github.com/richardlehane/mscfb v1.0.6 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -60,6 +65,7 @@ require ( go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect + golang.org/x/image v0.25.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0d53ede..cdbc850 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -11,6 +11,12 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c= +github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw= +github.com/extrame/xls v0.0.1 h1:jI7L/o3z73TyyENPopsLS/Jlekm3nF1a/kF5hKBvy/k= +github.com/extrame/xls v0.0.1/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -42,6 +48,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -81,6 +89,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -126,6 +136,7 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= @@ -133,6 +144,7 @@ golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index f5d5041..2453c83 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -1,9 +1,15 @@ package handler import ( + "fmt" + "io" "net/http" + "os" + "strconv" "strings" + "time" + "github.com/extrame/xls" "github.com/gin-gonic/gin" "github.com/xuri/excelize/v2" "gorm.io/gorm" @@ -92,60 +98,513 @@ func (h *ImportHandler) ImportProducts(c *gin.Context) { } // ImportPartners POST /api/v1/import/partners -// 列顺序:名称,类型(supplier/customer),联系人,电话,地址,备注 +// 列顺序(来往单位.xls):编号,类型,状态,名称,电话,卡号,初始金额,单位,地址,...,备注 func (h *ImportHandler) ImportPartners(c *gin.Context) { shopID := middleware.GetShopID(c) - file, err := c.FormFile("file") + rows, err := parseUploadedExcel(c) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "file required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - f, err := file.Open() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - defer f.Close() - - xl, err := excelize.OpenReader(f) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid excel file"}) - return - } - - rows, err := xl.GetRows(xl.GetSheetName(0)) - if err != nil || len(rows) < 2 { - c.JSON(http.StatusBadRequest, gin.H{"error": "empty sheet"}) - return - } - - var partners []model.Partner + total, imported, skipped := 0, 0, 0 for _, row := range rows[1:] { - if len(row) < 1 || strings.TrimSpace(row[0]) == "" { + name := cell(row, 3) + if name == "" { continue } - t := cell(row, 1) - if t == "" { - t = "supplier" + total++ + var existing model.Partner + if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil { + skipped++ + continue } - partners = append(partners, model.Partner{ + balance, _ := strconv.ParseFloat(cell(row, 6), 64) + status := "enabled" + if cell(row, 2) == "禁用" { + status = "disabled" + } + p := model.Partner{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: cell(row, 0), + Type: parsePartnerType(cell(row, 1)), + Status: status, + Name: name, + Phone: cell(row, 4), + BankAccount: cell(row, 5), + Balance: balance, + Address: cell(row, 8), + Remark: cell(row, 11), + } + if h.db.Create(&p).Error == nil { + imported++ + } + } + + c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped}) +} + +// ImportProductNames POST /api/v1/import/product-names +// 列顺序(商品名称.xls):选项编号,选项名称,备注 +func (h *ImportHandler) ImportProductNames(c *gin.Context) { + shopID := middleware.GetShopID(c) + rows, err := parseUploadedExcel(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + total, imported, skipped := 0, 0, 0 + for _, row := range rows[1:] { + name := cell(row, 1) + if name == "" { + continue + } + total++ + var existing model.ProductNameOption + if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil { + skipped++ + continue + } + opt := model.ProductNameOption{ TenantBase: model.TenantBase{ShopID: shopID}, - Name: cell(row, 0), - Type: t, - Contact: cell(row, 2), - Phone: cell(row, 3), - Address: cell(row, 4), - Remark: cell(row, 5), + Code: cell(row, 0), + Name: name, + Remark: cell(row, 2), + } + if h.db.Create(&opt).Error == nil { + imported++ + } + } + c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped}) +} + +// ImportProductSeries POST /api/v1/import/product-series +// 列顺序(商品系列.xls):选项编号,选项名称,备注 +func (h *ImportHandler) ImportProductSeries(c *gin.Context) { + shopID := middleware.GetShopID(c) + rows, err := parseUploadedExcel(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + total, imported, skipped := 0, 0, 0 + for _, row := range rows[1:] { + name := cell(row, 1) + if name == "" { + continue + } + total++ + var existing model.ProductSeriesOption + if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil { + skipped++ + continue + } + opt := model.ProductSeriesOption{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: cell(row, 0), + Name: name, + Remark: cell(row, 2), + } + if h.db.Create(&opt).Error == nil { + imported++ + } + } + c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped}) +} + +// ImportProductSpecs POST /api/v1/import/product-specs +// 列顺序(商品规格.xls):选项编号,选项名称,单品数量,备注 +func (h *ImportHandler) ImportProductSpecs(c *gin.Context) { + shopID := middleware.GetShopID(c) + rows, err := parseUploadedExcel(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + total, imported, skipped := 0, 0, 0 + for _, row := range rows[1:] { + name := cell(row, 1) + if name == "" { + continue + } + total++ + var existing model.ProductSpecOption + if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil { + skipped++ + continue + } + qty, _ := strconv.Atoi(strings.TrimSuffix(cell(row, 2), ".0")) // "12" 或 "12.0" + if qty == 0 { + qtyF, _ := strconv.ParseFloat(cell(row, 2), 64) + qty = int(qtyF) + } + opt := model.ProductSpecOption{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: cell(row, 0), + Name: name, + Quantity: qty, + Remark: cell(row, 3), + } + if h.db.Create(&opt).Error == nil { + imported++ + } + } + c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped}) +} + +// ImportStockIn POST /api/v1/import/stock-in +// 支持老系统打印格式(每文件一张入库单) +func (h *ImportHandler) ImportStockIn(c *gin.Context) { + shopID := middleware.GetShopID(c) + userID := middleware.GetUserID(c) + rows, err := parseUploadedExcel(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(rows) < 7 { + c.JSON(http.StatusBadRequest, gin.H{"error": "文件行数不足,请检查格式"}) + return + } + + // 解析单据头 + partnerName := strings.TrimPrefix(cell(rows[3], 0), "来往单位名称:") + dateStr := strings.TrimPrefix(cell(rows[3], 7), "单据日期:") + orderNo := strings.TrimPrefix(cell(rows[3], 18), "NO.") + + if orderNo == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "未找到单据号,请检查文件格式"}) + return + } + + // 检查重复 + var existing model.StockInOrder + if h.db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&existing).Error == nil { + c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "skipped": true, "message": "单据已存在,已跳过"}) + return + } + + // 解析日期 + orderDate := parseDate(dateStr) + + // 往来单位 + partnerID := findOrCreatePartner(h.db, shopID, partnerName, "supplier") + + // 默认仓库 + var wh model.Warehouse + if h.db.Where("shop_id = ? AND is_default = 1", shopID).First(&wh).Error != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请先在系统设置中设置默认仓库"}) + return + } + + // 解析明细 + var items []model.StockInItem + var totalAmount float64 + for _, row := range rows[6:] { + if cell(row, 0) == "" || strings.HasPrefix(cell(row, 0), "单据总计") { + break + } + productName := cell(row, 1) + if productName == "" { + continue + } + series := cell(row, 5) + spec := cell(row, 6) + qty, _ := strconv.ParseFloat(cell(row, 9), 64) + price, _ := strconv.ParseFloat(cell(row, 11), 64) + batchNo := cell(row, 16) + + prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec) + if err != nil { + continue + } + total := qty * price + totalAmount += total + items = append(items, model.StockInItem{ + ShopID: shopID, + ProductID: prod.ID, + Quantity: qty, + UnitPrice: price, + TotalPrice: total, + BatchNo: batchNo, }) } - if err := h.db.CreateInBatches(&partners, 100).Error; err != nil { + if len(items) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "未解析到有效明细行"}) + return + } + + order := model.StockInOrder{ + TenantBase: model.TenantBase{ShopID: shopID}, + OrderNo: orderNo, + Type: "purchase", + WarehouseID: wh.ID, + PartnerID: partnerID, + OperatorID: userID, + Status: "draft", + OrderDate: orderDate, + TotalAmount: totalAmount, + } + + if err := h.db.Create(&order).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"imported": len(partners)}) + for i := range items { + items[i].OrderID = order.ID + } + if err := h.db.CreateInBatches(&items, 50).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "items": len(items)}) +} + +// ImportStockOut POST /api/v1/import/stock-out +// 支持老系统打印格式(每文件一张出库单) +func (h *ImportHandler) ImportStockOut(c *gin.Context) { + shopID := middleware.GetShopID(c) + userID := middleware.GetUserID(c) + rows, err := parseUploadedExcel(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(rows) < 7 { + c.JSON(http.StatusBadRequest, gin.H{"error": "文件行数不足,请检查格式"}) + return + } + + // 出库单日期在 col8(比入库单多一个空列) + partnerName := strings.TrimPrefix(cell(rows[3], 0), "来往单位名称:") + dateStr := strings.TrimPrefix(cell(rows[3], 8), "单据日期:") + orderNo := strings.TrimPrefix(cell(rows[3], 18), "NO.") + + if orderNo == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "未找到单据号,请检查文件格式"}) + return + } + + var existing model.StockOutOrder + if h.db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&existing).Error == nil { + c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "skipped": true, "message": "单据已存在,已跳过"}) + return + } + + orderDate := parseDate(dateStr) + partnerID := findOrCreatePartner(h.db, shopID, partnerName, "customer") + + var wh model.Warehouse + if h.db.Where("shop_id = ? AND is_default = 1", shopID).First(&wh).Error != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请先在系统设置中设置默认仓库"}) + return + } + + var items []model.StockOutItem + var totalAmount float64 + for _, row := range rows[6:] { + if cell(row, 0) == "" || strings.HasPrefix(cell(row, 0), "单据总计") { + break + } + productName := cell(row, 1) + if productName == "" { + continue + } + series := cell(row, 5) + spec := cell(row, 6) + qty, _ := strconv.ParseFloat(cell(row, 9), 64) + price, _ := strconv.ParseFloat(cell(row, 11), 64) + + prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec) + if err != nil { + continue + } + total := qty * price + totalAmount += total + items = append(items, model.StockOutItem{ + ShopID: shopID, + ProductID: prod.ID, + Quantity: qty, + UnitPrice: price, + TotalPrice: total, + }) + } + + if len(items) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "未解析到有效明细行"}) + return + } + + order := model.StockOutOrder{ + TenantBase: model.TenantBase{ShopID: shopID}, + OrderNo: orderNo, + Type: "sale", + WarehouseID: wh.ID, + PartnerID: partnerID, + OperatorID: userID, + Status: "draft", + OrderDate: orderDate, + TotalAmount: totalAmount, + } + + if err := h.db.Create(&order).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + for i := range items { + items[i].OrderID = order.ID + } + if err := h.db.CreateInBatches(&items, 50).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "items": len(items)}) +} + +// ── 内部辅助函数 ───────────────────────────────────────────── + +func parseUploadedExcel(c *gin.Context) ([][]string, error) { + file, err := c.FormFile("file") + if err != nil { + return nil, fmt.Errorf("file required") + } + f, err := file.Open() + if err != nil { + return nil, err + } + defer f.Close() + + // 读取前 4 字节检测 OLE2 格式(D0 CF 11 E0 = 老式 .xls BIFF) + var magic [4]byte + f.Read(magic[:]) + f.Seek(0, 0) + isOLE := magic[0] == 0xD0 && magic[1] == 0xCF && magic[2] == 0x11 && magic[3] == 0xE0 + + var rows [][]string + + if isOLE { + // 老格式 BIFF — extrame/xls 需要文件路径,写入临时文件 + tmp, tmpErr := os.CreateTemp("", "import_*.xls") + if tmpErr != nil { + return nil, fmt.Errorf("cannot create temp file: %s", tmpErr.Error()) + } + defer os.Remove(tmp.Name()) + if _, cpErr := io.Copy(tmp, f); cpErr != nil { + tmp.Close() + return nil, fmt.Errorf("cannot write temp file: %s", cpErr.Error()) + } + tmp.Close() + + wb, xlErr := xls.Open(tmp.Name(), "utf-8") + if xlErr != nil { + return nil, fmt.Errorf("invalid xls file: %s", xlErr.Error()) + } + sheet := wb.GetSheet(0) + if sheet == nil { + return nil, fmt.Errorf("no sheet found") + } + // LastCol() returns 0 for many data rows in extrame/xls; derive column + // count from the header row instead. + numCols := 0 + headerRow := sheet.Row(0) + for c := 0; c < headerRow.LastCol(); c++ { + if strings.TrimSpace(headerRow.Col(c)) != "" { + numCols = c + 1 + } + } + if numCols == 0 { + numCols = 20 + } + for r := 0; r <= int(sheet.MaxRow); r++ { + row := sheet.Row(r) + cells := make([]string, numCols) + for c := 0; c < numCols; c++ { + cells[c] = strings.TrimSpace(row.Col(c)) + } + rows = append(rows, cells) + } + } else { + // xlsx / xlsm(ZIP 格式) + xl, xlErr := excelize.OpenReader(f) + if xlErr != nil { + return nil, fmt.Errorf("invalid xlsx file: %s", xlErr.Error()) + } + rows, err = xl.GetRows(xl.GetSheetName(0)) + if err != nil { + return nil, fmt.Errorf("cannot read sheet: %s", err.Error()) + } + } + + if len(rows) < 2 { + return nil, fmt.Errorf("empty or invalid sheet") + } + return rows, nil +} + +func findOrCreateProductFn(db *gorm.DB, shopID uint64, name, series, spec string) (model.Product, error) { + var p model.Product + if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL", + shopID, name, series, spec).First(&p).Error == nil { + return p, nil + } + p = model.Product{ + TenantBase: model.TenantBase{ShopID: shopID}, + Name: name, + Series: series, + Spec: spec, + Unit: "瓶", + } + return p, db.Create(&p).Error +} + +func findOrCreatePartner(db *gorm.DB, shopID uint64, name, ptype string) *uint64 { + if name == "" { + return nil + } + var p model.Partner + if db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&p).Error == nil { + id := p.ID + return &id + } + p = model.Partner{ + TenantBase: model.TenantBase{ShopID: shopID}, + Name: name, + Type: ptype, + Status: "enabled", + } + if db.Create(&p).Error != nil { + return nil + } + id := p.ID + return &id +} + +func parseDate(s string) model.Date { + t, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return model.Date{Time: time.Now()} + } + return model.Date{Time: t} +} + +func parsePartnerType(raw string) string { + hasCust := strings.Contains(raw, "客户") + hasSupp := strings.Contains(raw, "供应商") + switch { + case hasCust && hasSupp: + return "supplier,customer" + case hasCust: + return "customer" + default: + return "supplier" + } } func cell(row []string, idx int) string { diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index 28631e4..73a0010 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -1,12 +1,15 @@ package handler import ( + "bytes" "errors" "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" + "github.com/google/uuid" + qrcode "github.com/skip2/go-qrcode" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/middleware" @@ -65,6 +68,7 @@ func (h *ProductHandler) Create(c *gin.Context) { return } product.ShopID = shopID + product.PublicID = uuid.New().String() // Auto-generate product code if not provided (e.g. P001, P002) // Retry up to 5 times on duplicate key to handle concurrent creates @@ -130,6 +134,7 @@ func (h *ProductHandler) Update(c *gin.Context) { "purchase_price": req.PurchasePrice, "sale_price": req.SalePrice, "min_stock": req.MinStock, + "description": req.Description, "remark": req.Remark, "custom_fields": req.CustomFields, }).Error; err != nil { @@ -142,6 +147,97 @@ func (h *ProductHandler) Update(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": product}) } +// Detail GET /api/v1/products/:id/detail +func (h *ProductHandler) Detail(c *gin.Context) { + shopID := middleware.GetShopID(c) + id := c.Param("id") + + var product model.Product + if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID). + Preload("Category").Preload("Images"). + First(&product).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + // 老数据可能没有 public_id,按需补生成 + if product.PublicID == "" { + product.PublicID = uuid.New().String() + h.db.Model(&product).Update("public_id", product.PublicID) + } + + c.JSON(http.StatusOK, gin.H{"data": product}) +} + +// QRCode GET /api/v1/products/:id/qrcode +func (h *ProductHandler) QRCode(c *gin.Context) { + shopID := middleware.GetShopID(c) + id := c.Param("id") + + var product model.Product + if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID). + Select("id, public_id").First(&product).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + url := "https://jiu.51yanmei.com/product/" + product.PublicID + png, err := qrcode.Encode(url, qrcode.Medium, 256) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.DataFromReader(http.StatusOK, int64(len(png)), "image/png", bytes.NewReader(png), nil) +} + +// FindOrCreate POST /api/v1/products/find-or-create +func (h *ProductHandler) FindOrCreate(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Name string `json:"name" binding:"required"` + Series string `json:"series"` + Spec string `json:"spec"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var product model.Product + err := h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL", + shopID, req.Name, req.Series, req.Spec).First(&product).Error + if err == nil { + c.JSON(http.StatusOK, gin.H{"data": product}) + return + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var count int64 + h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count) + product = model.Product{ + TenantBase: model.TenantBase{ShopID: shopID}, + PublicID: uuid.New().String(), + Name: req.Name, + Series: req.Series, + Spec: req.Spec, + Code: fmt.Sprintf("P%03d", count+1), + } + if createErr := h.db.Create(&product).Error; createErr != nil { + // Race condition: try to find the record created by another request + if h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL", + shopID, req.Name, req.Series, req.Spec).First(&product).Error == nil { + c.JSON(http.StatusOK, gin.H{"data": product}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": product}) +} + // Delete DELETE /api/v1/products/:id (软删除) func (h *ProductHandler) Delete(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/product_image.go b/backend/internal/handler/product_image.go new file mode 100644 index 0000000..933cb63 --- /dev/null +++ b/backend/internal/handler/product_image.go @@ -0,0 +1,143 @@ +package handler + +import ( + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "net/http" + "os" + "path/filepath" + "strconv" + + "github.com/disintegration/imaging" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type ProductImageHandler struct { + db *gorm.DB +} + +func NewProductImageHandler(db *gorm.DB) *ProductImageHandler { + return &ProductImageHandler{db: db} +} + +// Upload POST /api/v1/products/:id/images +func (h *ProductImageHandler) Upload(c *gin.Context) { + shopID := middleware.GetShopID(c) + productID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"}) + return + } + + // Verify product belongs to shop + var product model.Product + if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", productID, shopID). + First(&product).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + return + } + + // Check image count limit + var count int64 + h.db.Model(&model.ProductImage{}).Where("product_id = ? AND shop_id = ?", productID, shopID).Count(&count) + if count >= 5 { + c.JSON(http.StatusBadRequest, gin.H{"error": "最多上传 5 张图片"}) + return + } + + // Parse multipart (1MB limit) + if err := c.Request.ParseMultipartForm(1 << 20); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 1MB 限制"}) + return + } + + file, _, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file)"}) + return + } + defer file.Close() + + // Validate image format via decoding + img, _, err := image.Decode(file) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"}) + return + } + + // Resize if needed (max 1200px on either dimension, preserve aspect ratio) + resized := imaging.Fit(img, 1200, 1200, imaging.Lanczos) + + // Prepare output path + filename := uuid.New().String() + ".jpg" + subdir := fmt.Sprintf("%s/products/%d", config.C.Storage.UploadDir, productID) + if err := os.MkdirAll(subdir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"}) + return + } + fullPath := filepath.Join(subdir, filename) + + // Save as JPEG with quality 85 + if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(85)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"}) + return + } + + // Relative URL served by Nginx / dev static handler + relURL := fmt.Sprintf("/images/products/%d/%s", productID, filename) + + pi := model.ProductImage{ + ProductID: productID, + ShopID: shopID, + URL: relURL, + } + if err := h.db.Create(&pi).Error; err != nil { + os.Remove(fullPath) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": pi}) +} + +// Delete DELETE /api/v1/products/:id/images/:image_id +func (h *ProductImageHandler) Delete(c *gin.Context) { + shopID := middleware.GetShopID(c) + productID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"}) + return + } + imageID, err := strconv.ParseUint(c.Param("image_id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image id"}) + return + } + + var pi model.ProductImage + if err := h.db.Where("id = ? AND product_id = ? AND shop_id = ?", imageID, productID, shopID). + First(&pi).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + // Delete file from disk + filename := filepath.Base(pi.URL) + fullPath := filepath.Join(config.C.Storage.UploadDir, fmt.Sprintf("products/%d/%s", productID, filename)) + os.Remove(fullPath) + + if err := h.db.Delete(&pi).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/backend/internal/handler/product_option.go b/backend/internal/handler/product_option.go new file mode 100644 index 0000000..aecc742 --- /dev/null +++ b/backend/internal/handler/product_option.go @@ -0,0 +1,138 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type ProductOptionHandler struct { + db *gorm.DB +} + +func NewProductOptionHandler(db *gorm.DB) *ProductOptionHandler { + return &ProductOptionHandler{db: db} +} + +// ── 商品名称 ────────────────────────────────────────────────── + +func (h *ProductOptionHandler) ListNames(c *gin.Context) { + shopID := middleware.GetShopID(c) + var items []model.ProductNameOption + h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *ProductOptionHandler) CreateName(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := model.ProductNameOption{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: req.Code, + Name: req.Name, + Remark: req.Remark, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": item}) +} + +func (h *ProductOptionHandler) DeleteName(c *gin.Context) { + shopID := middleware.GetShopID(c) + h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductNameOption{}) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// ── 商品系列 ────────────────────────────────────────────────── + +func (h *ProductOptionHandler) ListSeries(c *gin.Context) { + shopID := middleware.GetShopID(c) + var items []model.ProductSeriesOption + h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *ProductOptionHandler) CreateSeries(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := model.ProductSeriesOption{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: req.Code, + Name: req.Name, + Remark: req.Remark, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": item}) +} + +func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) { + shopID := middleware.GetShopID(c) + h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSeriesOption{}) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// ── 商品规格 ────────────────────────────────────────────────── + +func (h *ProductOptionHandler) ListSpecs(c *gin.Context) { + shopID := middleware.GetShopID(c) + var items []model.ProductSpecOption + h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *ProductOptionHandler) CreateSpec(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Quantity int `json:"quantity"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := model.ProductSpecOption{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: req.Code, + Name: req.Name, + Quantity: req.Quantity, + Remark: req.Remark, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": item}) +} + +func (h *ProductOptionHandler) DeleteSpec(c *gin.Context) { + shopID := middleware.GetShopID(c) + h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSpecOption{}) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go new file mode 100644 index 0000000..716c815 --- /dev/null +++ b/backend/internal/handler/public.go @@ -0,0 +1,45 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/model" +) + +type PublicHandler struct { + db *gorm.DB +} + +func NewPublicHandler(db *gorm.DB) *PublicHandler { + return &PublicHandler{db: db} +} + +// GetProduct GET /api/v1/public/products/:public_id (no auth) +func (h *PublicHandler) GetProduct(c *gin.Context) { + publicID := c.Param("public_id") + + var product model.Product + if err := h.db.Where("public_id = ? AND deleted_at IS NULL", publicID). + Preload("Images"). + First(&product).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + // Return only public-safe fields (no price/stock info) + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "id": product.ID, + "name": product.Name, + "series": product.Series, + "spec": product.Spec, + "brand": product.Brand, + "unit": product.Unit, + "description": product.Description, + "images": product.Images, + }, + }) +} diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index b3b5792..57e1e42 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -51,7 +51,7 @@ func (h *StockInHandler) List(c *gin.Context) { query.Count(&total) var orders []model.StockInOrder - query.Preload("Warehouse").Preload("Partner").Preload("Operator"). + query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer"). Offset((page - 1) * pageSize).Limit(pageSize). Order("id DESC").Find(&orders) @@ -62,7 +62,7 @@ func (h *StockInHandler) List(c *gin.Context) { func (h *StockInHandler) Get(c *gin.Context) { shopID := middleware.GetShopID(c) var order model.StockInOrder - if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner"). + if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer"). Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID). First(&order).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index b2a8cd4..f00d186 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -45,7 +45,7 @@ func (h *StockOutHandler) List(c *gin.Context) { query.Count(&total) var orders []model.StockOutOrder - query.Preload("Warehouse").Preload("Partner").Preload("Operator"). + query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer"). Offset((page - 1) * pageSize).Limit(pageSize). Order("id DESC").Find(&orders) @@ -56,7 +56,7 @@ func (h *StockOutHandler) List(c *gin.Context) { func (h *StockOutHandler) Get(c *gin.Context) { shopID := middleware.GetShopID(c) var order model.StockOutOrder - if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner"). + if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer"). Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID). First(&order).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) diff --git a/backend/internal/model/partner.go b/backend/internal/model/partner.go index 993a214..cbfaf8a 100644 --- a/backend/internal/model/partner.go +++ b/backend/internal/model/partner.go @@ -11,6 +11,7 @@ type Partner struct { BankAccount string `gorm:"size:100" json:"bank_account"` CreditLimit float64 `gorm:"type:decimal(12,2)" json:"credit_limit"` Balance float64 `gorm:"type:decimal(12,2);default:0" json:"balance"` + Status string `gorm:"type:enum('enabled','disabled');not null;default:'enabled'" json:"status"` CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` Remark string `gorm:"size:500" json:"remark"` } diff --git a/backend/internal/model/product.go b/backend/internal/model/product.go index 8f9c9d3..e9d785f 100644 --- a/backend/internal/model/product.go +++ b/backend/internal/model/product.go @@ -9,6 +9,7 @@ type ProductCategory struct { type Product struct { TenantBase + PublicID string `gorm:"size:36;uniqueIndex" json:"public_id"` Code string `gorm:"size:50" json:"code"` Barcode string `gorm:"size:100" json:"barcode"` Name string `gorm:"size:200;not null" json:"name"` @@ -20,8 +21,10 @@ type Product struct { PurchasePrice float64 `gorm:"type:decimal(12,2)" json:"purchase_price"` SalePrice float64 `gorm:"type:decimal(12,2)" json:"sale_price"` MinStock int `gorm:"default:0" json:"min_stock"` + Description string `gorm:"type:text" json:"description"` CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` Remark string `gorm:"size:500" json:"remark"` Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"` } diff --git a/backend/internal/model/product_image.go b/backend/internal/model/product_image.go new file mode 100644 index 0000000..fef5f56 --- /dev/null +++ b/backend/internal/model/product_image.go @@ -0,0 +1,12 @@ +package model + +import "time" + +type ProductImage struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + ProductID uint64 `gorm:"not null;index" json:"product_id"` + ShopID uint64 `gorm:"not null" json:"shop_id"` + URL string `gorm:"size:500;not null" json:"url"` + SortOrder int `gorm:"default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/model/product_option.go b/backend/internal/model/product_option.go new file mode 100644 index 0000000..3008008 --- /dev/null +++ b/backend/internal/model/product_option.go @@ -0,0 +1,23 @@ +package model + +type ProductNameOption struct { + TenantBase + Code string `gorm:"size:50" json:"code"` + Name string `gorm:"size:200;not null" json:"name"` + Remark string `gorm:"size:500" json:"remark"` +} + +type ProductSeriesOption struct { + TenantBase + Code string `gorm:"size:50" json:"code"` + Name string `gorm:"size:200;not null" json:"name"` + Remark string `gorm:"size:500" json:"remark"` +} + +type ProductSpecOption struct { + TenantBase + Code string `gorm:"size:50" json:"code"` + Name string `gorm:"size:200;not null" json:"name"` + Quantity int `gorm:"default:0" json:"quantity"` + Remark string `gorm:"size:500" json:"remark"` +} diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go index 31c531e..11f856c 100644 --- a/backend/internal/model/stock.go +++ b/backend/internal/model/stock.go @@ -23,6 +23,7 @@ type StockInOrder struct { Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"` Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"` Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"` + Reviewer *User `gorm:"foreignKey:ReviewerID" json:"reviewer,omitempty"` } type StockInItem struct { @@ -62,6 +63,7 @@ type StockOutOrder struct { Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"` Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"` Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"` + Reviewer *User `gorm:"foreignKey:ReviewerID" json:"reviewer,omitempty"` } type StockOutItem struct { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 83e9fdb..988e47c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -26,8 +26,11 @@ func Setup(r *gin.Engine, db *gorm.DB) { inventoryH := handler.NewInventoryHandler(db) importH := handler.NewImportHandler(db) userH := handler.NewUserHandler(db) + productOptH := handler.NewProductOptionHandler(db) + productImageH := handler.NewProductImageHandler(db) financeH := handler.NewFinanceHandler(db) numberRuleH := handler.NewNumberRuleHandler(db) + publicH := handler.NewPublicHandler(db) // 健康检查(无需认证,用于前端连通性探测) r.GET("/health", func(c *gin.Context) { @@ -46,6 +49,12 @@ func Setup(r *gin.Engine, db *gorm.DB) { auth.POST("/refresh", authH.Refresh) } + // 商品公开详情(无需登录) + public := v1.Group("/public") + { + public.GET("/products/:public_id", publicH.GetProduct) + } + // 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作) api := v1.Group("") api.Use(middleware.JWT(), middleware.ReadOnly()) @@ -64,8 +73,13 @@ func Setup(r *gin.Engine, db *gorm.DB) { { products.GET("", productH.List) products.POST("", productH.Create) + products.POST("/find-or-create", productH.FindOrCreate) + products.GET("/:id/detail", productH.Detail) + products.GET("/:id/qrcode", productH.QRCode) products.PUT("/:id", productH.Update) products.DELETE("/:id", productH.Delete) + products.POST("/:id/images", productImageH.Upload) + products.DELETE("/:id/images/:image_id", productImageH.Delete) } // 仓库 @@ -148,8 +162,29 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 导入 imp := api.Group("/import") { - imp.POST("/products", importH.ImportProducts) - imp.POST("/partners", importH.ImportPartners) + imp.POST("/products", importH.ImportProducts) + imp.POST("/partners", importH.ImportPartners) + imp.POST("/product-names", importH.ImportProductNames) + imp.POST("/product-series", importH.ImportProductSeries) + imp.POST("/product-specs", importH.ImportProductSpecs) + imp.POST("/stock-in", importH.ImportStockIn) + imp.POST("/stock-out", importH.ImportStockOut) + } + + // 基础数据选项(名称/系列/规格) + opts := api.Group("/product-options") + { + opts.GET("/names", productOptH.ListNames) + opts.POST("/names", productOptH.CreateName) + opts.DELETE("/names/:id", productOptH.DeleteName) + + opts.GET("/series", productOptH.ListSeries) + opts.POST("/series", productOptH.CreateSeries) + opts.DELETE("/series/:id", productOptH.DeleteSeries) + + opts.GET("/specs", productOptH.ListSpecs) + opts.POST("/specs", productOptH.CreateSpec) + opts.DELETE("/specs/:id", productOptH.DeleteSpec) } } } diff --git a/backend/main.go b/backend/main.go index 12a3af7..5a3bd19 100644 --- a/backend/main.go +++ b/backend/main.go @@ -21,19 +21,18 @@ func main() { // 初始化数据库 db := initDB() - // 自动迁移(开发环境用,生产使用 golang-migrate) - if config.C.Server.Mode == "debug" { - autoMigrate(db) - } + // 自动迁移(GORM AutoMigrate 只增不删,生产安全) + autoMigrate(db) // 启动 Gin gin.SetMode(config.C.Server.Mode) r := gin.New() r.Use(gin.Logger(), gin.Recovery()) - // CORS(开发期间允许所有来源) + // CORS + corsOrigin := config.C.Server.CORSOrigin r.Use(func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Origin", corsOrigin) c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type") if c.Request.Method == "OPTIONS" { @@ -43,6 +42,9 @@ func main() { c.Next() }) + // Serve uploaded images (in production, Nginx handles /images/) + r.Static("/images", config.C.Storage.UploadDir) + router.Setup(r, db) addr := fmt.Sprintf(":%s", config.C.Server.Port) @@ -95,6 +97,10 @@ func autoMigrate(db *gorm.DB) { &model.InventoryCheckItem{}, &model.FinanceRecord{}, &model.NumberRule{}, + &model.ProductNameOption{}, + &model.ProductSeriesOption{}, + &model.ProductSpecOption{}, + &model.ProductImage{}, ) if err != nil { log.Fatalf("auto migrate failed: %v", err) diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index 4672351..b541ad5 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -102,7 +102,9 @@ CREATE TABLE IF NOT EXISTS `products` ( `brand` VARCHAR(100) DEFAULT NULL COMMENT '品牌', `purchase_price` DECIMAL(16,2) DEFAULT NULL COMMENT '参考进价', `sale_price` DECIMAL(16,2) DEFAULT NULL COMMENT '参考售价', + `public_id` CHAR(36) NOT NULL DEFAULT '' COMMENT 'UUID,公开URL使用', `min_stock` INT DEFAULT 0 COMMENT '库存预警值', + `description` TEXT DEFAULT NULL COMMENT '商品描述', `custom_fields` JSON DEFAULT NULL COMMENT '动态扩展字段', `remark` VARCHAR(500) DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -113,9 +115,25 @@ CREATE TABLE IF NOT EXISTS `products` ( KEY `idx_category` (`category_id`), KEY `idx_deleted_at` (`deleted_at`), UNIQUE KEY `uk_product_code` (`shop_id`, `code`), + UNIQUE KEY `uk_public_id` (`public_id`), FULLTEXT KEY `ft_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品'; +-- ------------------------------------------------------------ +-- 商品图片 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `product_images` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `product_id` BIGINT UNSIGNED NOT NULL, + `shop_id` BIGINT UNSIGNED NOT NULL, + `url` VARCHAR(500) NOT NULL COMMENT '相对路径,如 /images/products/1/xxx.jpg', + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_product_id` (`product_id`), + KEY `idx_shop_id` (`shop_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品图片'; + -- ------------------------------------------------------------ -- 仓库 -- ------------------------------------------------------------ @@ -146,6 +164,7 @@ CREATE TABLE IF NOT EXISTS `partners` ( `phone` VARCHAR(30) DEFAULT NULL, `address` VARCHAR(255) DEFAULT NULL, `bank_account` VARCHAR(100) DEFAULT NULL COMMENT '银行账号', + `status` ENUM('enabled','disabled') NOT NULL DEFAULT 'enabled' COMMENT '正常/禁用', `custom_fields` JSON DEFAULT NULL, `remark` VARCHAR(500) DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -255,7 +274,7 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` ( -- ------------------------------------------------------------ -- 库存(实时) -- ------------------------------------------------------------ -CREATE TABLE IF NOT EXISTS `inventory` ( +CREATE TABLE IF NOT EXISTS `inventories` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `shop_id` BIGINT UNSIGNED NOT NULL, `warehouse_id` BIGINT UNSIGNED NOT NULL, @@ -358,4 +377,50 @@ CREATE TABLE IF NOT EXISTS `number_rules` ( UNIQUE KEY `uk_shop_type` (`shop_id`, `type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单号规则'; +-- ------------------------------------------------------------ +-- 基础数据选项 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `product_name_options` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `shop_id` BIGINT UNSIGNED NOT NULL, + `code` VARCHAR(50) DEFAULT NULL COMMENT '选项编号', + `name` VARCHAR(200) NOT NULL COMMENT '名称', + `remark` VARCHAR(500) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_shop_id` (`shop_id`), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品名称选项'; + +CREATE TABLE IF NOT EXISTS `product_series_options` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `shop_id` BIGINT UNSIGNED NOT NULL, + `code` VARCHAR(50) DEFAULT NULL COMMENT '选项编号', + `name` VARCHAR(200) NOT NULL COMMENT '系列名称', + `remark` VARCHAR(500) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_shop_id` (`shop_id`), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品系列选项'; + +CREATE TABLE IF NOT EXISTS `product_spec_options` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `shop_id` BIGINT UNSIGNED NOT NULL, + `code` VARCHAR(50) DEFAULT NULL COMMENT '选项编号', + `name` VARCHAR(200) NOT NULL COMMENT '规格名称', + `quantity` INT DEFAULT 0 COMMENT '单品数量', + `remark` VARCHAR(500) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_shop_id` (`shop_id`), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品规格选项'; + SET FOREIGN_KEY_CHECKS = 1; diff --git a/backend/seeds/S001.sql b/backend/seeds/S001.sql index 9e5bdb5..c654052 100644 --- a/backend/seeds/S001.sql +++ b/backend/seeds/S001.sql @@ -19,9 +19,13 @@ TRUNCATE TABLE stock_in_items; TRUNCATE TABLE stock_in_orders; TRUNCATE TABLE finance_records; TRUNCATE TABLE number_rules; +TRUNCATE TABLE product_images; TRUNCATE TABLE partners; TRUNCATE TABLE warehouses; TRUNCATE TABLE products; +TRUNCATE TABLE product_spec_options; +TRUNCATE TABLE product_series_options; +TRUNCATE TABLE product_name_options; TRUNCATE TABLE product_categories; TRUNCATE TABLE users; TRUNCATE TABLE shops; @@ -57,16 +61,44 @@ 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, 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 -(1, 1, 'MT-001', '6901234567890', '飞天茅台 53度 500ml', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()), -(2, 1, 'WLY-001', '6902345678901', '五粮液 52度 500ml', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()), -(3, 1, 'YH-001', '6903456789012', '洋河梦之蓝 M6 500ml', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()), -(4, 1, 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()), -(5, 1, 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()), -(6, 1, 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()), -(7, 1, 'LF-001', '3760093550058', '拉菲古堡 2018 750ml', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()), -(8, 1, 'RTM-001', '3021691010008', '人头马 VSOP 700ml', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW()); +(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()); + +-- ── 商品名称选项 ──────────────────────────────────────────── +INSERT INTO product_name_options (shop_id, code, name, created_at, updated_at) VALUES +(1, 'NA001', '飞天茅台 53度', NOW(), NOW()), +(1, 'NA002', '五粮液 52度', NOW(), NOW()), +(1, 'NA003', '洋河梦之蓝 M6', NOW(), NOW()), +(1, 'NA004', '泸州老窖 特曲', NOW(), NOW()), +(1, 'NA005', '剑南春 水晶剑', NOW(), NOW()), +(1, 'NA006', '郎酒 红花郎10', NOW(), NOW()), +(1, 'NA007', '拉菲古堡 2018', NOW(), NOW()), +(1, 'NA008', '人头马 VSOP', NOW(), NOW()); + +-- ── 商品系列选项 ──────────────────────────────────────────── +INSERT INTO product_series_options (shop_id, code, name, created_at, updated_at) VALUES +(1, 'SE001', '茅台', NOW(), NOW()), +(1, 'SE002', '五粮液', NOW(), NOW()), +(1, 'SE003', '洋河', NOW(), NOW()), +(1, 'SE004', '泸州老窖', NOW(), NOW()), +(1, 'SE005', '剑南春', NOW(), NOW()), +(1, 'SE006', '郎酒', NOW(), NOW()), +(1, 'SE007', '波尔多', NOW(), NOW()), +(1, 'SE008', '人头马', NOW(), NOW()); + +-- ── 商品规格选项 ──────────────────────────────────────────── +INSERT INTO product_spec_options (shop_id, code, name, quantity, created_at, updated_at) VALUES +(1, 'GG001', '500ml/瓶', 1, NOW(), NOW()), +(1, 'GG002', '750ml/瓶', 1, NOW(), NOW()), +(1, 'GG003', '700ml/瓶', 1, NOW(), NOW()); -- ── 往来单位 ──────────────────────────────────────────────── -- id=1~4 供应商, id=5~7 客户 diff --git a/backend/seeds/S002.sql b/backend/seeds/S002.sql index b220486..4533956 100644 --- a/backend/seeds/S002.sql +++ b/backend/seeds/S002.sql @@ -18,6 +18,7 @@ TRUNCATE TABLE stock_in_items; TRUNCATE TABLE stock_in_orders; TRUNCATE TABLE finance_records; TRUNCATE TABLE number_rules; +TRUNCATE TABLE product_images; TRUNCATE TABLE partners; TRUNCATE TABLE warehouses; TRUNCATE TABLE products; @@ -50,16 +51,16 @@ INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updat (2, 1, '进口烈酒', 2, NOW(), NOW()); -- ── 商品 ──────────────────────────────────────────────────── -INSERT INTO products (id, shop_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 -(1, 1, 'MT-001', '6901234567890', '飞天茅台 53度 500ml', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()), -(2, 1, 'WLY-001', '6902345678901', '五粮液 52度 500ml', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()), -(3, 1, 'YH-001', '6903456789012', '洋河梦之蓝 M6 500ml', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()), -(4, 1, 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()), -(5, 1, 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()), -(6, 1, 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()), -(7, 1, 'LF-001', '3760093550058', '拉菲古堡 2018 750ml', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()), -(8, 1, 'RTM-001', '3021691010008', '人头马 VSOP 700ml', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW()); +(1, 1, 'b2c3d4e5-0002-0002-0002-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度 500ml', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()), +(2, 1, 'b2c3d4e5-0002-0002-0002-000000000002', 'WLY-001', '6902345678901', '五粮液 52度 500ml', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()), +(3, 1, 'b2c3d4e5-0002-0002-0002-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6 500ml', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()), +(4, 1, 'b2c3d4e5-0002-0002-0002-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()), +(5, 1, 'b2c3d4e5-0002-0002-0002-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()), +(6, 1, 'b2c3d4e5-0002-0002-0002-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()), +(7, 1, 'b2c3d4e5-0002-0002-0002-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018 750ml', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()), +(8, 1, 'b2c3d4e5-0002-0002-0002-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP 700ml', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW()); -- ── 往来单位 ──────────────────────────────────────────────── INSERT INTO partners (id, shop_id, code, name, type, contact, phone, address, bank_account, diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index 9d43190..7e0aa8d 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -111,6 +111,12 @@ class ApiClient { Future get(String path, {Map? params}) => _dio.get(path, queryParameters: params); + Future> getBytes(String path) async { + final resp = await _dio.get>(path, + options: Options(responseType: ResponseType.bytes)); + return resp.data ?? []; + } + Future post(String path, {dynamic data}) => _dio.post(path, data: data); diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 16b39c1..640ecde 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -13,6 +13,8 @@ import '../../screens/inventory/batch_tracking_screen.dart'; import '../../screens/partners/partners_screen.dart'; import '../../screens/finance/finance_screen.dart'; import '../../screens/products/products_screen.dart'; +import '../../screens/products/product_detail_screen.dart'; +import '../../screens/public/public_product_screen.dart'; import '../../screens/settings/settings_screen.dart'; import '../auth/auth_state.dart'; @@ -37,9 +39,10 @@ class _RouterNotifier extends ChangeNotifier { final authState = _ref.read(authStateProvider); final isLoggedIn = authState.isLoggedIn; final isLoginRoute = state.matchedLocation == '/login'; + final isPublicRoute = state.matchedLocation.startsWith('/product/'); final result = !authState.initialized ? null - : (!isLoggedIn && !isLoginRoute) + : (!isLoggedIn && !isLoginRoute && !isPublicRoute) ? '/login' : (isLoggedIn && isLoginRoute) ? '/stock-in' @@ -69,6 +72,12 @@ final appRouterProvider = Provider((ref) { refreshListenable: notifier, redirect: notifier.redirect, routes: [ + // Public route — no auth, no shell nav bar + GoRoute( + path: '/product/:public_id', + builder: (context, state) => + PublicProductScreen(publicId: state.pathParameters['public_id']!), + ), GoRoute( path: '/login', builder: (context, state) => const LoginScreen(), @@ -119,6 +128,10 @@ final appRouterProvider = Provider((ref) { GoRoute( path: '/products', pageBuilder: (_, __) => _noTransition(const ProductsScreen())), + GoRoute( + path: '/products/:id', + pageBuilder: (_, state) => _noTransition(ProductDetailScreen( + productId: int.parse(state.pathParameters['id']!)))), GoRoute( path: '/settings', pageBuilder: (_, __) => _noTransition(const SettingsScreen())), diff --git a/client/lib/core/utils/export_util.dart b/client/lib/core/utils/export_util.dart new file mode 100644 index 0000000..c8d7569 --- /dev/null +++ b/client/lib/core/utils/export_util.dart @@ -0,0 +1,2 @@ +export 'export_util_stub.dart' + if (dart.library.js_interop) 'export_util_web.dart'; diff --git a/client/lib/core/utils/export_util_stub.dart b/client/lib/core/utils/export_util_stub.dart new file mode 100644 index 0000000..dc45caf --- /dev/null +++ b/client/lib/core/utils/export_util_stub.dart @@ -0,0 +1,33 @@ +import 'dart:io'; +import 'package:excel/excel.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +void exportExcel({ + required String filename, + required List headers, + required List> rows, +}) async { + final excel = Excel.createExcel(); + final sheet = excel['Sheet1']; + + sheet.appendRow(headers.map((h) => TextCellValue(h)).toList()); + + for (final row in rows) { + final cells = row.map((v) { + if (v == null) return TextCellValue(''); + if (v is double) return DoubleCellValue(v); + if (v is int) return IntCellValue(v); + return TextCellValue(v.toString()); + }).toList(); + sheet.appendRow(cells); + } + + final bytes = excel.encode(); + if (bytes == null) return; + + final dir = await getDownloadsDirectory() ?? await getTemporaryDirectory(); + final file = File('${dir.path}/$filename.xlsx'); + await file.writeAsBytes(bytes); + await launchUrl(Uri.file(file.path)); +} diff --git a/client/lib/core/utils/export_util_web.dart b/client/lib/core/utils/export_util_web.dart new file mode 100644 index 0000000..47504da --- /dev/null +++ b/client/lib/core/utils/export_util_web.dart @@ -0,0 +1,42 @@ +import 'dart:typed_data'; +import 'dart:js_interop'; +import 'package:excel/excel.dart'; +import 'package:web/web.dart' as web; + +void exportExcel({ + required String filename, + required List headers, + required List> rows, +}) { + final excel = Excel.createExcel(); + final sheet = excel['Sheet1']; + + sheet.appendRow(headers.map((h) => TextCellValue(h)).toList()); + + for (final row in rows) { + final cells = row.map((v) { + if (v == null) return TextCellValue(''); + if (v is double) return DoubleCellValue(v); + if (v is int) return IntCellValue(v); + return TextCellValue(v.toString()); + }).toList(); + sheet.appendRow(cells); + } + + final bytes = excel.encode(); + if (bytes == null) return; + + final uint8Array = Uint8List.fromList(bytes).toJS; + final blob = web.Blob( + [uint8Array].toJS, + web.BlobPropertyBag( + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'), + ); + final url = web.URL.createObjectURL(blob); + final anchor = web.document.createElement('a') as web.HTMLAnchorElement + ..href = url + ..download = '$filename.xlsx' + ..click(); + web.URL.revokeObjectURL(url); + anchor.remove(); +} diff --git a/client/lib/models/partner.dart b/client/lib/models/partner.dart index b1c8c99..9e9f2b6 100644 --- a/client/lib/models/partner.dart +++ b/client/lib/models/partner.dart @@ -7,6 +7,7 @@ class Partner { final String? phone; final String? address; final String? bankAccount; + final String status; final String? remark; const Partner({ @@ -18,6 +19,7 @@ class Partner { this.phone, this.address, this.bankAccount, + this.status = 'enabled', this.remark, }); @@ -30,6 +32,7 @@ class Partner { phone: json['phone'] as String?, address: json['address'] as String?, bankAccount: json['bank_account'] as String?, + status: json['status'] as String? ?? 'enabled', remark: json['remark'] as String?, ); diff --git a/client/lib/models/product.dart b/client/lib/models/product.dart index e443e4b..bab2c1f 100644 --- a/client/lib/models/product.dart +++ b/client/lib/models/product.dart @@ -1,5 +1,8 @@ +import 'product_image.dart'; + class Product { final int id; + final String publicId; final String code; final String? barcode; final String name; @@ -11,11 +14,14 @@ class Product { final double? purchasePrice; final double? salePrice; final int? minStock; + final String? description; final String? remark; final Map? customFields; + final List images; const Product({ required this.id, + this.publicId = '', required this.code, this.barcode, required this.name, @@ -27,12 +33,15 @@ class Product { this.purchasePrice, this.salePrice, this.minStock, + this.description, this.remark, this.customFields, + this.images = const [], }); factory Product.fromJson(Map json) => Product( id: (json['id'] as num).toInt(), + publicId: json['public_id'] as String? ?? '', code: json['code'] as String? ?? '', barcode: json['barcode'] as String?, name: json['name'] as String, @@ -52,8 +61,13 @@ class Product { minStock: json['min_stock'] != null ? (json['min_stock'] as num).toInt() : null, + description: json['description'] as String?, remark: json['remark'] as String?, customFields: json['custom_fields'] as Map?, + images: (json['images'] as List?) + ?.map((e) => ProductImage.fromJson(e as Map)) + .toList() ?? + [], ); Map toJson() => { @@ -68,6 +82,7 @@ class Product { if (purchasePrice != null) 'purchase_price': purchasePrice, if (salePrice != null) 'sale_price': salePrice, if (minStock != null) 'min_stock': minStock, + 'description': description ?? '', if (remark != null) 'remark': remark, }; } diff --git a/client/lib/models/product_image.dart b/client/lib/models/product_image.dart new file mode 100644 index 0000000..e3f3293 --- /dev/null +++ b/client/lib/models/product_image.dart @@ -0,0 +1,20 @@ +class ProductImage { + final int id; + final int productId; + final String url; + final int sortOrder; + + const ProductImage({ + required this.id, + required this.productId, + required this.url, + required this.sortOrder, + }); + + factory ProductImage.fromJson(Map json) => ProductImage( + id: (json['id'] as num).toInt(), + productId: (json['product_id'] as num).toInt(), + url: json['url'] as String, + sortOrder: (json['sort_order'] as num? ?? 0).toInt(), + ); +} diff --git a/client/lib/models/product_option.dart b/client/lib/models/product_option.dart new file mode 100644 index 0000000..43a88ca --- /dev/null +++ b/client/lib/models/product_option.dart @@ -0,0 +1,68 @@ +class ProductNameOption { + final int id; + final String? code; + final String name; + final String? remark; + + const ProductNameOption({ + required this.id, + this.code, + required this.name, + this.remark, + }); + + factory ProductNameOption.fromJson(Map json) => + ProductNameOption( + id: (json['id'] as num).toInt(), + code: json['code'] as String?, + name: json['name'] as String, + remark: json['remark'] as String?, + ); +} + +class ProductSeriesOption { + final int id; + final String? code; + final String name; + final String? remark; + + const ProductSeriesOption({ + required this.id, + this.code, + required this.name, + this.remark, + }); + + factory ProductSeriesOption.fromJson(Map json) => + ProductSeriesOption( + id: (json['id'] as num).toInt(), + code: json['code'] as String?, + name: json['name'] as String, + remark: json['remark'] as String?, + ); +} + +class ProductSpecOption { + final int id; + final String? code; + final String name; + final int quantity; + final String? remark; + + const ProductSpecOption({ + required this.id, + this.code, + required this.name, + this.quantity = 0, + this.remark, + }); + + factory ProductSpecOption.fromJson(Map json) => + ProductSpecOption( + id: (json['id'] as num).toInt(), + code: json['code'] as String?, + name: json['name'] as String, + quantity: (json['quantity'] as num?)?.toInt() ?? 0, + remark: json['remark'] as String?, + ); +} diff --git a/client/lib/models/stock_in.dart b/client/lib/models/stock_in.dart index fa64ec2..6092a09 100644 --- a/client/lib/models/stock_in.dart +++ b/client/lib/models/stock_in.dart @@ -8,6 +8,7 @@ class StockInItem { // Denormalized for display final String? productName; final String? productCode; + final String? productSeries; final String? productSpec; final String? productUnit; @@ -20,6 +21,7 @@ class StockInItem { this.batchNo, this.productName, this.productCode, + this.productSeries, this.productSpec, this.productUnit, }); @@ -35,6 +37,7 @@ class StockInItem { batchNo: json['batch_no'] as String?, productName: (json['product'] as Map?)?['name'] as String?, productCode: (json['product'] as Map?)?['code'] as String?, + productSeries: (json['product'] as Map?)?['series'] as String?, productSpec: (json['product'] as Map?)?['spec'] as String?, productUnit: (json['product'] as Map?)?['unit'] as String?, ); @@ -57,6 +60,9 @@ class StockInOrder { final int? partnerId; final String? partnerName; final int? operatorId; + final String? operatorName; + final int? reviewerId; + final String? reviewerName; final String status; // draft | pending | approved | rejected final String? orderDate; final double? totalAmount; @@ -72,6 +78,9 @@ class StockInOrder { this.partnerId, this.partnerName, this.operatorId, + this.operatorName, + this.reviewerId, + this.reviewerName, required this.status, this.orderDate, this.totalAmount, @@ -92,6 +101,11 @@ class StockInOrder { operatorId: json['operator_id'] != null ? (json['operator_id'] as num).toInt() : null, + operatorName: (json['operator'] as Map?)?['real_name'] as String?, + reviewerId: json['reviewer_id'] != null + ? (json['reviewer_id'] as num).toInt() + : null, + reviewerName: (json['reviewer'] as Map?)?['real_name'] as String?, status: json['status'] as String, orderDate: json['order_date'] as String?, totalAmount: json['total_amount'] != null diff --git a/client/lib/models/stock_out.dart b/client/lib/models/stock_out.dart index 6218b7f..d5daf18 100644 --- a/client/lib/models/stock_out.dart +++ b/client/lib/models/stock_out.dart @@ -6,6 +6,7 @@ class StockOutItem { final double totalPrice; final String? productName; final String? productCode; + final String? productSeries; final String? productSpec; final String? productUnit; @@ -17,6 +18,7 @@ class StockOutItem { required this.totalPrice, this.productName, this.productCode, + this.productSeries, this.productSpec, this.productUnit, }); @@ -31,6 +33,7 @@ class StockOutItem { totalPrice: (json['total_price'] as num).toDouble(), productName: (json['product'] as Map?)?['name'] as String?, productCode: (json['product'] as Map?)?['code'] as String?, + productSeries: (json['product'] as Map?)?['series'] as String?, productSpec: (json['product'] as Map?)?['spec'] as String?, productUnit: (json['product'] as Map?)?['unit'] as String?, ); @@ -45,6 +48,9 @@ class StockOutOrder { final int? partnerId; final String? partnerName; final int? operatorId; + final String? operatorName; + final int? reviewerId; + final String? reviewerName; final String status; // draft | pending | approved | rejected final String? orderDate; final double? totalAmount; @@ -60,6 +66,9 @@ class StockOutOrder { this.partnerId, this.partnerName, this.operatorId, + this.operatorName, + this.reviewerId, + this.reviewerName, required this.status, this.orderDate, this.totalAmount, @@ -80,6 +89,11 @@ class StockOutOrder { operatorId: json['operator_id'] != null ? (json['operator_id'] as num).toInt() : null, + operatorName: (json['operator'] as Map?)?['real_name'] as String?, + reviewerId: json['reviewer_id'] != null + ? (json['reviewer_id'] as num).toInt() + : null, + reviewerName: (json['reviewer'] as Map?)?['real_name'] as String?, status: json['status'] as String, orderDate: json['order_date'] as String?, totalAmount: json['total_amount'] != null diff --git a/client/lib/providers/finance_provider.dart b/client/lib/providers/finance_provider.dart index cbf824e..afe1a21 100644 --- a/client/lib/providers/finance_provider.dart +++ b/client/lib/providers/finance_provider.dart @@ -16,6 +16,7 @@ final financeListProvider = class FinanceListNotifier extends AsyncNotifier> { int _page = 1; + int _pageSize = 20; String _type = ''; String _month = ''; @@ -30,6 +31,7 @@ class FinanceListNotifier extends AsyncNotifier> { type: _type.isEmpty ? null : _type, month: _month.isEmpty ? null : _month, page: _page, + pageSize: _pageSize, ); } @@ -50,6 +52,12 @@ class FinanceListNotifier extends AsyncNotifier> { reload(); } + void setPageSize(int pageSize) { + _pageSize = pageSize; + _page = 1; + reload(); + } + void reload() { state = const AsyncValue.loading(); _fetch().then( diff --git a/client/lib/providers/inventory_provider.dart b/client/lib/providers/inventory_provider.dart index 4cad29e..a34a1ad 100644 --- a/client/lib/providers/inventory_provider.dart +++ b/client/lib/providers/inventory_provider.dart @@ -17,6 +17,7 @@ final inventoryListProvider = class InventoryListNotifier extends AsyncNotifier> { int _page = 1; + int _pageSize = 20; int? _warehouseId; String _keyword = ''; PageResult? _cache; @@ -40,7 +41,7 @@ class InventoryListNotifier extends AsyncNotifier> { warehouseId: _warehouseId, keyword: _keyword.isEmpty ? null : _keyword, page: _page, - pageSize: 50, + pageSize: _pageSize, ); } @@ -49,6 +50,12 @@ class InventoryListNotifier extends AsyncNotifier> { reload(); } + void setPageSize(int pageSize) { + _pageSize = pageSize; + _page = 1; + reload(); + } + void setWarehouseId(int? id) { _warehouseId = id; _page = 1; @@ -83,6 +90,7 @@ final inventoryLogProvider = class InventoryLogNotifier extends AsyncNotifier> { int _page = 1; + int _pageSize = 20; PageResult? _cache; @override @@ -102,7 +110,7 @@ class InventoryLogNotifier extends AsyncNotifier> { Future> _fetch() { return ref.read(inventoryRepositoryProvider).listLogs( page: _page, - pageSize: 50, + pageSize: _pageSize, ); } @@ -111,6 +119,12 @@ class InventoryLogNotifier extends AsyncNotifier> { reload(); } + void setPageSize(int pageSize) { + _pageSize = pageSize; + _page = 1; + reload(); + } + void reload() { state = const AsyncValue.loading(); _fetch().then((result) { diff --git a/client/lib/providers/partner_provider.dart b/client/lib/providers/partner_provider.dart index deae414..c3d1964 100644 --- a/client/lib/providers/partner_provider.dart +++ b/client/lib/providers/partner_provider.dart @@ -25,6 +25,7 @@ final customerListProvider = class PartnerListNotifier extends AsyncNotifier> { final String? type; int _page = 1; + int _pageSize = 20; String _keyword = ''; PageResult? _cache; @@ -49,6 +50,7 @@ class PartnerListNotifier extends AsyncNotifier> { type: type, keyword: _keyword.isEmpty ? null : _keyword, page: _page, + pageSize: _pageSize, ); } @@ -57,6 +59,12 @@ class PartnerListNotifier extends AsyncNotifier> { reload(); } + void setPageSize(int pageSize) { + _pageSize = pageSize; + _page = 1; + reload(); + } + void setKeyword(String keyword) { _keyword = keyword; _page = 1; diff --git a/client/lib/providers/product_option_provider.dart b/client/lib/providers/product_option_provider.dart new file mode 100644 index 0000000..69cda13 --- /dev/null +++ b/client/lib/providers/product_option_provider.dart @@ -0,0 +1,112 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../models/product_option.dart'; +import '../providers/connectivity_provider.dart'; +import '../repositories/product_option_repository.dart'; + +final productOptionRepositoryProvider = Provider((ref) { + return ProductOptionRepository(ref.watch(apiClientProvider)); +}); + +// ── 商品名称 ───────────────────────────────────────────────── + +final productNameListProvider = + AsyncNotifierProvider>( + ProductNameListNotifier.new, +); + +class ProductNameListNotifier extends AsyncNotifier> { + @override + Future> build() async { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + ref.watch(networkRecoveryCountProvider); + return ref.read(productOptionRepositoryProvider).listNames(); + } + + void reload() { + state = const AsyncValue.loading(); + ref.read(productOptionRepositoryProvider).listNames().then( + (data) => state = AsyncValue.data(data), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } + + Future create(Map data) async { + await ref.read(productOptionRepositoryProvider).createName(data); + reload(); + } + + Future delete(int id) async { + await ref.read(productOptionRepositoryProvider).deleteName(id); + reload(); + } +} + +// ── 商品系列 ───────────────────────────────────────────────── + +final productSeriesListProvider = + AsyncNotifierProvider>( + ProductSeriesListNotifier.new, +); + +class ProductSeriesListNotifier extends AsyncNotifier> { + @override + Future> build() async { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + ref.watch(networkRecoveryCountProvider); + return ref.read(productOptionRepositoryProvider).listSeries(); + } + + void reload() { + state = const AsyncValue.loading(); + ref.read(productOptionRepositoryProvider).listSeries().then( + (data) => state = AsyncValue.data(data), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } + + Future create(Map data) async { + await ref.read(productOptionRepositoryProvider).createSeries(data); + reload(); + } + + Future delete(int id) async { + await ref.read(productOptionRepositoryProvider).deleteSeries(id); + reload(); + } +} + +// ── 商品规格 ───────────────────────────────────────────────── + +final productSpecListProvider = + AsyncNotifierProvider>( + ProductSpecListNotifier.new, +); + +class ProductSpecListNotifier extends AsyncNotifier> { + @override + Future> build() async { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + ref.watch(networkRecoveryCountProvider); + return ref.read(productOptionRepositoryProvider).listSpecs(); + } + + void reload() { + state = const AsyncValue.loading(); + ref.read(productOptionRepositoryProvider).listSpecs().then( + (data) => state = AsyncValue.data(data), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } + + Future create(Map data) async { + await ref.read(productOptionRepositoryProvider).createSpec(data); + reload(); + } + + Future delete(int id) async { + await ref.read(productOptionRepositoryProvider).deleteSpec(id); + reload(); + } +} diff --git a/client/lib/providers/product_provider.dart b/client/lib/providers/product_provider.dart index c37498c..36c3005 100644 --- a/client/lib/providers/product_provider.dart +++ b/client/lib/providers/product_provider.dart @@ -17,6 +17,7 @@ final productListProvider = class ProductListNotifier extends AsyncNotifier> { int _page = 1; + int _pageSize = 20; String _keyword = ''; int? _categoryId; PageResult? _cache; @@ -39,7 +40,7 @@ class ProductListNotifier extends AsyncNotifier> { final repo = ref.read(productRepositoryProvider); return repo.list( page: _page, - pageSize: 20, + pageSize: _pageSize, keyword: _keyword.isEmpty ? null : _keyword, categoryId: _categoryId, ); @@ -50,6 +51,12 @@ class ProductListNotifier extends AsyncNotifier> { reload(); } + void setPageSize(int pageSize) { + _pageSize = pageSize; + _page = 1; + reload(); + } + void setKeyword(String keyword) { _keyword = keyword; _page = 1; diff --git a/client/lib/providers/stock_in_provider.dart b/client/lib/providers/stock_in_provider.dart index 9c3dafc..5b944a6 100644 --- a/client/lib/providers/stock_in_provider.dart +++ b/client/lib/providers/stock_in_provider.dart @@ -18,6 +18,7 @@ final stockInListProvider = class StockInListNotifier extends AsyncNotifier> { int _page = 1; + int _pageSize = 20; String _status = ''; String? _startDate; String? _endDate; @@ -43,6 +44,7 @@ class StockInListNotifier extends AsyncNotifier> { startDate: _startDate, endDate: _endDate, page: _page, + pageSize: _pageSize, ); } @@ -51,6 +53,12 @@ class StockInListNotifier extends AsyncNotifier> { reload(); } + void setPageSize(int pageSize) { + _pageSize = pageSize; + _page = 1; + reload(); + } + void setStatus(String status) { _status = status; _page = 1; diff --git a/client/lib/providers/stock_out_provider.dart b/client/lib/providers/stock_out_provider.dart index e5d0716..fb634a9 100644 --- a/client/lib/providers/stock_out_provider.dart +++ b/client/lib/providers/stock_out_provider.dart @@ -18,6 +18,7 @@ final stockOutListProvider = class StockOutListNotifier extends AsyncNotifier> { int _page = 1; + int _pageSize = 20; String _status = ''; String? _startDate; String? _endDate; @@ -43,6 +44,7 @@ class StockOutListNotifier extends AsyncNotifier> { startDate: _startDate, endDate: _endDate, page: _page, + pageSize: _pageSize, ); } @@ -51,6 +53,12 @@ class StockOutListNotifier extends AsyncNotifier> { reload(); } + void setPageSize(int pageSize) { + _pageSize = pageSize; + _page = 1; + reload(); + } + void setStatus(String status) { _status = status; _page = 1; diff --git a/client/lib/repositories/product_option_repository.dart b/client/lib/repositories/product_option_repository.dart new file mode 100644 index 0000000..5806583 --- /dev/null +++ b/client/lib/repositories/product_option_repository.dart @@ -0,0 +1,103 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../models/product_option.dart'; + +class ProductOptionRepository { + final ApiClient _client; + + const ProductOptionRepository(this._client); + + // ── 名称 ──────────────────────────────────────────────────── + + Future> listNames() async { + try { + final resp = await _client.get('/product-options/names'); + final data = (resp.data as Map)['data'] as List? ?? []; + return data.map((e) => ProductNameOption.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '获取名称列表失败', + statusCode: e.response?.statusCode); + } + } + + Future createName(Map data) async { + try { + await _client.post('/product-options/names', data: data); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '创建失败', + statusCode: e.response?.statusCode); + } + } + + Future deleteName(int id) async { + try { + await _client.delete('/product-options/names/$id'); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '删除失败', + statusCode: e.response?.statusCode); + } + } + + // ── 系列 ──────────────────────────────────────────────────── + + Future> listSeries() async { + try { + final resp = await _client.get('/product-options/series'); + final data = (resp.data as Map)['data'] as List? ?? []; + return data.map((e) => ProductSeriesOption.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '获取系列列表失败', + statusCode: e.response?.statusCode); + } + } + + Future createSeries(Map data) async { + try { + await _client.post('/product-options/series', data: data); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '创建失败', + statusCode: e.response?.statusCode); + } + } + + Future deleteSeries(int id) async { + try { + await _client.delete('/product-options/series/$id'); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '删除失败', + statusCode: e.response?.statusCode); + } + } + + // ── 规格 ──────────────────────────────────────────────────── + + Future> listSpecs() async { + try { + final resp = await _client.get('/product-options/specs'); + final data = (resp.data as Map)['data'] as List? ?? []; + return data.map((e) => ProductSpecOption.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '获取规格列表失败', + statusCode: e.response?.statusCode); + } + } + + Future createSpec(Map data) async { + try { + await _client.post('/product-options/specs', data: data); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '创建失败', + statusCode: e.response?.statusCode); + } + } + + Future deleteSpec(int id) async { + try { + await _client.delete('/product-options/specs/$id'); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '删除失败', + statusCode: e.response?.statusCode); + } + } +} diff --git a/client/lib/repositories/product_repository.dart b/client/lib/repositories/product_repository.dart index 6685fa3..1a30628 100644 --- a/client/lib/repositories/product_repository.dart +++ b/client/lib/repositories/product_repository.dart @@ -1,8 +1,10 @@ +import 'dart:typed_data'; import 'package:dio/dio.dart'; import '../core/api/api_client.dart'; import '../core/exceptions.dart'; import '../core/models/page_result.dart'; import '../models/product.dart'; +import '../models/product_image.dart'; class ProductRepository { final ApiClient _client; @@ -71,4 +73,77 @@ class ProductRepository { ); } } + + Future getDetail(int id) async { + try { + final resp = await _client.get('/products/$id/detail'); + return Product.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取商品详情失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future uploadImage(int productId, String filePath) async { + try { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath), + }); + final resp = await _client.post('/products/$productId/images', data: formData); + return ProductImage.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '上传图片失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future getQRCodeBytes(int productId) async { + try { + final bytes = await _client.getBytes('/products/$productId/qrcode'); + return Uint8List.fromList(bytes); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取二维码失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future deleteImage(int productId, int imageId) async { + try { + await _client.delete('/products/$productId/images/$imageId'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '删除图片失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future findOrCreate({ + required String name, + String series = '', + String spec = '', + }) async { + try { + final resp = await _client.post('/products/find-or-create', data: { + 'name': name, + 'series': series, + 'spec': spec, + }); + return Product.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '查找/创建商品失败', + statusCode: e.response?.statusCode, + ); + } + } } diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 6b1bb57..6b58fa5 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -7,6 +7,7 @@ import '../../widgets/data_table_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../providers/connectivity_provider.dart'; +import '../../core/utils/export_util.dart'; class FinanceScreen extends ConsumerWidget { const FinanceScreen({super.key}); @@ -41,10 +42,12 @@ class _FinanceTab extends ConsumerStatefulWidget { class _FinanceTabState extends ConsumerState<_FinanceTab> { late String _month; int _page = 1; + int _pageSize = 20; // We drive fetches by maintaining a Future locally, bypassing the global provider late Future> _future; List _allRecords = []; + int _total = 0; Set _filterType = {}; Set _filterPartner = {}; @@ -75,10 +78,11 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { type: widget.typeFilter.isEmpty ? null : widget.typeFilter, month: _month, page: _page, - pageSize: 50, + pageSize: _pageSize, ) .then((r) { _allRecords = r.data; + _total = r.total; return r.data; }); } @@ -294,16 +298,50 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { const Divider(height: 1), Expanded( child: DataTableCard( - totalCount: records.length, + totalCount: _total > 0 ? _total : records.length, page: _page, + pageSize: _pageSize, onPageChanged: (p) { setState(() { _page = p; _fetch(); }); }, + onPageSizeChanged: (s) { + setState(() { + _pageSize = s; + _page = 1; + _fetch(); + }); + }, toolbar: Row( children: [ + OutlinedButton.icon( + onPressed: () { + final tabName = widget.typeFilter.isEmpty + ? '财务全部记录' + : widget.typeFilter == 'payable' + ? '应付账款' + : '应收账款'; + exportExcel( + filename: tabName, + headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '备注'], + rows: records.map((r) => [ + r.recordDate?.substring(0, 10) ?? '', + r.typeLabel, + r.partnerName ?? '', + r.refType != null && r.refId != null + ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' + : '', + r.amount, + r.balance, + r.remark ?? '', + ]).toList(), + ); + }, + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), const Spacer(), _MonthSelector( value: _month, diff --git a/client/lib/screens/inventory/batch_tracking_screen.dart b/client/lib/screens/inventory/batch_tracking_screen.dart index b9eabb7..c02fc26 100644 --- a/client/lib/screens/inventory/batch_tracking_screen.dart +++ b/client/lib/screens/inventory/batch_tracking_screen.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../models/inventory.dart'; import '../../providers/inventory_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../providers/connectivity_provider.dart'; +import '../../core/utils/export_util.dart'; class BatchTrackingScreen extends ConsumerStatefulWidget { const BatchTrackingScreen({super.key}); @@ -17,6 +19,7 @@ class BatchTrackingScreen extends ConsumerStatefulWidget { class _BatchTrackingScreenState extends ConsumerState { int _page = 1; + int _pageSize = 20; int _total = 0; late Future> _future; List _records = []; @@ -49,7 +52,7 @@ class _BatchTrackingScreenState extends ConsumerState { void _fetch() { _future = ref .read(inventoryRepositoryProvider) - .listProducts(page: _page, pageSize: 20) + .listProducts(page: _page, pageSize: _pageSize) .then((r) { _total = r.total; _records = r.data; @@ -189,15 +192,44 @@ class _BatchTrackingScreenState extends ConsumerState { return DataTableCard( totalCount: _total, page: _page, + pageSize: _pageSize, onPageChanged: (p) => setState(() { _page = p; _fetch(); }), + onPageSizeChanged: (s) => setState(() { + _pageSize = s; + _page = 1; + _fetch(); + }), toolbar: Row( children: [ const Text('已审核入库商品(含库存与销售状态)', style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)), const Spacer(), + OutlinedButton.icon( + onPressed: () => exportExcel( + filename: '商品管理', + headers: ['商品名称', '商品编码', '规格', '批次号', '入库单号', '供应商', '仓库', '入库日期', '数量', '单价', '状态', '买家'], + rows: records.map((r) => [ + r.productName ?? '', + r.productCode ?? '', + r.productSpec ?? '', + r.batchNo ?? '', + r.orderNo ?? '', + r.supplierName ?? '', + r.warehouseName ?? '', + r.orderDate?.substring(0, 10) ?? '', + r.quantity.toInt(), + r.unitPrice, + r.isSoldOut ? '已卖出' : '在售', + r.buyerName ?? '', + ]).toList(), + ), + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.refresh, size: 18), onPressed: () => setState(() { @@ -224,21 +256,30 @@ class _BatchTrackingScreenState extends ConsumerState { (r.batchNo != null && r.batchNo!.isNotEmpty) ? r.batchNo! : null; switch (key) { case 'product': - return DataCell(Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(r.productName ?? '-', - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w500)), - if (r.productCode != null) - Text(r.productCode!, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textSecondary, - fontFamily: 'monospace')), - ], - )); + return DataCell( + GestureDetector( + onTap: () => context.push('/products/${r.productId}'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(r.productName ?? '-', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppTheme.primary, + decoration: TextDecoration.underline, + decorationColor: AppTheme.primary)), + if (r.productCode != null) + Text(r.productCode!, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + fontFamily: 'monospace')), + ], + ), + ), + ); case 'spec': return DataCell(Text(r.productSpec ?? '-', style: const TextStyle(fontSize: 12))); diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index 8529f13..b81763d 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -8,6 +8,7 @@ import '../../providers/inventory_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; +import '../../core/utils/export_util.dart'; class InventoryListScreen extends ConsumerStatefulWidget { const InventoryListScreen({super.key}); @@ -128,10 +129,13 @@ class _InventoryListScreenState extends ConsumerState { const Divider(height: 1), Expanded( child: DataTableCard( - totalCount: filteredItems.length, + totalCount: result.total, page: result.page, + pageSize: result.pageSize, onPageChanged: (p) => ref.read(inventoryListProvider.notifier).setPage(p), + onPageSizeChanged: (s) => + ref.read(inventoryListProvider.notifier).setPageSize(s), toolbar: Row( children: [ OutlinedButton.icon( @@ -139,6 +143,32 @@ class _InventoryListScreenState extends ConsumerState { icon: const Icon(Icons.fact_check, size: 16), label: const Text('发起盘点'), ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => exportExcel( + filename: '库存查询', + headers: ['商品编码', '商品名称', '品牌', '规格', '仓库', '库存', '安全库存', '状态'], + rows: filteredItems.map((item) { + final status = item.quantity == 0 + ? '缺货' + : (item.minStock != null && item.quantity < item.minStock!) + ? '库存不足' + : '正常'; + return [ + item.productCode ?? '', + item.productName ?? '', + item.productBrand ?? '', + item.productSpec ?? '', + item.warehouseName ?? '', + item.quantity.toInt(), + item.minStock ?? '', + status, + ]; + }).toList(), + ), + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), const Spacer(), SizedBox( width: 200, @@ -284,6 +314,24 @@ class _InventoryListScreenState extends ConsumerState { style: const TextStyle( fontSize: 13, color: AppTheme.accent), ), + const Spacer(), + OutlinedButton.icon( + onPressed: () => exportExcel( + filename: '库存预警', + headers: ['商品编码', '商品名称', '仓库', '当前库存', '安全库存', '缺口', '状态'], + rows: warnings.map((item) => [ + item.productCode ?? '', + item.productName ?? '', + item.warehouseName ?? '', + item.quantity.toInt(), + item.minStock ?? 0, + item.minStock! - item.quantity.toInt(), + item.quantity == 0 ? '缺货' : '库存不足', + ]).toList(), + ), + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), ], ), columns: const [ @@ -377,15 +425,37 @@ class _InventoryListScreenState extends ConsumerState { return DataTableCard( totalCount: result.total, page: result.page, + pageSize: result.pageSize, onPageChanged: (p) => ref.read(inventoryLogProvider.notifier).setPage(p), - toolbar: const Row( + onPageSizeChanged: (s) => + ref.read(inventoryLogProvider.notifier).setPageSize(s), + toolbar: Row( children: [ - Icon(Icons.history, color: AppTheme.primary, size: 18), - SizedBox(width: 8), - Text('库存流水记录', + const Icon(Icons.history, color: AppTheme.primary, size: 18), + const SizedBox(width: 8), + const Text('库存流水记录', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500)), + const Spacer(), + OutlinedButton.icon( + onPressed: () => exportExcel( + filename: '库存流水', + headers: ['商品名称', '仓库', '方向', '数量', '变前', '变后', '来源', '时间'], + rows: logs.map((log) => [ + log.productName ?? '', + log.warehouseName ?? '', + log.direction == 'in' ? '入库' : '出库', + log.quantity.toInt(), + log.qtyBefore?.toInt() ?? 0, + log.qtyAfter?.toInt() ?? 0, + log.refType ?? '', + log.createdAt?.substring(0, 19) ?? '', + ]).toList(), + ), + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), ], ), columns: const [ diff --git a/client/lib/screens/partners/partners_screen.dart b/client/lib/screens/partners/partners_screen.dart index 62f04ad..ca54d95 100644 --- a/client/lib/screens/partners/partners_screen.dart +++ b/client/lib/screens/partners/partners_screen.dart @@ -7,6 +7,7 @@ import '../../models/partner.dart'; import '../../providers/partner_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/page_scaffold.dart'; +import '../../core/utils/export_util.dart'; class PartnersScreen extends ConsumerStatefulWidget { const PartnersScreen({super.key}); @@ -78,6 +79,8 @@ class _PartnersScreenState extends ConsumerState { }, onPageChanged: (p) => ref.read(supplierListProvider.notifier).setPage(p), + onPageSizeChanged: (s) => + ref.read(supplierListProvider.notifier).setPageSize(s), onAdd: () => _showPartnerDialog(context, isSupplier: true), onEdit: (p) => _showPartnerDialog(context, isSupplier: true, partner: p), @@ -119,6 +122,8 @@ class _PartnersScreenState extends ConsumerState { }, onPageChanged: (p) => ref.read(customerListProvider.notifier).setPage(p), + onPageSizeChanged: (s) => + ref.read(customerListProvider.notifier).setPageSize(s), onAdd: () => _showPartnerDialog(context, isSupplier: false), onEdit: (p) => _showPartnerDialog(context, isSupplier: false, partner: p), @@ -133,6 +138,7 @@ class _PartnersScreenState extends ConsumerState { required TextEditingController searchCtrl, required void Function(String) onSearchChanged, required void Function(int) onPageChanged, + required void Function(int) onPageSizeChanged, required VoidCallback onAdd, required void Function(Partner) onEdit, required void Function(Partner) onDelete, @@ -141,7 +147,9 @@ class _PartnersScreenState extends ConsumerState { return DataTableCard( totalCount: result.total, page: result.page, + pageSize: result.pageSize, onPageChanged: onPageChanged, + onPageSizeChanged: onPageSizeChanged, toolbar: Row( children: [ ElevatedButton.icon( @@ -149,6 +157,19 @@ class _PartnersScreenState extends ConsumerState { icon: const Icon(Icons.add, size: 16), label: Text(isSupplier ? '新建' : '新建'), ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => exportExcel( + filename: isSupplier ? '供应商列表' : '客户列表', + headers: ['编码', '名称', '联系人', '电话', '地址', '备注'], + rows: partners.map((p) => [ + p.code ?? '', p.name, p.contact ?? '', + p.phone ?? '', p.address ?? '', p.remark ?? '', + ]).toList(), + ), + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), const Spacer(), SizedBox( width: 200, @@ -170,6 +191,7 @@ class _PartnersScreenState extends ConsumerState { const DataColumn(label: Text('联系人')), const DataColumn(label: Text('联系电话')), const DataColumn(label: Text('地址')), + const DataColumn(label: Text('状态')), const DataColumn(label: Text('操作')), ], rows: partners.isEmpty @@ -183,6 +205,7 @@ class _PartnersScreenState extends ConsumerState { const DataCell(SizedBox()), const DataCell(SizedBox()), const DataCell(SizedBox()), + const DataCell(SizedBox()), ]) ] : partners @@ -207,6 +230,15 @@ class _PartnersScreenState extends ConsumerState { child: Text(p.address ?? '-', overflow: TextOverflow.ellipsis), )), + DataCell(Text( + p.status == 'enabled' ? '正常' : '禁用', + style: TextStyle( + fontSize: 13, + color: p.status == 'enabled' + ? AppTheme.success + : AppTheme.textSecondary, + ), + )), DataCell(Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/client/lib/screens/products/product_detail_screen.dart b/client/lib/screens/products/product_detail_screen.dart new file mode 100644 index 0000000..f3ff311 --- /dev/null +++ b/client/lib/screens/products/product_detail_screen.dart @@ -0,0 +1,541 @@ +import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/config/app_config.dart'; +import '../../core/theme/app_theme.dart'; +import '../../models/product.dart'; +import '../../models/product_image.dart'; +import '../../providers/product_provider.dart'; + +class ProductDetailScreen extends ConsumerStatefulWidget { + final int productId; + const ProductDetailScreen({super.key, required this.productId}); + + @override + ConsumerState createState() => + _ProductDetailScreenState(); +} + +class _ProductDetailScreenState extends ConsumerState { + late Future _future; + Product? _product; + bool _uploading = false; + bool _savingDesc = false; + bool _descChanged = false; + + late TextEditingController _descCtrl; + + @override + void initState() { + super.initState(); + _descCtrl = TextEditingController(); + _descCtrl.addListener(() { + final changed = _descCtrl.text != (_product?.description ?? ''); + if (changed != _descChanged) setState(() => _descChanged = changed); + }); + _load(); + } + + @override + void dispose() { + _descCtrl.dispose(); + super.dispose(); + } + + void _load() { + _future = ref + .read(productRepositoryProvider) + .getDetail(widget.productId) + .then((p) { + _product = p; + _descCtrl.text = p.description ?? ''; + _descChanged = false; + return p; + }); + } + + void _reload() => setState(() => _load()); + + Future _saveDesc() async { + if (_product == null) return; + setState(() => _savingDesc = true); + try { + final updated = await ref.read(productRepositoryProvider).update( + _product!.id, + {..._product!.toJson(), 'description': _descCtrl.text.trim()}, + ); + setState(() { + _product = updated; + _descCtrl.text = updated.description ?? ''; + _descChanged = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('描述已保存')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('保存失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _savingDesc = false); + } + } + + Future _pickAndUpload() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + final path = result.files.first.path; + if (path == null) return; + + setState(() => _uploading = true); + try { + final img = await ref + .read(productRepositoryProvider) + .uploadImage(_product!.id, path); + _updateImages([..._product!.images, img]); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('图片上传成功')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('上传失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + Future _deleteImage(ProductImage img) async { + try { + await ref.read(productRepositoryProvider).deleteImage(_product!.id, img.id); + _updateImages(_product!.images.where((i) => i.id != img.id).toList()); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('删除失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } + } + + void _updateImages(List images) { + final p = _product!; + setState(() { + _product = Product( + id: p.id, publicId: p.publicId, code: p.code, barcode: p.barcode, + name: p.name, series: p.series, spec: p.spec, unit: p.unit, + categoryId: p.categoryId, brand: p.brand, purchasePrice: p.purchasePrice, + salePrice: p.salePrice, minStock: p.minStock, description: p.description, + remark: p.remark, customFields: p.customFields, images: images, + ); + }); + } + + void _showQRCode() { + showDialog( + context: context, + builder: (_) => _QRCodeDialog(productId: widget.productId), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting && _product == null) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError && _product == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 40, color: AppTheme.danger), + const SizedBox(height: 12), + Text('加载失败:${snap.error}'), + const SizedBox(height: 12), + ElevatedButton(onPressed: _reload, child: const Text('重试')), + ], + ), + ); + } + final p = _product!; + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + tooltip: '返回', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 8), + Expanded( + child: Text(p.name, + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.w700)), + ), + OutlinedButton.icon( + onPressed: _showQRCode, + icon: const Icon(Icons.qr_code, size: 16), + label: const Text('二维码'), + ), + ], + ), + const SizedBox(height: 20), + _buildImageSection(p), + const SizedBox(height: 20), + _buildInfoSection(p), + const SizedBox(height: 20), + _buildDescSection(), + const SizedBox(height: 20), + _buildPublicLinkSection(p), + ], + ), + ); + }, + ), + ); + } + + Widget _buildImageSection(Product p) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('图片', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textSecondary)), + const SizedBox(height: 8), + SizedBox( + height: 120, + child: Row( + children: [ + ...p.images.map((img) => _ImageThumbnail( + url: AppConfig.baseUrl + img.url, + onDelete: () => _deleteImage(img), + )), + if (p.images.length < 5) + _UploadButton(uploading: _uploading, onTap: _pickAndUpload), + ], + ), + ), + if (p.images.isEmpty && !_uploading) + const Padding( + padding: EdgeInsets.only(top: 4), + child: Text('暂无图片,点击 + 上传', + style: TextStyle(fontSize: 12, color: AppTheme.textSecondary)), + ), + ], + ); + } + + Widget _buildInfoSection(Product p) { + Widget row(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 56, + child: Text(label, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary))), + const SizedBox(width: 8), + Expanded( + child: Text(value, style: const TextStyle(fontSize: 13))), + ], + ), + ); + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide(color: AppTheme.border, width: 0.5), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 32, + runSpacing: 0, + children: [ + SizedBox(width: 200, child: row('编号', p.code.isEmpty ? '-' : p.code)), + SizedBox(width: 200, child: row('系列', p.series?.isEmpty ?? true ? '-' : p.series!)), + SizedBox(width: 200, child: row('规格', p.spec?.isEmpty ?? true ? '-' : p.spec!)), + SizedBox(width: 200, child: row('品牌', p.brand?.isEmpty ?? true ? '-' : p.brand!)), + SizedBox(width: 200, child: row('单位', p.unit.isEmpty ? '-' : p.unit)), + SizedBox(width: 200, child: row('进价', p.purchasePrice != null ? '¥${p.purchasePrice!.toStringAsFixed(2)}' : '-')), + SizedBox(width: 200, child: row('售价', p.salePrice != null ? '¥${p.salePrice!.toStringAsFixed(2)}' : '-')), + if (p.barcode?.isNotEmpty ?? false) + SizedBox(width: 200, child: row('条码', p.barcode!)), + ], + ), + ), + ); + } + + Widget _buildDescSection() { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide(color: AppTheme.border, width: 0.5), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('描述', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), + const Spacer(), + if (_descChanged) + _savingDesc + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : ElevatedButton( + onPressed: _saveDesc, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: const Text('保存', style: TextStyle(fontSize: 13)), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _descCtrl, + maxLines: 6, + minLines: 3, + decoration: InputDecoration( + hintText: '输入商品描述,例如酒的产地、工艺、口感等...', + hintStyle: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: AppTheme.border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: AppTheme.border)), + contentPadding: const EdgeInsets.all(12), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPublicLinkSection(Product p) { + final publicUrl = 'https://jiu.51yanmei.com/product/${p.publicId}'; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide(color: AppTheme.border, width: 0.5), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('公开链接', + style: + TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text(publicUrl, + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontFamily: 'monospace')), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: publicUrl)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('链接已复制')), + ); + }, + icon: const Icon(Icons.copy, size: 14), + label: const Text('复制'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _ImageThumbnail extends StatelessWidget { + final String url; + final VoidCallback onDelete; + const _ImageThumbnail({required this.url, required this.onDelete}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + url, + width: 110, + height: 110, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 110, + height: 110, + color: AppTheme.border, + child: const Icon(Icons.broken_image, + color: AppTheme.textSecondary), + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: onDelete, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(10), + ), + child: + const Icon(Icons.close, size: 14, color: Colors.white), + ), + ), + ), + ], + ), + ); + } +} + +class _UploadButton extends StatelessWidget { + final bool uploading; + final VoidCallback onTap; + const _UploadButton({required this.uploading, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: uploading ? null : onTap, + child: Container( + width: 110, + height: 110, + decoration: BoxDecoration( + border: Border.all(color: AppTheme.border, width: 1.5), + borderRadius: BorderRadius.circular(4), + color: AppTheme.background, + ), + child: uploading + ? const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2))) + : const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_photo_alternate_outlined, + size: 28, color: AppTheme.textSecondary), + SizedBox(height: 4), + Text('上传图片', + style: TextStyle( + fontSize: 11, color: AppTheme.textSecondary)), + ], + ), + ), + ); + } +} + +class _QRCodeDialog extends ConsumerStatefulWidget { + final int productId; + const _QRCodeDialog({required this.productId}); + + @override + ConsumerState<_QRCodeDialog> createState() => _QRCodeDialogState(); +} + +class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { + late Future _future; + + @override + void initState() { + super.initState(); + _future = ref + .read(productRepositoryProvider) + .getQRCodeBytes(widget.productId); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('商品二维码'), + content: SizedBox( + width: 200, + height: 200, + child: FutureBuilder( + future: _future, + builder: (_, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center(child: Text('加载失败:${snap.error}')); + } + return Image.memory(snap.data!); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + ], + ); + } +} diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index bc227e4..0bdbce0 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -1,12 +1,10 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/app_theme.dart'; -import '../../models/product.dart'; -import '../../providers/product_provider.dart'; +import '../../providers/product_option_provider.dart'; import '../../widgets/data_table_card.dart'; -import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; +import '../../core/utils/export_util.dart'; class ProductsScreen extends ConsumerStatefulWidget { const ProductsScreen({super.key}); @@ -16,493 +14,400 @@ class ProductsScreen extends ConsumerStatefulWidget { } class _ProductsScreenState extends ConsumerState { - final _searchCtrl = TextEditingController(); - Timer? _debounce; - Set _filterBrand = {}; + final _nameSearchCtrl = TextEditingController(); + final _seriesSearchCtrl = TextEditingController(); + final _specSearchCtrl = TextEditingController(); + + int _namePage = 1; int _namePageSize = 20; + int _seriesPage = 1; int _seriesPageSize = 20; + int _specPage = 1; int _specPageSize = 20; @override void dispose() { - _searchCtrl.dispose(); - _debounce?.cancel(); + _nameSearchCtrl.dispose(); + _seriesSearchCtrl.dispose(); + _specSearchCtrl.dispose(); super.dispose(); } - void _onSearchChanged(String value) { - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 300), () { - ref.read(productListProvider.notifier).setKeyword(value); - }); - } - - void _showProductDialog(BuildContext context, {Product? product}) { - showDialog( - context: context, - builder: (ctx) => _ProductFormDialog( - product: product, - onSaved: () { - ref.read(productListProvider.notifier).reload(); - }, - ), - ); - } - - Future _confirmDelete(BuildContext context, Product product) async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('确认删除'), - content: Text('确认删除商品「${product.name}」?此操作不可恢复。'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.danger, - foregroundColor: Colors.white), - child: const Text('删除'), - ), - ], - ), - ); - if (confirmed == true && mounted) { - try { - await ref - .read(productListProvider.notifier) - .deleteProduct(product.id); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('删除成功'), backgroundColor: AppTheme.success), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('删除失败:$e'), - backgroundColor: AppTheme.danger), - ); - } - } - } - } - @override Widget build(BuildContext context) { return PageScaffold( title: '基础数据', tabs: const [ - Tab(text: '商品档案'), + Tab(text: '商品名称'), + Tab(text: '系列'), + Tab(text: '规格'), ], tabViews: [ - _buildProductTab(), + _buildNameTab(), + _buildSeriesTab(), + _buildSpecTab(), ], ); } - Widget _buildProductTab() { - final asyncProducts = ref.watch(productListProvider); - return asyncProducts.when( + // ── 商品名称 tab ─────────────────────────────────────────── + + Widget _buildNameTab() { + final async = ref.watch(productNameListProvider); + return async.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), - const SizedBox(height: 12), - const Text('暂无数据,网络不可用', - style: const TextStyle(color: AppTheme.textSecondary)), - const SizedBox(height: 12), - ElevatedButton( - onPressed: () => - ref.read(productListProvider.notifier).reload(), - child: const Text('重试'), + error: (e, _) => _buildError(() => ref.read(productNameListProvider.notifier).reload()), + data: (items) { + final keyword = _nameSearchCtrl.text.toLowerCase(); + final filtered = keyword.isEmpty + ? items + : items.where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); + final paged = filtered.skip((_namePage - 1) * _namePageSize).take(_namePageSize).toList(); + return DataTableCard( + totalCount: filtered.length, + page: _namePage, + pageSize: _namePageSize, + onPageChanged: (p) => setState(() => _namePage = p), + onPageSizeChanged: (s) => setState(() { _namePageSize = s; _namePage = 1; }), + toolbar: _buildToolbar( + searchCtrl: _nameSearchCtrl, + hint: '搜索名称/编号', + onSearchChanged: () => setState(() => _namePage = 1), + onAdd: () => _showCreateDialog( + title: '新建商品名称', + hasQuantity: false, + onSave: (data) => ref.read(productNameListProvider.notifier).create(data), ), + onExport: () => exportExcel( + filename: '商品名称', + headers: ['编号', '名称', '备注'], + rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(), + ), + ), + columns: const [ + DataColumn(label: Text('编号')), + DataColumn(label: Text('名称')), + DataColumn(label: Text('备注')), + DataColumn(label: Text('操作')), ], - ), - ), - data: (result) { - if (result.data.isEmpty) { - return _buildProductList([], result.total, result.page); - } - return _buildProductList(result.data, result.total, result.page); + rows: paged.isEmpty + ? [DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + ])] + : paged.map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), + DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(it.remark ?? '-')), + DataCell(_deleteButton(() => _confirmDelete( + '删除名称「${it.name}」?', + () => ref.read(productNameListProvider.notifier).delete(it.id), + ))), + ])).toList(), + ); }, ); } - Widget _buildProductList( - List products, int totalCount, int page) { - // 品牌选项从当前数据中派生,客户端筛选 - final brandOptions = products - .map((p) => p.brand ?? '') - .where((s) => s.isNotEmpty) - .toSet() - .toList() - ..sort(); - final filteredProducts = _filterBrand.isEmpty - ? products - : products.where((p) => _filterBrand.contains(p.brand ?? '')).toList(); + // ── 系列 tab ────────────────────────────────────────────── - return DataTableCard( - totalCount: totalCount, - page: page, - onPageChanged: (p) => - ref.read(productListProvider.notifier).setPage(p), - toolbar: Row( - children: [ - ElevatedButton.icon( - onPressed: () => _showProductDialog(context), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建'), - ), - const Spacer(), - SizedBox( - width: 220, - child: TextField( - controller: _searchCtrl, - decoration: const InputDecoration( - hintText: '搜索商品名/编码/品牌', - prefixIcon: Icon(Icons.search, size: 16), - hintStyle: TextStyle(fontSize: 13), - ), - onChanged: _onSearchChanged, + Widget _buildSeriesTab() { + final async = ref.watch(productSeriesListProvider); + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _buildError(() => ref.read(productSeriesListProvider.notifier).reload()), + data: (items) { + final keyword = _seriesSearchCtrl.text.toLowerCase(); + final filtered = keyword.isEmpty + ? items + : items.where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); + final paged = filtered.skip((_seriesPage - 1) * _seriesPageSize).take(_seriesPageSize).toList(); + return DataTableCard( + totalCount: filtered.length, + page: _seriesPage, + pageSize: _seriesPageSize, + onPageChanged: (p) => setState(() => _seriesPage = p), + onPageSizeChanged: (s) => setState(() { _seriesPageSize = s; _seriesPage = 1; }), + toolbar: _buildToolbar( + searchCtrl: _seriesSearchCtrl, + hint: '搜索系列/编号', + onSearchChanged: () => setState(() => _seriesPage = 1), + onAdd: () => _showCreateDialog( + title: '新建系列', + hasQuantity: false, + onSave: (data) => ref.read(productSeriesListProvider.notifier).create(data), ), + onExport: () => exportExcel( + filename: '商品系列', + headers: ['编号', '系列名称', '备注'], + rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(), + ), + ), + columns: const [ + DataColumn(label: Text('编号')), + DataColumn(label: Text('系列名称')), + DataColumn(label: Text('备注')), + DataColumn(label: Text('操作')), + ], + rows: paged.isEmpty + ? [DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + ])] + : paged.map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), + DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(it.remark ?? '-')), + DataCell(_deleteButton(() => _confirmDelete( + '删除系列「${it.name}」?', + () => ref.read(productSeriesListProvider.notifier).delete(it.id), + ))), + ])).toList(), + ); + }, + ); + } + + // ── 规格 tab ────────────────────────────────────────────── + + Widget _buildSpecTab() { + final async = ref.watch(productSpecListProvider); + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _buildError(() => ref.read(productSpecListProvider.notifier).reload()), + data: (items) { + final keyword = _specSearchCtrl.text.toLowerCase(); + final filtered = keyword.isEmpty + ? items + : items.where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); + final paged = filtered.skip((_specPage - 1) * _specPageSize).take(_specPageSize).toList(); + return DataTableCard( + totalCount: filtered.length, + page: _specPage, + pageSize: _specPageSize, + onPageChanged: (p) => setState(() => _specPage = p), + onPageSizeChanged: (s) => setState(() { _specPageSize = s; _specPage = 1; }), + toolbar: _buildToolbar( + searchCtrl: _specSearchCtrl, + hint: '搜索规格/编号', + onSearchChanged: () => setState(() => _specPage = 1), + onAdd: () => _showCreateDialog( + title: '新建规格', + hasQuantity: true, + onSave: (data) => ref.read(productSpecListProvider.notifier).create(data), + ), + onExport: () => exportExcel( + filename: '商品规格', + headers: ['编号', '规格名称', '单品数量', '备注'], + rows: items.map((it) => [it.code ?? '', it.name, it.quantity, it.remark ?? '']).toList(), + ), + ), + columns: const [ + DataColumn(label: Text('编号')), + DataColumn(label: Text('规格名称')), + DataColumn(label: Text('单品数量'), numeric: true), + DataColumn(label: Text('备注')), + DataColumn(label: Text('操作')), + ], + rows: paged.isEmpty + ? [DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + ])] + : paged.map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), + DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(it.quantity > 0 ? '${it.quantity}' : '-')), + DataCell(Text(it.remark ?? '-')), + DataCell(_deleteButton(() => _confirmDelete( + '删除规格「${it.name}」?', + () => ref.read(productSpecListProvider.notifier).delete(it.id), + ))), + ])).toList(), + ); + }, + ); + } + + // ── 公共组件 ────────────────────────────────────────────── + + Widget _buildToolbar({ + required TextEditingController searchCtrl, + required String hint, + required VoidCallback onSearchChanged, + required VoidCallback onAdd, + required VoidCallback onExport, + }) { + return Row( + children: [ + ElevatedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 16), + label: const Text('新建'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: onExport, + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), + const Spacer(), + SizedBox( + width: 200, + child: TextField( + controller: searchCtrl, + decoration: InputDecoration( + hintText: hint, + prefixIcon: const Icon(Icons.search, size: 16), + hintStyle: const TextStyle(fontSize: 13), + ), + onChanged: (_) => onSearchChanged(), + ), + ), + ], + ); + } + + Widget _buildError(VoidCallback onRetry) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), + const SizedBox(height: 12), + const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)), + const SizedBox(height: 12), + ElevatedButton(onPressed: onRetry, child: const Text('重试')), + ], + ), + ); + } + + Widget _deleteButton(VoidCallback onTap) { + return TextButton( + onPressed: onTap, + child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)), + ); + } + + Future _confirmDelete(String message, Future Function() onConfirmed) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认删除'), + content: Text(message), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + child: const Text('删除'), ), ], ), - columns: [ - const DataColumn(label: Text('商品编码')), - const DataColumn(label: Text('商品名称')), - DataColumn( - label: FilterableColumnHeader( - text: '品牌', - options: brandOptions, - selected: _filterBrand, - onChanged: (v) => setState(() => _filterBrand = v), - ), - ), - const DataColumn(label: Text('规格')), - const DataColumn(label: Text('单位')), - const DataColumn(label: Text('进价'), numeric: true), - const DataColumn(label: Text('售价'), numeric: true), - const DataColumn(label: Text('安全库存'), numeric: true), - const DataColumn(label: Text('操作')), - ], - rows: filteredProducts.isEmpty - ? [ - const DataRow(cells: [ - DataCell(SizedBox()), - DataCell(Text('暂无商品', - style: TextStyle(color: AppTheme.textSecondary))), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - ]) - ] - : filteredProducts - .map((p) => DataRow( - cells: [ - DataCell(Text(p.code, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: AppTheme.textSecondary))), - DataCell(SizedBox( - width: 180, - child: Text(p.name, - overflow: TextOverflow.ellipsis), - )), - DataCell(Text(p.brand ?? '-')), - DataCell(Text(p.spec ?? '-')), - DataCell(Text(p.unit)), - DataCell(Text(p.purchasePrice != null - ? '¥${p.purchasePrice!.toStringAsFixed(2)}' - : '-')), - DataCell(Text( - p.salePrice != null - ? '¥${p.salePrice!.toStringAsFixed(2)}' - : '-', - style: - const TextStyle(color: AppTheme.primary), - )), - DataCell(Text( - p.minStock != null ? '${p.minStock}' : '-')), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - key: Key('btn_edit_${p.id}'), - onPressed: () => - _showProductDialog(context, product: p), - child: const Text('编辑', - style: TextStyle(fontSize: 12)), - ), - TextButton( - key: Key('btn_delete_${p.id}'), - onPressed: () => - _confirmDelete(context, p), - child: const Text('删除', - style: TextStyle( - fontSize: 12, - color: AppTheme.danger)), - ), - ], - )), - ], - )) - .toList(), ); - } -} - -class _ProductFormDialog extends ConsumerStatefulWidget { - final Product? product; - final VoidCallback onSaved; - - const _ProductFormDialog({this.product, required this.onSaved}); - - @override - ConsumerState<_ProductFormDialog> createState() => - _ProductFormDialogState(); -} - -class _ProductFormDialogState extends ConsumerState<_ProductFormDialog> { - final _formKey = GlobalKey(); - late final TextEditingController _nameCtrl; - late final TextEditingController _codeCtrl; - late final TextEditingController _barcodeCtrl; - late final TextEditingController _brandCtrl; - late final TextEditingController _specCtrl; - late final TextEditingController _purchasePriceCtrl; - late final TextEditingController _salePriceCtrl; - late final TextEditingController _minStockCtrl; - late final TextEditingController _remarkCtrl; - String _unit = '瓶'; - bool _saving = false; - - @override - void initState() { - super.initState(); - final p = widget.product; - _nameCtrl = TextEditingController(text: p?.name ?? ''); - _codeCtrl = TextEditingController(text: p?.code ?? ''); - _barcodeCtrl = TextEditingController(text: p?.barcode ?? ''); - _brandCtrl = TextEditingController(text: p?.brand ?? ''); - _specCtrl = TextEditingController(text: p?.spec ?? ''); - _purchasePriceCtrl = TextEditingController( - text: p?.purchasePrice?.toStringAsFixed(2) ?? ''); - _salePriceCtrl = - TextEditingController(text: p?.salePrice?.toStringAsFixed(2) ?? ''); - _minStockCtrl = - TextEditingController(text: p?.minStock?.toString() ?? ''); - _remarkCtrl = TextEditingController(text: p?.remark ?? ''); - _unit = p?.unit ?? '瓶'; - } - - @override - void dispose() { - _nameCtrl.dispose(); - _codeCtrl.dispose(); - _barcodeCtrl.dispose(); - _brandCtrl.dispose(); - _specCtrl.dispose(); - _purchasePriceCtrl.dispose(); - _salePriceCtrl.dispose(); - _minStockCtrl.dispose(); - _remarkCtrl.dispose(); - super.dispose(); - } - - Future _save() async { - if (!_formKey.currentState!.validate()) return; - setState(() => _saving = true); - final data = { - 'name': _nameCtrl.text.trim(), - 'code': _codeCtrl.text.trim(), - if (_barcodeCtrl.text.trim().isNotEmpty) - 'barcode': _barcodeCtrl.text.trim(), - if (_brandCtrl.text.trim().isNotEmpty) 'brand': _brandCtrl.text.trim(), - if (_specCtrl.text.trim().isNotEmpty) 'spec': _specCtrl.text.trim(), - 'unit': _unit, - if (_purchasePriceCtrl.text.trim().isNotEmpty) - 'purchase_price': double.tryParse(_purchasePriceCtrl.text.trim()), - if (_salePriceCtrl.text.trim().isNotEmpty) - 'sale_price': double.tryParse(_salePriceCtrl.text.trim()), - if (_minStockCtrl.text.trim().isNotEmpty) - 'min_stock': int.tryParse(_minStockCtrl.text.trim()), - if (_remarkCtrl.text.trim().isNotEmpty) - 'remark': _remarkCtrl.text.trim(), - }; - try { - final notifier = ref.read(productListProvider.notifier); - if (widget.product != null) { - await notifier.updateProduct(widget.product!.id, data); - } else { - await notifier.createProduct(data); + if (ok == true && mounted) { + try { + await onConfirmed(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('删除失败:$e'), backgroundColor: AppTheme.danger), + ); + } } - if (mounted) { - Navigator.of(context).pop(); - widget.onSaved(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text(widget.product != null ? '商品更新成功' : '商品创建成功'), - backgroundColor: AppTheme.success, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('保存失败:$e'), - backgroundColor: AppTheme.danger), - ); - } - } finally { - if (mounted) setState(() => _saving = false); } } - @override - Widget build(BuildContext context) { - final isEdit = widget.product != null; - return Dialog( - child: Container( - width: 600, - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(isEdit ? '编辑商品' : '新建商品', - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.w600)), - const SizedBox(height: 20), - Row(children: [ - Expanded( - child: TextFormField( - controller: _nameCtrl, - decoration: const InputDecoration(labelText: '商品名称'), - validator: (v) => - (v == null || v.isEmpty) ? '不能为空' : null, - ), + Future _showCreateDialog({ + required String title, + required bool hasQuantity, + required Future Function(Map) onSave, + }) async { + final codeCtrl = TextEditingController(); + final nameCtrl = TextEditingController(); + final quantityCtrl = TextEditingController(); + final remarkCtrl = TextEditingController(); + final formKey = GlobalKey(); + + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(title), + content: SizedBox( + width: 360, + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: codeCtrl, + decoration: const InputDecoration(labelText: '编号(可选)'), ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: _codeCtrl, - decoration: const InputDecoration(labelText: '商品编码'), - validator: (v) => - (v == null || v.isEmpty) ? '不能为空' : null, - ), + const SizedBox(height: 12), + TextFormField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: '名称 *'), + validator: (v) => (v == null || v.trim().isEmpty) ? '请输入名称' : null, ), - ]), - const SizedBox(height: 12), - Row(children: [ - Expanded( - child: TextFormField( - controller: _brandCtrl, - decoration: const InputDecoration(labelText: '品牌'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: _specCtrl, - decoration: const InputDecoration(labelText: '规格'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: DropdownButtonFormField( - value: _unit, - decoration: const InputDecoration(labelText: '单位'), - items: ['瓶', '箱', '件', '桶', '支', '个'] - .map((u) => DropdownMenuItem( - value: u, - child: Text(u, - style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (v) => setState(() => _unit = v!), - ), - ), - ]), - const SizedBox(height: 12), - Row(children: [ - Expanded( - child: TextFormField( - controller: _purchasePriceCtrl, - decoration: const InputDecoration( - labelText: '进货价', prefixText: '¥'), - keyboardType: const TextInputType.numberWithOptions( - decimal: true), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: _salePriceCtrl, - decoration: const InputDecoration( - labelText: '销售价', prefixText: '¥'), - keyboardType: const TextInputType.numberWithOptions( - decimal: true), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: _minStockCtrl, - decoration: - const InputDecoration(labelText: '安全库存'), + if (hasQuantity) ...[ + const SizedBox(height: 12), + TextFormField( + controller: quantityCtrl, + decoration: const InputDecoration(labelText: '单品数量'), keyboardType: TextInputType.number, ), - ), - ]), - const SizedBox(height: 12), - TextFormField( - controller: _barcodeCtrl, - decoration: const InputDecoration(labelText: '条形码'), - ), - const SizedBox(height: 12), - TextFormField( - controller: _remarkCtrl, - decoration: const InputDecoration(labelText: '备注'), - maxLines: 2, - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('取消'), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Text('保存'), - ), ], - ), - ], + const SizedBox(height: 12), + TextFormField( + controller: remarkCtrl, + decoration: const InputDecoration(labelText: '备注'), + maxLines: 2, + ), + ], + ), ), ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')), + ElevatedButton( + onPressed: () async { + if (!formKey.currentState!.validate()) return; + final data = { + 'name': nameCtrl.text.trim(), + if (codeCtrl.text.isNotEmpty) 'code': codeCtrl.text.trim(), + if (hasQuantity && quantityCtrl.text.isNotEmpty) + 'quantity': int.tryParse(quantityCtrl.text) ?? 0, + if (remarkCtrl.text.isNotEmpty) 'remark': remarkCtrl.text.trim(), + }; + Navigator.of(ctx).pop(); + try { + await onSave(data); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('创建失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } + }, + child: const Text('保存'), + ), + ], ), ); } diff --git a/client/lib/screens/public/public_product_screen.dart b/client/lib/screens/public/public_product_screen.dart new file mode 100644 index 0000000..a34d860 --- /dev/null +++ b/client/lib/screens/public/public_product_screen.dart @@ -0,0 +1,164 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import '../../core/config/app_config.dart'; +import '../../core/theme/app_theme.dart'; + +class PublicProductScreen extends StatefulWidget { + final String publicId; + const PublicProductScreen({super.key, required this.publicId}); + + @override + State createState() => _PublicProductScreenState(); +} + +class _PublicProductScreenState extends State { + Map? _data; + String? _error; + bool _loading = true; + + final _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + )); + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + final url = '${AppConfig.apiBaseUrl}/public/products/${widget.publicId}'; + final resp = await _dio.get(url); + setState(() { + _data = (resp.data as Map)['data'] as Map; + _loading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + if (_error != null || _data == null) { + return Scaffold( + 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), + ElevatedButton(onPressed: _load, child: const Text('重试')), + ], + ), + ), + ); + } + + final d = _data!; + final images = (d['images'] as List? ?? []) + .cast>(); + final name = d['name'] as String? ?? ''; + final series = d['series'] as String? ?? ''; + final spec = d['spec'] as String? ?? ''; + final brand = d['brand'] as String? ?? ''; + final unit = d['unit'] as String? ?? ''; + 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), + 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), + ), + 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)), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 6cd641d..6e8b4bf 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,8 +1,13 @@ +import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../core/auth/auth_state.dart'; +import '../../core/config/app_config.dart'; import '../../core/theme/app_theme.dart'; import '../../models/number_rule.dart'; import '../../models/user.dart'; @@ -33,7 +38,7 @@ class _SettingsScreenState extends ConsumerState { @override Widget build(BuildContext context) { return DefaultTabController( - length: 5, + length: 6, child: Column( children: [ Container( @@ -51,6 +56,7 @@ class _SettingsScreenState extends ConsumerState { Tab(text: '仓库管理'), Tab(text: '编号规则'), Tab(text: '系统参数'), + Tab(text: '数据导入'), Tab(text: '关于'), ], ), @@ -63,6 +69,7 @@ class _SettingsScreenState extends ConsumerState { _buildWarehousesTab(), _buildNumberRulesTab(), _buildSystemParamsTab(), + _buildImportTab(), _buildAboutTab(), ], ), @@ -586,6 +593,9 @@ class _SettingsScreenState extends ConsumerState { ); } + // ── 数据导入 Tab ────────────────────────────────────────── + Widget _buildImportTab() => const _BatchImportWidget(); + // ── 关于 Tab ───────────────────────────────────────────── Widget _buildAboutTab() { final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; @@ -1396,3 +1406,281 @@ class _AboutRow extends StatelessWidget { ); } } + +// ── 批量数据导入 ─────────────────────────────────────────── + +class _ImportSlot { + final String title; + final String endpoint; + final String hint; + PlatformFile? file; + // null = 未运行;true = 成功;false = 失败 + bool? success; + String? error; + int total = 0; + int imported = 0; + int skipped = 0; + + _ImportSlot(this.title, this.endpoint, this.hint); + + bool get hasResult => success != null; +} + +class _BatchImportWidget extends ConsumerStatefulWidget { + const _BatchImportWidget(); + + @override + ConsumerState<_BatchImportWidget> createState() => _BatchImportWidgetState(); +} + +class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { + bool _loading = false; + String? _lastDir; + + static const _prefKey = 'import_last_dir'; + + late final List<_ImportSlot> _slots = [ + _ImportSlot('往来单位', '/import/partners', + '格式:编号 | 类型 | 状态 | 名称 | 电话 | 卡号 | 初始金额 | 单位 | 地址 | 备注'), + _ImportSlot('商品名称', '/import/product-names', + '格式:选项编号 | 选项名称 | 备注'), + _ImportSlot('商品系列', '/import/product-series', + '格式:选项编号 | 选项名称 | 备注'), + _ImportSlot('商品规格', '/import/product-specs', + '格式:选项编号 | 选项名称 | 单品数量 | 备注'), + ]; + + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then((prefs) { + final dir = prefs.getString(_prefKey); + if (dir != null && mounted) setState(() => _lastDir = dir); + }); + } + + Future _pickFile(int index) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['xls', 'xlsx'], + withData: true, + initialDirectory: _lastDir, + ); + if (result == null) return; + + // 保存目录供下次使用 + final path = result.files.first.path; + if (path != null) { + final dir = path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : null; + if (dir != null) { + _lastDir = dir; + SharedPreferences.getInstance().then((p) => p.setString(_prefKey, dir)); + } + } + + setState(() { + _slots[index].file = result.files.first; + _slots[index].success = null; + _slots[index].error = null; + }); + } + + Future _runImport() async { + final token = ref.read(authStateProvider).user?.accessToken ?? ''; + if (!_slots.any((s) => s.file != null)) return; + + setState(() { + _loading = true; + for (final s in _slots) { + if (s.file != null) { + s.success = null; + s.error = null; + } + } + }); + + final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl)); + dio.options.headers['Authorization'] = 'Bearer $token'; + + for (final slot in _slots) { + if (slot.file == null) continue; + final bytes = slot.file!.bytes; + if (bytes == null) { + if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; }); + continue; + } + try { + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name), + }); + final resp = await dio.post(slot.endpoint, data: formData); + final data = resp.data as Map; + if (mounted) setState(() { + slot.success = true; + slot.total = (data['total'] ?? 0) as int; + slot.imported = (data['imported'] ?? 0) as int; + slot.skipped = (data['skipped'] ?? 0) as int; + }); + } on DioException catch (e) { + final msg = (e.response?.data as Map?)?['error'] ?? e.message ?? '未知错误'; + if (mounted) setState(() { + slot.success = false; + slot.error = msg.toString(); + }); + } + } + + if (mounted) setState(() => _loading = false); + } + + @override + Widget build(BuildContext context) { + final hasAnyFile = _slots.any((s) => s.file != null); + final ran = _slots.where((s) => s.file != null && s.hasResult).toList(); + final allDone = !_loading && ran.length == _slots.where((s) => s.file != null).length && ran.isNotEmpty; + final failCount = ran.where((s) => s.success == false).length; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('数据导入', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text( + '选择对应的 Excel 文件后点击「全部导入」,系统将依次导入,按名称去重(已存在的数据不重复导入)。', + style: TextStyle(fontSize: 13, color: AppTheme.textSecondary), + ), + const SizedBox(height: 20), + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + children: [ + for (int i = 0; i < _slots.length; i++) ...[ + _buildSlotRow(i), + if (i < _slots.length - 1) + const Divider(height: 1), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + ElevatedButton.icon( + onPressed: (_loading || !hasAnyFile) ? null : _runImport, + icon: _loading + ? const SizedBox( + width: 14, height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.upload_rounded, size: 16), + label: Text(_loading ? '导入中...' : '全部导入'), + ), + const SizedBox(width: 16), + if (allDone) ...[ + Icon( + failCount == 0 + ? Icons.check_circle_outline + : Icons.warning_amber_rounded, + size: 16, + color: failCount == 0 ? AppTheme.success : Colors.orange, + ), + const SizedBox(width: 4), + Text( + failCount == 0 ? '全部导入完成' : '$failCount 项失败,请检查错误信息', + style: TextStyle( + fontSize: 13, + color: failCount == 0 ? AppTheme.success : Colors.orange, + ), + ), + ], + ], + ), + ], + ), + ); + } + + Widget _buildSlotRow(int index) { + final slot = _slots[index]; + + Widget? statusWidget; + if (slot.hasResult) { + if (slot.success == true) { + final parts = [ + '共 ${slot.total} 条', + '新增 ${slot.imported} 条', + if (slot.skipped > 0) '重复跳过 ${slot.skipped} 条', + ]; + statusWidget = Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle_outline, size: 15, color: AppTheme.success), + const SizedBox(width: 4), + Text( + parts.join(','), + style: const TextStyle(fontSize: 13, color: AppTheme.success), + ), + ], + ); + } else { + statusWidget = Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 15, color: AppTheme.danger), + const SizedBox(width: 4), + Text( + slot.error ?? '未知错误', + style: const TextStyle(fontSize: 13, color: AppTheme.danger), + ), + ], + ); + } + } else if (_loading && slot.file != null) { + statusWidget = const SizedBox( + width: 14, height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + SizedBox( + width: 68, + child: Text(slot.title, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: _loading ? null : () => _pickFile(index), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('选择文件', style: TextStyle(fontSize: 13)), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + slot.file?.name ?? '未选择', + style: TextStyle( + fontSize: 13, + color: slot.file != null ? AppTheme.textPrimary : AppTheme.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + if (statusWidget != null) statusWidget, + ], + ), + ); + } +} diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index a88d2c8..fb1f40f 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -66,7 +66,7 @@ class _AppShellState extends ConsumerState { _NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'), _NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'), _NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'), - _NavItem(icon: Icons.track_changes, label: '商品管理', path: '/batches'), + _NavItem(icon: Icons.track_changes, label: '商品详情', path: '/batches'), _NavItem( icon: Icons.account_balance_wallet, label: '财务管理', diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index 156a08e..fe5b7ac 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -3,9 +3,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; -import '../../models/product.dart'; +import '../../core/auth/auth_state.dart'; import '../../providers/partner_provider.dart'; +import '../../providers/product_option_provider.dart'; import '../../providers/product_provider.dart'; +import '../../providers/inventory_provider.dart'; import '../../providers/stock_in_provider.dart'; import '../../providers/warehouse_provider.dart'; @@ -25,6 +27,7 @@ class _StockInFormScreenState extends ConsumerState { DateTime _orderDate = DateTime.now(); bool _submitting = false; bool _loadingEdit = false; + Map _inventoryMap = {}; final List<_ItemRow> _items = []; @@ -43,9 +46,11 @@ class _StockInFormScreenState extends ConsumerState { Future _loadEditOrder() async { setState(() => _loadingEdit = true); try { - final order = await ref - .read(stockInRepositoryProvider) - .get(widget.editOrderId!); + final order = await ref.read(stockInRepositoryProvider).get(widget.editOrderId!); + final nameOpts = await ref.read(productNameListProvider.future); + final seriesOpts = await ref.read(productSeriesListProvider.future); + final specOpts = await ref.read(productSpecListProvider.future); + setState(() { _warehouseId = order.warehouseId; _partnerId = order.partnerId; @@ -59,6 +64,9 @@ class _StockInFormScreenState extends ConsumerState { row.productId = item.productId; row.qtyCtrl.text = item.quantity.toStringAsFixed(0); row.priceCtrl.text = item.unitPrice.toStringAsFixed(2); + row.selectedNameId = nameOpts.where((o) => o.name == item.productName).firstOrNull?.id; + row.selectedSeriesId = seriesOpts.where((o) => o.name == item.productSeries).firstOrNull?.id; + row.selectedSpecId = specOpts.where((o) => o.name == item.productSpec).firstOrNull?.id; _items.add(row); } if (_items.isEmpty) _items.add(_ItemRow()); @@ -93,6 +101,17 @@ class _StockInFormScreenState extends ConsumerState { return total; } + Future _loadInventory(int warehouseId) async { + try { + final result = await ref + .read(inventoryRepositoryProvider) + .listInventory(warehouseId: warehouseId, pageSize: 500); + setState(() { + _inventoryMap = {for (final inv in result.data) inv.productId: inv.quantity}; + }); + } catch (_) {} + } + void _addItem() { setState(() => _items.add(_ItemRow())); } @@ -121,6 +140,40 @@ class _StockInFormScreenState extends ConsumerState { setState(() => _submitting = true); + // Resolve product IDs for items where productId is null + final nameOpts = ref.read(productNameListProvider).valueOrNull ?? []; + final seriesOpts = ref.read(productSeriesListProvider).valueOrNull ?? []; + final specOpts = ref.read(productSpecListProvider).valueOrNull ?? []; + + for (final item in _items) { + if (item.productId == null) { + final name = nameOpts.where((o) => o.id == item.selectedNameId).firstOrNull?.name ?? ''; + final series = seriesOpts.where((o) => o.id == item.selectedSeriesId).firstOrNull?.name ?? ''; + final spec = specOpts.where((o) => o.id == item.selectedSpecId).firstOrNull?.name ?? ''; + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请选择商品名称'), backgroundColor: AppTheme.danger), + ); + setState(() => _submitting = false); + return; + } + try { + final product = await ref + .read(productRepositoryProvider) + .findOrCreate(name: name, series: series, spec: spec); + item.productId = product.id; + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('商品查找失败:$e'), backgroundColor: AppTheme.danger), + ); + setState(() => _submitting = false); + } + return; + } + } + } + final itemsData = _items.map((item) { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; @@ -144,9 +197,7 @@ class _StockInFormScreenState extends ConsumerState { try { if (_isEdit) { - await ref - .read(stockInRepositoryProvider) - .update(widget.editOrderId!, data); + await ref.read(stockInRepositoryProvider).update(widget.editOrderId!, data); ref.read(stockInListProvider.notifier).reload(); } else { await ref.read(stockInListProvider.notifier).createOrder(data); @@ -163,8 +214,7 @@ class _StockInFormScreenState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('操作失败:$e'), backgroundColor: AppTheme.danger), + SnackBar(content: Text('操作失败:$e'), backgroundColor: AppTheme.danger), ); } } finally { @@ -176,6 +226,7 @@ class _StockInFormScreenState extends ConsumerState { Widget build(BuildContext context) { final asyncWarehouses = ref.watch(warehouseListProvider); final asyncSuppliers = ref.watch(supplierListProvider); + final currentUser = ref.watch(authStateProvider).user; return Scaffold( backgroundColor: AppTheme.background, @@ -194,8 +245,7 @@ class _StockInFormScreenState extends ConsumerState { ), const SizedBox(width: 8), Text(_isEdit ? '修改入库单' : '新建入库单', - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600)), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), OutlinedButton( onPressed: _submitting ? null : () => _submit(true), @@ -208,8 +258,7 @@ class _StockInFormScreenState extends ConsumerState { ? const SizedBox( width: 14, height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.send, size: 16), label: const Text('提交审核'), ), @@ -226,226 +275,211 @@ class _StockInFormScreenState extends ConsumerState { if (_loadingEdit) const Expanded(child: Center(child: CircularProgressIndicator())), if (!_loadingEdit) - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('基本信息', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - const SizedBox(height: 16), - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - // Warehouse dropdown - _FormField( - label: '入库仓库', - required: true, - child: asyncWarehouses.when( - loading: () => - const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (warehouses) => - DropdownButtonFormField( - value: _warehouseId, - hint: const Text('请选择仓库', - style: - TextStyle(fontSize: 13)), - items: warehouses - .map((w) => DropdownMenuItem( - value: w.id, - child: Text(w.name, - style: const TextStyle( - fontSize: 13)))) - .toList(), - onChanged: (v) => setState( - () => _warehouseId = v), - validator: (v) => - v == null ? '不能为空' : null, - decoration: const InputDecoration(), - ), - ), - ), - // Partner dropdown - _FormField( - label: '供应商', - child: asyncSuppliers.when( - loading: () => - const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (result) => - DropdownButtonFormField( - value: _partnerId, - hint: const Text('请选择供应商', - style: - TextStyle(fontSize: 13)), - items: result.data - .map((p) => DropdownMenuItem( - value: p.id, - child: Text(p.name, - style: const TextStyle( - fontSize: 13)))) - .toList(), - onChanged: (v) => setState( - () => _partnerId = v), - decoration: const InputDecoration(), - ), - ), - ), - // Date picker - _FormField( - label: '入库日期', - required: true, - child: InkWell( - onTap: _pickDate, - child: InputDecorator( - decoration: - const InputDecoration(), - child: Row( - children: [ - Expanded( - child: Text( - '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', - style: const TextStyle( - fontSize: 13), - ), - ), - const Icon( - Icons.calendar_today, - size: 16, - color: - AppTheme.textSecondary), - ], + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('基本信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _FormField( + label: '入库仓库', + required: true, + child: asyncWarehouses.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (warehouses) => DropdownButtonFormField( + value: _warehouseId, + hint: const Text('请选择仓库', + style: TextStyle(fontSize: 13)), + items: warehouses + .map((w) => DropdownMenuItem( + value: w.id, + child: Text(w.name, + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (v) { + setState(() => _warehouseId = v); + if (v != null) _loadInventory(v); + }, + validator: (v) => v == null ? '不能为空' : null, + decoration: const InputDecoration(), ), ), ), - ), - ], - ), - const SizedBox(height: 16), - _FormField( - label: '备注', - width: double.infinity, - child: TextFormField( - controller: _remarkCtrl, - maxLines: 2, - decoration: const InputDecoration( - hintText: '选填,如有特殊说明请在此注明', - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('商品明细', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - const Spacer(), - ElevatedButton.icon( - onPressed: _addItem, - icon: const Icon(Icons.add, size: 16), - label: const Text('添加商品'), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 32)), - ), - ], - ), - const SizedBox(height: 12), - Table( - columnWidths: const { - 0: FixedColumnWidth(36), - 1: FlexColumnWidth(3), - 2: FlexColumnWidth(1.5), - 3: FlexColumnWidth(1.5), - 4: FlexColumnWidth(1.5), - 5: FixedColumnWidth(60), - }, - children: [ - TableRow( - decoration: const BoxDecoration( - color: Color(0xFFF0F4FF)), - children: [ - '序号', - '商品', - '数量', - '单价', - '金额', - '操作', - ] - .map((h) => Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 8, - vertical: 10), - child: Text(h, - style: const TextStyle( - fontSize: 13, - fontWeight: - FontWeight.w600, - color: AppTheme - .primaryDark)), - )) - .toList(), - ), - ...List.generate( - _items.length, - (i) => _buildItemRow(i)), - ], - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Text('合计金额:', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500)), - Text( - '¥${_totalAmount.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppTheme.danger), + _FormField( + label: '供应商', + child: asyncSuppliers.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (result) => DropdownButtonFormField( + value: _partnerId, + hint: const Text('请选择供应商', + style: TextStyle(fontSize: 13)), + items: result.data + .map((p) => DropdownMenuItem( + value: p.id, + child: Text(p.name, + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (v) => setState(() => _partnerId = v), + decoration: const InputDecoration(), + ), + ), + ), + _FormField( + label: '入库员', + child: InputDecorator( + decoration: const InputDecoration(), + child: Text( + currentUser?.realName ?? '-', + style: const TextStyle(fontSize: 13), + ), + ), + ), + _FormField( + label: '入库日期', + required: true, + child: InkWell( + onTap: _pickDate, + child: InputDecorator( + decoration: const InputDecoration(), + child: Row( + children: [ + Expanded( + child: Text( + '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 13), + ), + ), + const Icon(Icons.calendar_today, + size: 16, color: AppTheme.textSecondary), + ], + ), + ), + ), ), ], ), - ), - ], + const SizedBox(height: 16), + _FormField( + label: '备注', + width: double.infinity, + child: TextFormField( + controller: _remarkCtrl, + maxLines: 2, + decoration: const InputDecoration( + hintText: '选填,如有特殊说明请在此注明', + ), + ), + ), + ], + ), ), ), - ), - ], + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('商品明细', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const Spacer(), + ElevatedButton.icon( + onPressed: _addItem, + icon: const Icon(Icons.add, size: 16), + label: const Text('添加商品'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 32)), + ), + ], + ), + const SizedBox(height: 12), + Table( + columnWidths: const { + 0: FixedColumnWidth(36), + 1: FlexColumnWidth(2.2), + 2: FlexColumnWidth(1.3), + 3: FlexColumnWidth(1.3), + 4: FlexColumnWidth(0.9), + 5: FlexColumnWidth(1.0), + 6: FlexColumnWidth(1.0), + 7: FlexColumnWidth(1.0), + 8: FlexColumnWidth(1.0), + 9: FixedColumnWidth(60), + }, + children: [ + TableRow( + decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), + children: [ + '序号', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '当前库存', '操作', + ] + .map((h) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 10), + child: Text(h, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + )) + .toList(), + ), + ...List.generate(_items.length, (i) => _buildItemRow(i)), + ], + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text('合计金额:', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500)), + Text( + '¥${_totalAmount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.danger), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), ), ), ), - ), ], ), ); @@ -453,69 +487,129 @@ class _StockInFormScreenState extends ConsumerState { TableRow _buildItemRow(int index) { final item = _items[index]; - final asyncProducts = ref.watch(productListProvider); + final asyncNames = ref.watch(productNameListProvider); + final asyncSeries = ref.watch(productSeriesListProvider); + final asyncSpecs = ref.watch(productSpecListProvider); final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; final amount = qty * price; + final specQty = asyncSpecs.valueOrNull + ?.where((o) => o.id == item.selectedSpecId) + .firstOrNull + ?.quantity ?? 0; return TableRow( decoration: BoxDecoration( color: index.isEven ? Colors.white : const Color(0xFFFAFAFA), ), children: [ + // 序号 Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text('${index + 1}', - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), ), + // 名称 Padding( padding: const EdgeInsets.all(4), - child: asyncProducts.when( + child: asyncNames.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), - data: (result) => DropdownButtonFormField( - value: item.productId, - hint: const Text('选择商品', - style: TextStyle(fontSize: 13)), - items: result.data - .map((p) => DropdownMenuItem( - value: p.id, - child: Text('${p.name}(${p.spec ?? p.unit})', - style: const TextStyle(fontSize: 13)))) + data: (names) => DropdownButtonFormField( + value: item.selectedNameId, + hint: const Text('选择名称', style: TextStyle(fontSize: 12)), + isExpanded: true, + items: names + .map((o) => DropdownMenuItem( + value: o.id, + child: Text(o.name, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis))) .toList(), - onChanged: (v) { - setState(() { - item.productId = v; - // Auto-fill price if available - if (v != null) { - final found = result.data.firstWhere( - (p) => p.id == v, - orElse: () => Product( - id: 0, code: '', name: '', unit: '')); - if (found.purchasePrice != null) { - item.priceCtrl.text = - found.purchasePrice!.toStringAsFixed(2); - } - } - }); - }, - validator: (v) => v == null ? '不能为空' : null, - decoration: const InputDecoration(), + onChanged: (v) => setState(() { + item.selectedNameId = v; + item.productId = null; + }), + validator: (v) => v == null ? '请选择' : null, + decoration: const InputDecoration(isDense: true), + style: const TextStyle(fontSize: 12, color: Colors.black87), ), ), ), + // 系列 + Padding( + padding: const EdgeInsets.all(4), + child: asyncSeries.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (series) => DropdownButtonFormField( + value: item.selectedSeriesId, + hint: const Text('选择系列', style: TextStyle(fontSize: 12)), + isExpanded: true, + items: series + .map((o) => DropdownMenuItem( + value: o.id, + child: Text(o.name, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis))) + .toList(), + onChanged: (v) => setState(() { + item.selectedSeriesId = v; + item.productId = null; + }), + decoration: const InputDecoration(isDense: true), + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ), + ), + // 规格 + Padding( + padding: const EdgeInsets.all(4), + child: asyncSpecs.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (specs) => DropdownButtonFormField( + value: item.selectedSpecId, + hint: const Text('选择规格', style: TextStyle(fontSize: 12)), + isExpanded: true, + items: specs + .map((o) => DropdownMenuItem( + value: o.id, + child: Text(o.name, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis))) + .toList(), + onChanged: (v) => setState(() { + item.selectedSpecId = v; + item.productId = null; + }), + validator: (v) => v == null ? '请选择' : null, + decoration: const InputDecoration(isDense: true), + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ), + ), + // 单品数量 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Text( + specQty > 0 ? '$specQty' : '-', + style: TextStyle( + fontSize: 13, + color: specQty > 0 ? Colors.black87 : AppTheme.textSecondary, + ), + ), + ), + // 数量 Padding( padding: const EdgeInsets.all(4), child: TextFormField( controller: item.qtyCtrl, - decoration: const InputDecoration(hintText: '0'), + decoration: const InputDecoration(hintText: '0', isDense: true), style: const TextStyle(fontSize: 13), - keyboardType: - const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d+\.?\d{0,2}')) + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) ], onChanged: (_) => setState(() {}), validator: (v) { @@ -525,18 +619,17 @@ class _StockInFormScreenState extends ConsumerState { }, ), ), + // 单价 Padding( padding: const EdgeInsets.all(4), child: TextFormField( controller: item.priceCtrl, decoration: const InputDecoration( - hintText: '0.00', prefixText: '¥'), + hintText: '0.00', prefixText: '¥', isDense: true), style: const TextStyle(fontSize: 13), - keyboardType: - const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d+\.?\d{0,2}')) + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) ], onChanged: (_) => setState(() {}), validator: (v) { @@ -546,31 +639,54 @@ class _StockInFormScreenState extends ConsumerState { }, ), ), + // 金额 Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text( '¥${amount.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w500), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), ), + // 当前库存 + _buildInventoryCell(item.productId), + // 操作 Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: IconButton( - icon: const Icon(Icons.delete_outline, - size: 18, color: AppTheme.danger), - onPressed: - _items.length > 1 ? () => _removeItem(index) : null, + icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger), + onPressed: _items.length > 1 ? () => _removeItem(index) : null, tooltip: '删除', padding: EdgeInsets.zero, - constraints: - const BoxConstraints(minWidth: 28, minHeight: 28), + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), ), ], ); } + Widget _buildInventoryCell(int? productId) { + if (productId == null) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)), + ); + } + final qty = _inventoryMap[productId]; + final text = qty != null + ? qty.toStringAsFixed(0) + : (_warehouseId == null ? '选仓库后显示' : '-'); + final color = qty == null + ? AppTheme.textSecondary + : qty <= 0 + ? AppTheme.danger + : AppTheme.primary; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Text(text, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)), + ); + } + Future _pickDate() async { final date = await showDatePicker( context: context, @@ -584,6 +700,9 @@ class _StockInFormScreenState extends ConsumerState { class _ItemRow { int? productId; + int? selectedNameId; + int? selectedSeriesId; + int? selectedSpecId; final TextEditingController qtyCtrl = TextEditingController(); final TextEditingController priceCtrl = TextEditingController(); @@ -616,12 +735,9 @@ class _FormField extends StatelessWidget { Row( children: [ if (required) - const Text('*', - style: - TextStyle(color: AppTheme.danger, fontSize: 13)), + const Text('*', style: TextStyle(color: AppTheme.danger, fontSize: 13)), Text(label, - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), ], ), const SizedBox(height: 6), diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index ec689b4..b3816e6 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -9,6 +9,8 @@ import '../../widgets/data_table_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; +import '../../core/utils/export_util.dart'; +import '../../providers/inventory_provider.dart'; class StockInListScreen extends ConsumerStatefulWidget { const StockInListScreen({super.key}); @@ -31,6 +33,8 @@ class _StockInListScreenState extends ConsumerState { ColDef('amount', '金额', minWidth: 800), ColDef('status', '状态'), ColDef('date', '日期', minWidth: 900), + ColDef('operator', '入库员', minWidth: 1100), + ColDef('reviewer', '审核员', minWidth: 1100), ColDef('actions', '操作', required: true), ]; @@ -135,8 +139,9 @@ class _StockInListScreenState extends ConsumerState { return _buildOrderTable( orders: orders, - totalCount: orders.length, + totalCount: result.total, page: result.page, + pageSize: result.pageSize, showStatusFilter: filterStatus == 'exclude_pending', showNewButton: showNewButton, warehouseOptions: warehouseOptions, @@ -150,6 +155,7 @@ class _StockInListScreenState extends ConsumerState { required List orders, required int totalCount, required int page, + required int pageSize, required bool showStatusFilter, required bool showNewButton, required List warehouseOptions, @@ -205,6 +211,12 @@ class _StockInListScreenState extends ConsumerState { return DataCell(StatusBadge(_apiStatusToEnum(o.status))); case 'date': return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); + case 'operator': + return DataCell(Text(o.operatorName ?? '-', + style: const TextStyle(fontSize: 13))); + case 'reviewer': + return DataCell(Text(o.reviewerName ?? '-', + style: const TextStyle(fontSize: 13))); case 'actions': return DataCell(Row( mainAxisSize: MainAxisSize.min, @@ -281,8 +293,11 @@ class _StockInListScreenState extends ConsumerState { return DataTableCard( totalCount: totalCount, page: page, + pageSize: pageSize, onPageChanged: (p) => ref.read(stockInListProvider.notifier).setPage(p), + onPageSizeChanged: (s) => + ref.read(stockInListProvider.notifier).setPageSize(s), toolbar: Row( children: [ if (showNewButton) @@ -321,6 +336,19 @@ class _StockInListScreenState extends ConsumerState { ), ], const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => exportExcel( + filename: '入库单列表', + headers: ['单号', '仓库', '供应商', '状态', '日期', '总金额'], + rows: orders.map((o) => [ + o.orderNo, o.warehouseName ?? '', o.partnerName ?? '', + o.status, o.orderDate, o.totalAmount, + ]).toList(), + ), + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), + const SizedBox(width: 8), ColumnToggleButton( columns: _colDefs, hidden: _hiddenCols, @@ -509,7 +537,7 @@ class _StockInListScreenState extends ConsumerState { } // Detail dialog — fetches full order with items -class _StockInDetailDialog extends StatefulWidget { +class _StockInDetailDialog extends ConsumerStatefulWidget { final int orderId; final StockInRepository repository; @@ -519,16 +547,31 @@ class _StockInDetailDialog extends StatefulWidget { }); @override - State<_StockInDetailDialog> createState() => _StockInDetailDialogState(); + ConsumerState<_StockInDetailDialog> createState() => _StockInDetailDialogState(); } -class _StockInDetailDialogState extends State<_StockInDetailDialog> { +class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { late Future _future; + Map _inventoryMap = {}; @override void initState() { super.initState(); - _future = widget.repository.get(widget.orderId); + _future = widget.repository.get(widget.orderId).then((order) async { + try { + final result = await ref + .read(inventoryRepositoryProvider) + .listInventory(warehouseId: order.warehouseId, pageSize: 500); + if (mounted) { + setState(() { + _inventoryMap = { + for (final inv in result.data) inv.productId: inv.quantity + }; + }); + } + } catch (_) {} + return order; + }); } @override @@ -631,40 +674,71 @@ class _StockInDetailDialogState extends State<_StockInDetailDialog> { border: TableBorder.all(color: AppTheme.border, width: 0.5), columnWidths: const { 0: FixedColumnWidth(36), - 1: FlexColumnWidth(3), + 1: FlexColumnWidth(2.5), 2: FlexColumnWidth(1.5), 3: FlexColumnWidth(1.5), 4: FlexColumnWidth(1.5), 5: FlexColumnWidth(1.5), + 6: FlexColumnWidth(1.5), + 7: FlexColumnWidth(1.2), }, children: [ TableRow( decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), - children: ['序号', '商品', '规格', '数量', '单价', '金额'] - .map((h) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 8), - child: Text(h, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - )) - .toList(), + children: ['序号', '名称', '系列', '规格', '数量', '单价', '金额', '当前库存'] + .asMap().entries.map((e) { + final i = e.key; final h = e.value; + if (i == 0) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(h, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), + ); + } + return TableCell( + verticalAlignment: TableCellVerticalAlignment.fill, + child: Container( + color: h == '当前库存' ? const Color(0xFFCFE2FF) : Colors.transparent, + child: Center( + child: Text(h, style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600, + color: h == '当前库存' ? AppTheme.primary : AppTheme.primaryDark)), + ), + ), + ); + }).toList(), ), ...o.items.asMap().entries.map((e) { final i = e.key; final item = e.value; + final qty = item.productId != null ? _inventoryMap[item.productId] : null; + final invColor = qty == null + ? AppTheme.textSecondary + : qty <= 0 + ? AppTheme.danger + : AppTheme.primary; return TableRow( decoration: BoxDecoration( color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)), children: [ _TableCell('${i + 1}'), _TableCell(item.productName ?? '-'), + _TableCell(item.productSeries ?? '-'), _TableCell(item.productSpec ?? '-'), _TableCell(item.quantity.toStringAsFixed(3)), _TableCell('¥${item.unitPrice.toStringAsFixed(2)}'), _TableCell('¥${item.totalPrice.toStringAsFixed(2)}'), + TableCell( + verticalAlignment: TableCellVerticalAlignment.fill, + child: Container( + color: const Color(0xFFEBF3FF), + child: Center( + child: Text( + qty != null ? qty.toStringAsFixed(0) : '-', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: invColor), + ), + ), + ), + ), ], ); }), diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index 5590daf..2a4a32e 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -3,9 +3,10 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; -import '../../models/product.dart'; +import '../../core/auth/auth_state.dart'; import '../../providers/inventory_provider.dart'; import '../../providers/partner_provider.dart'; +import '../../providers/product_option_provider.dart'; import '../../providers/product_provider.dart'; import '../../providers/stock_out_provider.dart'; import '../../providers/warehouse_provider.dart'; @@ -26,7 +27,6 @@ class _StockOutFormScreenState extends ConsumerState { DateTime _orderDate = DateTime.now(); bool _submitting = false; bool _loadingEdit = false; - // productId → available quantity in selected warehouse Map _inventoryMap = {}; final List<_ItemRow> _items = []; @@ -46,9 +46,11 @@ class _StockOutFormScreenState extends ConsumerState { Future _loadEditOrder() async { setState(() => _loadingEdit = true); try { - final order = await ref - .read(stockOutRepositoryProvider) - .get(widget.editOrderId!); + final order = await ref.read(stockOutRepositoryProvider).get(widget.editOrderId!); + final nameOpts = await ref.read(productNameListProvider.future); + final seriesOpts = await ref.read(productSeriesListProvider.future); + final specOpts = await ref.read(productSpecListProvider.future); + setState(() { _warehouseId = order.warehouseId; _partnerId = order.partnerId; @@ -62,6 +64,9 @@ class _StockOutFormScreenState extends ConsumerState { row.productId = item.productId; row.qtyCtrl.text = item.quantity.toStringAsFixed(0); row.priceCtrl.text = item.unitPrice.toStringAsFixed(2); + row.selectedNameId = nameOpts.where((o) => o.name == item.productName).firstOrNull?.id; + row.selectedSeriesId = seriesOpts.where((o) => o.name == item.productSeries).firstOrNull?.id; + row.selectedSpecId = specOpts.where((o) => o.name == item.productSpec).firstOrNull?.id; _items.add(row); } if (_items.isEmpty) _items.add(_ItemRow()); @@ -103,9 +108,7 @@ class _StockOutFormScreenState extends ConsumerState { .read(inventoryRepositoryProvider) .listInventory(warehouseId: warehouseId, pageSize: 500); setState(() { - _inventoryMap = { - for (final inv in result.data) inv.productId: inv.quantity - }; + _inventoryMap = {for (final inv in result.data) inv.productId: inv.quantity}; }); } catch (_) {} } @@ -138,6 +141,39 @@ class _StockOutFormScreenState extends ConsumerState { setState(() => _submitting = true); + final nameOpts = ref.read(productNameListProvider).valueOrNull ?? []; + final seriesOpts = ref.read(productSeriesListProvider).valueOrNull ?? []; + final specOpts = ref.read(productSpecListProvider).valueOrNull ?? []; + + for (final item in _items) { + if (item.productId == null) { + final name = nameOpts.where((o) => o.id == item.selectedNameId).firstOrNull?.name ?? ''; + final series = seriesOpts.where((o) => o.id == item.selectedSeriesId).firstOrNull?.name ?? ''; + final spec = specOpts.where((o) => o.id == item.selectedSpecId).firstOrNull?.name ?? ''; + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请选择商品名称'), backgroundColor: AppTheme.danger), + ); + setState(() => _submitting = false); + return; + } + try { + final product = await ref + .read(productRepositoryProvider) + .findOrCreate(name: name, series: series, spec: spec); + item.productId = product.id; + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('商品查找失败:$e'), backgroundColor: AppTheme.danger), + ); + setState(() => _submitting = false); + } + return; + } + } + } + final itemsData = _items.map((item) { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; @@ -161,9 +197,7 @@ class _StockOutFormScreenState extends ConsumerState { try { if (_isEdit) { - await ref - .read(stockOutRepositoryProvider) - .update(widget.editOrderId!, data); + await ref.read(stockOutRepositoryProvider).update(widget.editOrderId!, data); ref.read(stockOutListProvider.notifier).reload(); } else { await ref.read(stockOutListProvider.notifier).createOrder(data); @@ -180,8 +214,7 @@ class _StockOutFormScreenState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('操作失败:$e'), backgroundColor: AppTheme.danger), + SnackBar(content: Text('操作失败:$e'), backgroundColor: AppTheme.danger), ); } } finally { @@ -193,6 +226,7 @@ class _StockOutFormScreenState extends ConsumerState { Widget build(BuildContext context) { final asyncWarehouses = ref.watch(warehouseListProvider); final asyncCustomers = ref.watch(customerListProvider); + final currentUser = ref.watch(authStateProvider).user; return Scaffold( backgroundColor: AppTheme.background, @@ -211,8 +245,7 @@ class _StockOutFormScreenState extends ConsumerState { ), const SizedBox(width: 8), Text(_isEdit ? '修改出库单' : '新建出库单', - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600)), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), OutlinedButton( onPressed: _submitting ? null : () => _submit(true), @@ -225,8 +258,7 @@ class _StockOutFormScreenState extends ConsumerState { ? const SizedBox( width: 14, height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.send, size: 16), label: const Text('提交审核'), ), @@ -243,228 +275,211 @@ class _StockOutFormScreenState extends ConsumerState { if (_loadingEdit) const Expanded(child: Center(child: CircularProgressIndicator())), if (!_loadingEdit) - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('基本信息', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - const SizedBox(height: 16), - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - // Warehouse dropdown - _FormField( - label: '出库仓库', - required: true, - child: asyncWarehouses.when( - loading: () => - const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (warehouses) => - DropdownButtonFormField( - value: _warehouseId, - hint: const Text('请选择仓库', - style: - TextStyle(fontSize: 13)), - items: warehouses - .map((w) => DropdownMenuItem( - value: w.id, - child: Text(w.name, - style: const TextStyle( - fontSize: 13)))) - .toList(), - onChanged: (v) { - setState(() => _warehouseId = v); - if (v != null) _loadInventory(v); - }, - validator: (v) => - v == null ? '不能为空' : null, - decoration: const InputDecoration(), - ), - ), - ), - // Customer dropdown - _FormField( - label: '客户', - child: asyncCustomers.when( - loading: () => - const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (result) => - DropdownButtonFormField( - value: _partnerId, - hint: const Text('请选择客户', - style: - TextStyle(fontSize: 13)), - items: result.data - .map((p) => DropdownMenuItem( - value: p.id, - child: Text(p.name, - style: const TextStyle( - fontSize: 13)))) - .toList(), - onChanged: (v) => setState( - () => _partnerId = v), - decoration: const InputDecoration(), - ), - ), - ), - // Date picker - _FormField( - label: '出库日期', - required: true, - child: InkWell( - onTap: _pickDate, - child: InputDecorator( - decoration: - const InputDecoration(), - child: Row( - children: [ - Expanded( - child: Text( - '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', - style: const TextStyle( - fontSize: 13), - ), - ), - const Icon( - Icons.calendar_today, - size: 16, - color: - AppTheme.textSecondary), - ], + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('基本信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _FormField( + label: '出库仓库', + required: true, + child: asyncWarehouses.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (warehouses) => DropdownButtonFormField( + value: _warehouseId, + hint: const Text('请选择仓库', + style: TextStyle(fontSize: 13)), + items: warehouses + .map((w) => DropdownMenuItem( + value: w.id, + child: Text(w.name, + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (v) { + setState(() => _warehouseId = v); + if (v != null) _loadInventory(v); + }, + validator: (v) => v == null ? '不能为空' : null, + decoration: const InputDecoration(), ), ), ), - ), - ], - ), - const SizedBox(height: 16), - _FormField( - label: '备注', - width: double.infinity, - child: TextFormField( - controller: _remarkCtrl, - maxLines: 2, - decoration: const InputDecoration( - hintText: '选填,如有特殊说明请在此注明', - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('商品明细', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - const Spacer(), - ElevatedButton.icon( - onPressed: _addItem, - icon: const Icon(Icons.add, size: 16), - label: const Text('添加商品'), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 32)), - ), - ], - ), - const SizedBox(height: 12), - Table( - columnWidths: const { - 0: FixedColumnWidth(36), - 1: FlexColumnWidth(3), - 2: FlexColumnWidth(1.5), - 3: FlexColumnWidth(1.5), - 4: FlexColumnWidth(1.5), - 5: FixedColumnWidth(60), - }, - children: [ - TableRow( - decoration: const BoxDecoration( - color: Color(0xFFF0F4FF)), - children: [ - '序号', - '商品', - '数量', - '单价', - '金额', - '操作', - ] - .map((h) => Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 8, - vertical: 10), - child: Text(h, - style: const TextStyle( - fontSize: 13, - fontWeight: - FontWeight.w600, - color: AppTheme - .primaryDark)), - )) - .toList(), - ), - ...List.generate( - _items.length, - (i) => _buildItemRow(i)), - ], - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Text('合计金额:', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500)), - Text( - '¥${_totalAmount.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppTheme.danger), + _FormField( + label: '客户', + child: asyncCustomers.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (result) => DropdownButtonFormField( + value: _partnerId, + hint: const Text('请选择客户', + style: TextStyle(fontSize: 13)), + items: result.data + .map((p) => DropdownMenuItem( + value: p.id, + child: Text(p.name, + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (v) => setState(() => _partnerId = v), + decoration: const InputDecoration(), + ), + ), + ), + _FormField( + label: '出库员', + child: InputDecorator( + decoration: const InputDecoration(), + child: Text( + currentUser?.realName ?? '-', + style: const TextStyle(fontSize: 13), + ), + ), + ), + _FormField( + label: '出库日期', + required: true, + child: InkWell( + onTap: _pickDate, + child: InputDecorator( + decoration: const InputDecoration(), + child: Row( + children: [ + Expanded( + child: Text( + '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 13), + ), + ), + const Icon(Icons.calendar_today, + size: 16, color: AppTheme.textSecondary), + ], + ), + ), + ), ), ], ), - ), - ], + const SizedBox(height: 16), + _FormField( + label: '备注', + width: double.infinity, + child: TextFormField( + controller: _remarkCtrl, + maxLines: 2, + decoration: const InputDecoration( + hintText: '选填,如有特殊说明请在此注明', + ), + ), + ), + ], + ), ), ), - ), - ], + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('商品明细', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const Spacer(), + ElevatedButton.icon( + onPressed: _addItem, + icon: const Icon(Icons.add, size: 16), + label: const Text('添加商品'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 32)), + ), + ], + ), + const SizedBox(height: 12), + Table( + columnWidths: const { + 0: FixedColumnWidth(36), + 1: FlexColumnWidth(2.2), + 2: FlexColumnWidth(1.3), + 3: FlexColumnWidth(1.3), + 4: FlexColumnWidth(0.9), + 5: FlexColumnWidth(1.0), + 6: FlexColumnWidth(1.0), + 7: FlexColumnWidth(1.0), + 8: FlexColumnWidth(1.0), + 9: FixedColumnWidth(60), + }, + children: [ + TableRow( + decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), + children: [ + '序号', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '当前库存', '操作', + ] + .map((h) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 10), + child: Text(h, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + )) + .toList(), + ), + ...List.generate(_items.length, (i) => _buildItemRow(i)), + ], + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text('合计金额:', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500)), + Text( + '¥${_totalAmount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.danger), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), ), ), ), - ), ], ), ); @@ -472,75 +487,129 @@ class _StockOutFormScreenState extends ConsumerState { TableRow _buildItemRow(int index) { final item = _items[index]; - final asyncProducts = ref.watch(productListProvider); + final asyncNames = ref.watch(productNameListProvider); + final asyncSeries = ref.watch(productSeriesListProvider); + final asyncSpecs = ref.watch(productSpecListProvider); final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; final amount = qty * price; + final specQty = asyncSpecs.valueOrNull + ?.where((o) => o.id == item.selectedSpecId) + .firstOrNull + ?.quantity ?? 0; return TableRow( decoration: BoxDecoration( color: index.isEven ? Colors.white : const Color(0xFFFAFAFA), ), children: [ + // 序号 Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text('${index + 1}', - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), ), + // 名称 Padding( padding: const EdgeInsets.all(4), - child: asyncProducts.when( + child: asyncNames.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), - data: (result) => DropdownButtonFormField( - value: item.productId, - hint: const Text('选择商品', - style: TextStyle(fontSize: 13)), - items: result.data - .map((p) => DropdownMenuItem( - value: p.id, - child: Text('${p.name}(${p.spec ?? p.unit})', - style: const TextStyle(fontSize: 13)))) + data: (names) => DropdownButtonFormField( + value: item.selectedNameId, + hint: const Text('选择名称', style: TextStyle(fontSize: 12)), + isExpanded: true, + items: names + .map((o) => DropdownMenuItem( + value: o.id, + child: Text(o.name, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis))) .toList(), - onChanged: (v) { - setState(() { - item.productId = v; - if (v != null) { - // Auto-fill sale price - final found = result.data.firstWhere( - (p) => p.id == v, - orElse: () => Product( - id: 0, code: '', name: '', unit: '')); - if (found.salePrice != null) { - item.priceCtrl.text = - found.salePrice!.toStringAsFixed(2); - } - // Auto-fill max available quantity from inventory - final available = _inventoryMap[v] ?? 0; - if (available > 0) { - item.qtyCtrl.text = - available.toStringAsFixed(0); - } - } - }); - }, - validator: (v) => v == null ? '不能为空' : null, - decoration: const InputDecoration(), + onChanged: (v) => setState(() { + item.selectedNameId = v; + item.productId = null; + }), + validator: (v) => v == null ? '请选择' : null, + decoration: const InputDecoration(isDense: true), + style: const TextStyle(fontSize: 12, color: Colors.black87), ), ), ), + // 系列 + Padding( + padding: const EdgeInsets.all(4), + child: asyncSeries.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (series) => DropdownButtonFormField( + value: item.selectedSeriesId, + hint: const Text('选择系列', style: TextStyle(fontSize: 12)), + isExpanded: true, + items: series + .map((o) => DropdownMenuItem( + value: o.id, + child: Text(o.name, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis))) + .toList(), + onChanged: (v) => setState(() { + item.selectedSeriesId = v; + item.productId = null; + }), + decoration: const InputDecoration(isDense: true), + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ), + ), + // 规格 + Padding( + padding: const EdgeInsets.all(4), + child: asyncSpecs.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (specs) => DropdownButtonFormField( + value: item.selectedSpecId, + hint: const Text('选择规格', style: TextStyle(fontSize: 12)), + isExpanded: true, + items: specs + .map((o) => DropdownMenuItem( + value: o.id, + child: Text(o.name, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis))) + .toList(), + onChanged: (v) => setState(() { + item.selectedSpecId = v; + item.productId = null; + }), + validator: (v) => v == null ? '请选择' : null, + decoration: const InputDecoration(isDense: true), + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ), + ), + // 单品数量 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Text( + specQty > 0 ? '$specQty' : '-', + style: TextStyle( + fontSize: 13, + color: specQty > 0 ? Colors.black87 : AppTheme.textSecondary, + ), + ), + ), + // 数量 Padding( padding: const EdgeInsets.all(4), child: TextFormField( controller: item.qtyCtrl, - decoration: const InputDecoration(hintText: '0'), + decoration: const InputDecoration(hintText: '0', isDense: true), style: const TextStyle(fontSize: 13), - keyboardType: - const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d+\.?\d{0,2}')) + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) ], onChanged: (_) => setState(() {}), validator: (v) { @@ -550,18 +619,17 @@ class _StockOutFormScreenState extends ConsumerState { }, ), ), + // 单价 Padding( padding: const EdgeInsets.all(4), child: TextFormField( controller: item.priceCtrl, decoration: const InputDecoration( - hintText: '0.00', prefixText: '¥'), + hintText: '0.00', prefixText: '¥', isDense: true), style: const TextStyle(fontSize: 13), - keyboardType: - const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d+\.?\d{0,2}')) + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) ], onChanged: (_) => setState(() {}), validator: (v) { @@ -571,31 +639,54 @@ class _StockOutFormScreenState extends ConsumerState { }, ), ), + // 金额 Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text( '¥${amount.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w500), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), ), + // 当前库存 + _buildInventoryCell(item.productId), + // 操作 Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: IconButton( - icon: const Icon(Icons.delete_outline, - size: 18, color: AppTheme.danger), - onPressed: - _items.length > 1 ? () => _removeItem(index) : null, + icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger), + onPressed: _items.length > 1 ? () => _removeItem(index) : null, tooltip: '删除', padding: EdgeInsets.zero, - constraints: - const BoxConstraints(minWidth: 28, minHeight: 28), + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), ), ], ); } + Widget _buildInventoryCell(int? productId) { + if (productId == null) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)), + ); + } + final qty = _inventoryMap[productId]; + final text = qty != null + ? qty.toStringAsFixed(0) + : (_warehouseId == null ? '选仓库后显示' : '-'); + final color = qty == null + ? AppTheme.textSecondary + : qty <= 0 + ? AppTheme.danger + : AppTheme.primary; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Text(text, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)), + ); + } + Future _pickDate() async { final date = await showDatePicker( context: context, @@ -609,6 +700,9 @@ class _StockOutFormScreenState extends ConsumerState { class _ItemRow { int? productId; + int? selectedNameId; + int? selectedSeriesId; + int? selectedSpecId; final TextEditingController qtyCtrl = TextEditingController(); final TextEditingController priceCtrl = TextEditingController(); @@ -641,12 +735,9 @@ class _FormField extends StatelessWidget { Row( children: [ if (required) - const Text('*', - style: - TextStyle(color: AppTheme.danger, fontSize: 13)), + const Text('*', style: TextStyle(color: AppTheme.danger, fontSize: 13)), Text(label, - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), ], ), const SizedBox(height: 6), diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index b04cffc..c386db7 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -9,6 +9,8 @@ import '../../widgets/data_table_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; +import '../../core/utils/export_util.dart'; +import '../../providers/inventory_provider.dart'; class StockOutListScreen extends ConsumerStatefulWidget { const StockOutListScreen({super.key}); @@ -32,6 +34,8 @@ class _StockOutListScreenState extends ConsumerState { ColDef('amount', '金额', minWidth: 800), ColDef('status', '状态'), ColDef('date', '日期', minWidth: 900), + ColDef('operator', '出库员', minWidth: 1100), + ColDef('reviewer', '审核员', minWidth: 1100), ColDef('actions', '操作', required: true), ]; @@ -136,8 +140,9 @@ class _StockOutListScreenState extends ConsumerState { return _buildOrderTable( orders: orders, - totalCount: orders.length, + totalCount: result.total, page: result.page, + pageSize: result.pageSize, showStatusFilter: filterStatus == 'exclude_pending', showNewButton: showNewButton, warehouseOptions: warehouseOptions, @@ -151,6 +156,7 @@ class _StockOutListScreenState extends ConsumerState { required List orders, required int totalCount, required int page, + required int pageSize, required bool showStatusFilter, required bool showNewButton, required List warehouseOptions, @@ -206,6 +212,12 @@ class _StockOutListScreenState extends ConsumerState { return DataCell(StatusBadge(_apiStatusToEnum(o.status))); case 'date': return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); + case 'operator': + return DataCell(Text(o.operatorName ?? '-', + style: const TextStyle(fontSize: 13))); + case 'reviewer': + return DataCell(Text(o.reviewerName ?? '-', + style: const TextStyle(fontSize: 13))); case 'actions': return DataCell(Row( mainAxisSize: MainAxisSize.min, @@ -282,8 +294,11 @@ class _StockOutListScreenState extends ConsumerState { return DataTableCard( totalCount: totalCount, page: page, + pageSize: pageSize, onPageChanged: (p) => ref.read(stockOutListProvider.notifier).setPage(p), + onPageSizeChanged: (s) => + ref.read(stockOutListProvider.notifier).setPageSize(s), toolbar: Row( children: [ if (showNewButton) @@ -322,6 +337,19 @@ class _StockOutListScreenState extends ConsumerState { ), ], const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => exportExcel( + filename: '出库单列表', + headers: ['单号', '仓库', '客户', '状态', '日期', '总金额'], + rows: orders.map((o) => [ + o.orderNo, o.warehouseName ?? '', o.partnerName ?? '', + o.status, o.orderDate, o.totalAmount, + ]).toList(), + ), + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), + ), + const SizedBox(width: 8), ColumnToggleButton( columns: _colDefs, hidden: _hiddenCols, @@ -514,7 +542,7 @@ class _StockOutListScreenState extends ConsumerState { } // Detail dialog — fetches full order with items -class _StockOutDetailDialog extends StatefulWidget { +class _StockOutDetailDialog extends ConsumerStatefulWidget { final int orderId; final StockOutRepository repository; @@ -524,16 +552,31 @@ class _StockOutDetailDialog extends StatefulWidget { }); @override - State<_StockOutDetailDialog> createState() => _StockOutDetailDialogState(); + ConsumerState<_StockOutDetailDialog> createState() => _StockOutDetailDialogState(); } -class _StockOutDetailDialogState extends State<_StockOutDetailDialog> { +class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> { late Future _future; + Map _inventoryMap = {}; @override void initState() { super.initState(); - _future = widget.repository.get(widget.orderId); + _future = widget.repository.get(widget.orderId).then((order) async { + try { + final result = await ref + .read(inventoryRepositoryProvider) + .listInventory(warehouseId: order.warehouseId, pageSize: 500); + if (mounted) { + setState(() { + _inventoryMap = { + for (final inv in result.data) inv.productId: inv.quantity + }; + }); + } + } catch (_) {} + return order; + }); } @override @@ -635,40 +678,82 @@ class _StockOutDetailDialogState extends State<_StockOutDetailDialog> { border: TableBorder.all(color: AppTheme.border, width: 0.5), columnWidths: const { 0: FixedColumnWidth(36), - 1: FlexColumnWidth(3), + 1: FlexColumnWidth(2.5), 2: FlexColumnWidth(1.5), 3: FlexColumnWidth(1.5), 4: FlexColumnWidth(1.5), 5: FlexColumnWidth(1.5), + 6: FlexColumnWidth(1.5), + 7: FlexColumnWidth(1.2), }, children: [ TableRow( decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), - children: ['序号', '商品', '规格', '数量', '单价', '金额'] - .map((h) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 8), + children: ['序号', '名称', '系列', '规格', '数量', '单价', '金额', '当前库存'] + .asMap() + .entries + .map((e) { + final i = e.key; + final h = e.value; + // 第一个格子用 Padding 提供行高参照 + if (i == 0) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text(h, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), - )) + ); + } + return TableCell( + verticalAlignment: TableCellVerticalAlignment.fill, + child: Container( + color: h == '当前库存' ? const Color(0xFFCFE2FF) : Colors.transparent, + child: Center( + child: Text(h, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: h == '当前库存' ? AppTheme.primary : AppTheme.primaryDark)), + ), + ), + ); + }) .toList(), ), ...o.items.asMap().entries.map((e) { final i = e.key; final item = e.value; + final qty = item.productId != null ? _inventoryMap[item.productId] : null; + final invColor = qty == null + ? AppTheme.textSecondary + : qty <= 0 + ? AppTheme.danger + : AppTheme.primary; return TableRow( decoration: BoxDecoration( color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)), children: [ _TableCell('${i + 1}'), _TableCell(item.productName ?? '-'), + _TableCell(item.productSeries ?? '-'), _TableCell(item.productSpec ?? '-'), _TableCell(item.quantity.toStringAsFixed(3)), _TableCell('¥${item.unitPrice.toStringAsFixed(2)}'), _TableCell('¥${item.totalPrice.toStringAsFixed(2)}'), + TableCell( + verticalAlignment: TableCellVerticalAlignment.fill, + child: Container( + color: const Color(0xFFEBF3FF), + child: Center( + child: Text( + qty != null ? qty.toStringAsFixed(0) : '-', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: invColor), + ), + ), + ), + ), ], ); }), diff --git a/client/lib/widgets/data_table_card.dart b/client/lib/widgets/data_table_card.dart index 4cb1caf..0c3e5e6 100644 --- a/client/lib/widgets/data_table_card.dart +++ b/client/lib/widgets/data_table_card.dart @@ -9,6 +9,7 @@ class DataTableCard extends StatefulWidget { final int page; final int pageSize; final ValueChanged? onPageChanged; + final ValueChanged? onPageSizeChanged; const DataTableCard({ super.key, @@ -19,6 +20,7 @@ class DataTableCard extends StatefulWidget { this.page = 1, this.pageSize = 20, this.onPageChanged, + this.onPageSizeChanged, }); @override @@ -91,6 +93,7 @@ class _DataTableCardState extends State { total: widget.totalCount!, pageSize: widget.pageSize, onPageChanged: widget.onPageChanged, + onPageSizeChanged: widget.onPageSizeChanged, ), ], ), @@ -157,19 +160,45 @@ class _PaginationBar extends StatelessWidget { final int total; final int pageSize; final ValueChanged? onPageChanged; + final ValueChanged? onPageSizeChanged; + + static const _pageSizeOptions = [10, 20, 50, 100]; const _PaginationBar({ required this.page, required this.total, required this.pageSize, this.onPageChanged, + this.onPageSizeChanged, }); @override Widget build(BuildContext context) { final totalPages = (total / pageSize).ceil().clamp(1, 9999); + // 确保当前 pageSize 在选项中(如默认值 50 不在列表时仍能显示) + final effectiveSize = _pageSizeOptions.contains(pageSize) ? pageSize : _pageSizeOptions.first; return Row( children: [ + // 每页条数选择器 + if (onPageSizeChanged != null) ...[ + const Text('每页', style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(width: 4), + DropdownButtonHideUnderline( + child: DropdownButton( + value: effectiveSize, + isDense: true, + style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary), + items: _pageSizeOptions + .map((n) => DropdownMenuItem(value: n, child: Text('$n 条'))) + .toList(), + onChanged: (v) { + if (v != null) onPageSizeChanged!(v); + }, + ), + ), + const SizedBox(width: 16), + ], + // 翻页控件 IconButton( icon: const Icon(Icons.first_page, size: 18), onPressed: page > 1 ? () => onPageChanged?.call(1) : null, @@ -182,20 +211,16 @@ class _PaginationBar extends StatelessWidget { padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), - Text('$page / $totalPages', - style: const TextStyle(fontSize: 13)), + Text('$page / $totalPages', style: const TextStyle(fontSize: 13)), IconButton( icon: const Icon(Icons.chevron_right, size: 18), - onPressed: - page < totalPages ? () => onPageChanged?.call(page + 1) : null, + onPressed: page < totalPages ? () => onPageChanged?.call(page + 1) : null, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), IconButton( icon: const Icon(Icons.last_page, size: 18), - onPressed: page < totalPages - ? () => onPageChanged?.call(totalPages) - : null, + onPressed: page < totalPages ? () => onPageChanged?.call(totalPages) : null, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), diff --git a/client/lib/widgets/select_product_dialog.dart b/client/lib/widgets/select_product_dialog.dart new file mode 100644 index 0000000..002a8f3 --- /dev/null +++ b/client/lib/widgets/select_product_dialog.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:lpinyin/lpinyin.dart'; +import '../models/product.dart'; + +class SelectProductDialog extends StatefulWidget { + final List products; + final int? selectedId; + + const SelectProductDialog({ + super.key, + required this.products, + this.selectedId, + }); + + @override + State createState() => _SelectProductDialogState(); +} + +class _SelectProductDialogState extends State { + final _searchCtrl = TextEditingController(); + List _filtered = []; + + // 缓存每个商品的拼音索引,避免搜索时重复计算 + late final Map _pinyinCache; + + @override + void initState() { + super.initState(); + _pinyinCache = { + for (final p in widget.products) p.id: _PinyinIndex.from(p), + }; + _filtered = widget.products; + _searchCtrl.addListener(_onSearch); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + void _onSearch() { + final q = _searchCtrl.text.trim().toLowerCase(); + setState(() { + if (q.isEmpty) { + _filtered = widget.products; + } else { + _filtered = widget.products.where((p) { + // 原文匹配(名称、规格、编码、条码) + if (p.name.toLowerCase().contains(q)) return true; + if (p.spec?.toLowerCase().contains(q) ?? false) return true; + if (p.code.toLowerCase().contains(q)) return true; + if (p.barcode?.toLowerCase().contains(q) ?? false) return true; + // 拼音匹配(全拼 or 首字母) + final idx = _pinyinCache[p.id]!; + if (idx.fullPinyin.contains(q)) return true; + if (idx.initials.contains(q)) return true; + return false; + }).toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + // 每行约 56px,窗口默认显示约 6 行,可滚动查看全部 + const listHeight = 56.0 * 6; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 标题栏 + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 0), + child: Row( + children: [ + const Text('选择商品', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const Spacer(), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close, size: 20), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + // 搜索框 + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: _searchCtrl, + autofocus: true, + decoration: InputDecoration( + hintText: '搜索名称 / 规格 / 编码 / 拼音首字母...', + prefixIcon: const Icon(Icons.search, size: 18), + suffixIcon: _searchCtrl.text.isNotEmpty + ? IconButton( + onPressed: _searchCtrl.clear, + icon: const Icon(Icons.clear, size: 16), + visualDensity: VisualDensity.compact, + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const Divider(height: 1), + // 商品列表(固定高度,可滚动) + if (_filtered.isEmpty) + const SizedBox( + height: listHeight, + child: Center( + child: Text('无匹配商品', style: TextStyle(color: Colors.grey)), + ), + ) + else + SizedBox( + height: listHeight, + child: ListView.separated( + itemCount: _filtered.length, + separatorBuilder: (_, __) => + const Divider(height: 1, indent: 16, endIndent: 16), + itemBuilder: (context, i) { + final p = _filtered[i]; + final isSelected = p.id == widget.selectedId; + return ListTile( + dense: true, + selected: isSelected, + selectedTileColor: Colors.blue.shade50, + title: Text( + p.name, + style: const TextStyle(fontSize: 13), + ), + subtitle: Text( + [ + if (p.spec != null && p.spec!.isNotEmpty) p.spec!, + p.unit, + if (p.code.isNotEmpty) '编码: ${p.code}', + ].join(' · '), + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue, size: 18) + : null, + onTap: () => Navigator.pop(context, p), + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} + +/// 每个商品的拼音索引:全拼 + 首字母,在 initState 时一次性计算并缓存 +class _PinyinIndex { + final String fullPinyin; // "longjingcha" + final String initials; // "ljc" + + const _PinyinIndex({required this.fullPinyin, required this.initials}); + + factory _PinyinIndex.from(Product p) { + // 拼接名称 + 规格作为拼音转换源 + final text = '${p.name}${p.spec ?? ''}'; + final full = PinyinHelper.getPinyinE(text, separator: '', defPinyin: '') + .toLowerCase(); + final init = PinyinHelper.getShortPinyin(text).toLowerCase(); + return _PinyinIndex(fullPinyin: full, initials: init); + } +} diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index cc667fc..c2314b3 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,13 @@ import FlutterMacOS import Foundation +import file_picker import package_info_plus import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/client/macos/Podfile.lock b/client/macos/Podfile.lock index bdfeb77..e2c8248 100644 --- a/client/macos/Podfile.lock +++ b/client/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - file_picker (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - package_info_plus (0.0.1): - FlutterMacOS @@ -9,12 +11,15 @@ PODS: - FlutterMacOS DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos FlutterMacOS: :path: Flutter/ephemeral package_info_plus: @@ -25,6 +30,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb diff --git a/client/macos/Runner/DebugProfile.entitlements b/client/macos/Runner/DebugProfile.entitlements index 3ba6c12..0ee7d91 100644 --- a/client/macos/Runner/DebugProfile.entitlements +++ b/client/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,9 @@ com.apple.security.network.server + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-only + diff --git a/client/macos/Runner/Release.entitlements b/client/macos/Runner/Release.entitlements index ee95ab7..88dbe71 100644 --- a/client/macos/Runner/Release.entitlements +++ b/client/macos/Runner/Release.entitlements @@ -6,5 +6,9 @@ com.apple.security.network.client + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-only + diff --git a/client/pubspec.lock b/client/pubspec.lock index 1521d39..f84144b 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +65,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" dio: dependency: "direct main" description: @@ -57,6 +97,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + excel: + dependency: "direct main" + description: + name: excel + sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780" + url: "https://pub.dev" + source: hosted + version: "4.0.6" fake_async: dependency: transitive description: @@ -81,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" flutter: dependency: "direct main" description: flutter @@ -94,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_riverpod: dependency: "direct main" description: @@ -112,6 +184,14 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" go_router: dependency: "direct main" description: @@ -120,6 +200,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" http: dependency: transitive description: @@ -152,6 +240,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" leak_tracker: dependency: transitive description: @@ -200,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lpinyin: + dependency: "direct main" + description: + name: lpinyin + sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a" + url: "https://pub.dev" + source: hosted + version: "2.0.3" matcher: dependency: transitive description: @@ -232,6 +344,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -256,6 +392,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -280,6 +440,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -296,6 +464,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: @@ -533,6 +717,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 4e09f0b..6f10bd6 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -16,6 +16,10 @@ dependencies: intl: ^0.19.0 package_info_plus: ^8.0.0 url_launcher: ^6.3.0 + lpinyin: ^2.0.3 + excel: ^4.0.6 + path_provider: ^2.1.0 + file_picker: ^8.1.6 dev_dependencies: flutter_test: diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..a96e520 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# 本地一键构建 + 部署脚本 +# 用法:bash deploy/deploy.sh +set -e + +EC2_HOST="18.136.60.128" +EC2_USER="ec2-user" +EC2_KEY="$HOME/.ssh/wangjia.pem" +DOMAIN="jiu.51yanmei.com" + +export PATH="/opt/homebrew/bin:$PATH" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +echo "=== 运行后端测试 ===" +cd backend +go test ./... +cd "$REPO_ROOT" + +echo "=== 运行前端测试 ===" +cd client +flutter test +cd "$REPO_ROOT" + +echo "=== 编译 Go 后端(linux/amd64) ===" +cd backend +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server . +cd "$REPO_ROOT" + +echo "=== 编译 Flutter Web ===" +cd client +flutter build web --release --dart-define=BASE_URL=https://$DOMAIN +cd "$REPO_ROOT" + +echo "=== 上传后端二进制 ===" +scp -i "$EC2_KEY" backend/jiu-server $EC2_USER@$EC2_HOST:/tmp/jiu-server + +echo "=== 上传前端静态文件 ===" +rsync -avz --delete -e "ssh -i $EC2_KEY" \ + client/build/web/ $EC2_USER@$EC2_HOST:/tmp/jiu-web-new/ + +echo "=== 切换文件并重启服务 ===" +ssh -i "$EC2_KEY" $EC2_USER@$EC2_HOST bash << 'REMOTE' +set -e + +# 后端 +mv /tmp/jiu-server /opt/jiu/backend/jiu-server +chmod +x /opt/jiu/backend/jiu-server +sudo systemctl restart jiu +sleep 3 +curl -sf http://localhost:8080/health || { echo "健康检查失败!"; exit 1; } +echo "后端重启成功" + +# 前端(原子替换) +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 systemctl reload nginx +echo "前端已更新" +REMOTE + +echo "" +echo "部署完成:https://$DOMAIN" diff --git a/deploy/docker-compose.jiu.yml b/deploy/docker-compose.jiu.yml new file mode 100644 index 0000000..7960d43 --- /dev/null +++ b/deploy/docker-compose.jiu.yml @@ -0,0 +1,17 @@ +version: "3.9" +services: + jiu-mysql: + image: mysql:8.0 + container_name: jiu_mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} + MYSQL_DATABASE: jiu_db + ports: + - "127.0.0.1:3306:3306" + volumes: + - jiu_mysql_data:/var/lib/mysql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + +volumes: + jiu_mysql_data: diff --git a/deploy/jiu.service b/deploy/jiu.service new file mode 100644 index 0000000..5c69525 --- /dev/null +++ b/deploy/jiu.service @@ -0,0 +1,15 @@ +[Unit] +Description=Jiu Backend +After=network.target docker.service + +[Service] +Type=simple +User=ec2-user +WorkingDirectory=/opt/jiu +ExecStart=/opt/jiu/backend/jiu-server +Restart=on-failure +RestartSec=5 +EnvironmentFile=/opt/jiu/config/production.env + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-jiu.conf b/deploy/nginx-jiu.conf new file mode 100644 index 0000000..88c0325 --- /dev/null +++ b/deploy/nginx-jiu.conf @@ -0,0 +1,38 @@ +server { + listen 127.0.0.1:8445 ssl; + server_name jiu.51yanmei.com; + + ssl_certificate /etc/letsencrypt/live/jiu.51yanmei.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jiu.51yanmei.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + root /opt/jiu/web; + index index.html; + + # 商品图片静态文件 + location /images/ { + alias /opt/jiu/images/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # API 反向代理 + location ~ ^/(api|health|version) { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 30s; + } + + # Flutter SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} + +server { + listen 80; + server_name jiu.51yanmei.com; + return 301 https://$host$request_uri; +} diff --git a/deploy/production.env.template b/deploy/production.env.template new file mode 100644 index 0000000..798f475 --- /dev/null +++ b/deploy/production.env.template @@ -0,0 +1,11 @@ +SERVER_MODE=release +SERVER_PORT=8080 +SERVER_CORS_ORIGIN=https://jiu.51yanmei.com +DATABASE_DSN=root:CHANGE_ME_DB_PASS@tcp(127.0.0.1:3306)/jiu_db?charset=utf8mb4&parseTime=True&loc=Local +JWT_SECRET=CHANGE_ME_RANDOM_32CHARS +JWT_ACCESS_EXPIRE_MIN=60 +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 +DB_PASSWORD=CHANGE_ME_DB_PASS diff --git a/deploy/setup-ec2.sh b/deploy/setup-ec2.sh new file mode 100644 index 0000000..e028955 --- /dev/null +++ b/deploy/setup-ec2.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# EC2 一次性初始化脚本(Amazon Linux 2023) +# 前提:以下文件已 scp 到 /opt/jiu/config/ +# - production.env(填好真实密钥) +# - docker-compose.jiu.yml +# - nginx-jiu.conf +# - jiu.service +set -e + +echo "=== Step 0: 清理 Docker 悬空镜像 ===" +docker image prune -f + +echo "=== Step 1: 创建目录结构 ===" +sudo mkdir -p /opt/jiu/{backend,web,config} +sudo chown -R ec2-user:ec2-user /opt/jiu + +echo "=== Step 2: 启动 MySQL ===" +cd /opt/jiu/config +# 从 production.env 读取 DB_PASSWORD 并写入临时 .env 供 docker-compose 使用 +grep ^DB_PASSWORD production.env | sed 's/^DB_PASSWORD=/DB_PASSWORD=/' > .env +docker-compose -f docker-compose.jiu.yml up -d +rm -f .env +echo "等待 MySQL 就绪(20秒)..." +sleep 20 +docker ps | grep jiu_mysql && echo "MySQL 已启动" || { echo "MySQL 启动失败!"; exit 1; } + +echo "=== Step 3: 安装 systemd 服务 ===" +sudo cp /opt/jiu/config/jiu.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable jiu +echo "jiu.service 已注册(首次部署后再 start)" + +echo "=== Step 4: 获取 SSL 证书 ===" +echo "注意:需要 jiu.51yanmei.com 的 DNS A 记录已指向此 EC2" +sudo certbot certonly --standalone \ + --pre-hook "systemctl stop nginx" \ + --post-hook "systemctl start nginx" \ + -d jiu.51yanmei.com \ + --non-interactive --agree-tos -m admin@51yanmei.com + +echo "=== Step 5: 安装 Nginx 配置 ===" +sudo cp /opt/jiu/config/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf + +echo "=== Step 6: 在 stream.conf 添加 SNI 路由 ===" +if grep -q "jiu.51yanmei.com" /etc/nginx/stream.conf; then + echo "SNI 路由已存在,跳过" +else + sudo sed -i '/default.*10443/i\ jiu.51yanmei.com 127.0.0.1:8445;' /etc/nginx/stream.conf + echo "SNI 路由已添加" +fi + +sudo nginx -t && sudo systemctl reload nginx + +echo "" +echo "=== EC2 初始化完成 ===" +echo "请在 Mac 上运行:bash deploy/deploy.sh" diff --git a/scripts/dev.sh b/scripts/dev.sh index 197ad28..2350b7a 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -6,10 +6,13 @@ fi # dev.sh — 本地开发环境管理 # # 启动: -# sh scripts/dev.sh run # 启动前后端(后端无改动则跳过重启) +# sh scripts/dev.sh run # 启动前后端(macOS 桌面端) # sh scripts/dev.sh run --force # 强制重启后端 +# sh scripts/dev.sh run --web # 启动前后端(Chrome Web 端) +# sh scripts/dev.sh run --all # 启动前后端(macOS + Web 同时) # sh scripts/dev.sh --backend-only # 仅启动后端 -# sh scripts/dev.sh --frontend-only # 仅启动前端 +# sh scripts/dev.sh --frontend-only # 仅启动前端(macOS) +# sh scripts/dev.sh --web-only # 仅启动前端(Web) # # 数据库: # sh scripts/dev.sh seed H001 # 清空并写入 H001 测试数据(SQL 文件) @@ -27,6 +30,8 @@ LOG_DIR="$ROOT/scripts/.logs" BACKEND_LOG="$LOG_DIR/backend.log" BACKEND_PID_FILE="$LOG_DIR/backend.pid" BACKEND_HASH_FILE="$LOG_DIR/backend.hash" +WEB_LOG="$LOG_DIR/web.log" +WEB_PID_FILE="$LOG_DIR/web.pid" mkdir -p "$LOG_DIR" @@ -86,11 +91,16 @@ error() { echo -e "${RED}[dev]${NC} $*"; } COMMAND="${1:-run}" FORCE=false RUN_BACKEND=true -RUN_FRONTEND=true +RUN_FRONTEND=true # macOS +RUN_WEB=false case "$COMMAND" in run) - [ "${2:-}" = "--force" ] && FORCE=true + case "${2:-}" in + --force) FORCE=true ;; + --web) RUN_FRONTEND=false; RUN_WEB=true ;; + --all) RUN_WEB=true ;; + esac ;; seed) cmd_seed "${2:-}" @@ -117,14 +127,23 @@ case "$COMMAND" in COMMAND=run RUN_BACKEND=false ;; + --web-only) + COMMAND=run + RUN_BACKEND=false + RUN_FRONTEND=false + RUN_WEB=true + ;; stop) - # 优先用 PID 文件 if [ -f "$BACKEND_PID_FILE" ]; then pid=$(cat "$BACKEND_PID_FILE") kill -9 "$pid" 2>/dev/null || true rm -f "$BACKEND_PID_FILE" fi - # 无论如何,再用 lsof 兜底清理端口(只杀监听 8080 的进程,不杀连接客户端) + if [ -f "$WEB_PID_FILE" ]; then + pid=$(cat "$WEB_PID_FILE") + kill -9 "$pid" 2>/dev/null || true + rm -f "$WEB_PID_FILE" + fi lsof -ti TCP:8080 -s TCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true sleep 0.5 if lsof -ti TCP:8080 -s TCP:LISTEN >/dev/null 2>&1; then @@ -136,7 +155,7 @@ case "$COMMAND" in ;; *) error "未知命令: $COMMAND" - echo "用法: sh scripts/dev.sh [run [--force] | seed | reset | clear | stop | --backend-only | --frontend-only]" + echo "用法: sh scripts/dev.sh [run [--force|--web|--all] | seed | reset | clear | stop | --backend-only | --frontend-only | --web-only]" exit 1 ;; esac @@ -145,8 +164,13 @@ esac cleanup() { echo "" info "正在关闭服务..." - # 不杀后端:run 模式下后端是持久进程,Ctrl+C 只退出 flutter - info "后端继续在后台运行。如需停止:bash scripts/dev.sh stop" + if [ -f "$WEB_PID_FILE" ]; then + pid=$(cat "$WEB_PID_FILE") + kill "$pid" 2>/dev/null || true + rm -f "$WEB_PID_FILE" + info "Web 服务已停止" + fi + info "后端继续在后台运行。如需停止:sh scripts/dev.sh stop" exit 0 } trap cleanup INT TERM @@ -246,6 +270,25 @@ start_backend() { wait_backend } +# ── 启动 Web 前端(后台) ───────────────────────────────── +start_web_bg() { + info "启动 Flutter Web (Chrome,后台)..." + flutter run -d chrome --web-port 8081 >>"$WEB_LOG" 2>&1 & + local pid=$! + echo "$pid" > "$WEB_PID_FILE" + # 等待 Chrome 打开(最多 15 秒) + local i=0 + while [ $i -lt 15 ]; do + if grep -q "To hot reload\|Launching lib/main.dart" "$WEB_LOG" 2>/dev/null; then + success "Web 已启动 → http://localhost:8081 (日志: tail -f $WEB_LOG)" + return 0 + fi + sleep 1 + i=$((i + 1)) + done + warn "Web 启动较慢,继续等待... (日志: tail -f $WEB_LOG)" +} + # ════════════════════════════════════════════════════════ check_deps @@ -253,16 +296,33 @@ if [ "$RUN_BACKEND" = true ]; then start_backend fi -if [ "$RUN_FRONTEND" = true ]; then - info "启动 Flutter (macOS)..." - cd "$CLIENT_DIR" - flutter pub get +# --all 模式:先在后台启动 Web,再在前台启动 macOS +if [ "$RUN_WEB" = true ] && [ "$RUN_FRONTEND" = true ]; then + cd "$CLIENT_DIR" && flutter pub get + start_web_bg echo "" - success "Flutter 启动中,窗口将自动弹出..." - echo -e "${CYAN}提示:关闭 Flutter 窗口或按 q 退出前端,后端继续运行${NC}" - echo -e "${CYAN} 再次执行 bash scripts/dev.sh run 可复用已有后端${NC}" + success "Flutter macOS 启动中..." + echo -e "${CYAN}提示:macOS 窗口 + Chrome 同时运行,Ctrl+C 退出时 Web 同步停止${NC}" echo "" flutter run -d macos + +elif [ "$RUN_WEB" = true ]; then + cd "$CLIENT_DIR" && flutter pub get + echo "" + success "Chrome 启动中,稍候自动打开..." + echo -e "${CYAN}提示:按 r 热重载,按 q 退出,后端继续在后台运行${NC}" + echo "" + flutter run -d chrome --web-port 8081 + +elif [ "$RUN_FRONTEND" = true ]; then + cd "$CLIENT_DIR" && flutter pub get + echo "" + success "Flutter macOS 启动中,窗口将自动弹出..." + echo -e "${CYAN}提示:按 q 退出前端,后端继续运行${NC}" + echo -e "${CYAN} 再次执行 sh scripts/dev.sh run 可复用已有后端${NC}" + echo "" + flutter run -d macos + else echo "" success "后端运行中,按 Ctrl+C 退出此脚本(后端仍在后台)"