From bb4f17cf7a88faf7f3e7d64aa57890c4ccf84a0c Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Apr 2026 00:28:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E3=80=81=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE=E3=80=81=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 GET /version 版本检查端点(version.go + version.yaml) - 新增 GET /license/info 接口,返回门店授权信息 - 修复 GenerateOrderNo 并发重复单号:事务内加 FOR UPDATE 行锁 - 修复 ApproveStockOut 超卖竞态:预检和库存更新均加 FOR UPDATE - 修复 Product Create 并发 code 冲突:加重试逻辑,schema 加 UNIQUE KEY - 修复 Product Update 全字段覆盖:改用 selective Updates() - 挂载 ReadOnly 中间件(全局)+ AdminOnly(用户管理路由) - version.go 配置缺失时返回 500 而非静默降级 前端: - 新增自动更新检测(update_provider.dart)+ shell 更新 banner/弹窗 - 新增系统设置"关于"标签页:版本、授权、开发信息、意见反馈 - 新增离线缓存:所有 AsyncNotifierProvider 支持断网浏览历史数据 - 新增门店信息弹窗(点击左上角 logo 或右上角门店号触发) - 提取 AppConfig 统一管理 BASE_URL,支持 --dart-define 注入 - update_provider.dart 加 kIsWeb 保护,修复 Web 平台崩溃 - dev.sh 新增 stop 命令,修复 stop 误杀前端进程问题 Co-Authored-By: Claude Sonnet 4.6 --- backend/config/version.yaml | 10 + backend/internal/handler/license.go | 11 + backend/internal/handler/product.go | 56 +- backend/internal/handler/stock_in.go | 75 ++- backend/internal/handler/stock_out.go | 66 +++ backend/internal/handler/version.go | 67 +++ backend/internal/router/router.go | 20 +- backend/internal/service/license.go | 10 + backend/internal/service/stock.go | 17 +- backend/schema/schema.sql | 1 + client/lib/core/api/api_client.dart | 87 ++-- client/lib/core/config/app_config.dart | 17 + client/lib/core/router/app_router.dart | 10 + .../lib/providers/connectivity_provider.dart | 43 ++ client/lib/providers/inventory_provider.dart | 52 +- client/lib/providers/license_provider.dart | 78 +++ .../lib/providers/number_rule_provider.dart | 26 +- client/lib/providers/partner_provider.dart | 26 +- client/lib/providers/product_provider.dart | 26 +- client/lib/providers/stock_in_provider.dart | 31 +- client/lib/providers/stock_out_provider.dart | 31 +- client/lib/providers/update_provider.dart | 162 ++++++ client/lib/providers/user_provider.dart | 26 +- client/lib/providers/warehouse_provider.dart | 26 +- .../lib/repositories/stock_in_repository.dart | 22 + .../repositories/stock_out_repository.dart | 22 + .../lib/screens/finance/finance_screen.dart | 298 ++++++++--- .../inventory/batch_tracking_screen.dart | 414 ++++++++++----- .../inventory/inventory_list_screen.dart | 18 +- .../lib/screens/partners/partners_screen.dart | 12 +- .../lib/screens/products/products_screen.dart | 6 +- .../lib/screens/settings/settings_screen.dart | 491 +++++++++++++++++- client/lib/screens/shell/app_shell.dart | 394 ++++++++++++-- .../stock_in/stock_in_form_screen.dart | 63 ++- .../stock_in/stock_in_list_screen.dart | 430 ++++++++++----- .../stock_out/stock_out_form_screen.dart | 64 ++- .../stock_out/stock_out_list_screen.dart | 430 ++++++++++----- client/lib/widgets/data_table_card.dart | 47 +- client/lib/widgets/multi_select_dropdown.dart | 272 ++++++++++ .../Flutter/GeneratedPluginRegistrant.swift | 4 + client/macos/Podfile.lock | 12 + client/pubspec.lock | 100 +++- client/pubspec.yaml | 2 + scripts/dev.sh | 19 +- 44 files changed, 3384 insertions(+), 710 deletions(-) create mode 100644 backend/config/version.yaml create mode 100644 backend/internal/handler/version.go create mode 100644 client/lib/core/config/app_config.dart create mode 100644 client/lib/providers/connectivity_provider.dart create mode 100644 client/lib/providers/license_provider.dart create mode 100644 client/lib/providers/update_provider.dart create mode 100644 client/lib/widgets/multi_select_dropdown.dart diff --git a/backend/config/version.yaml b/backend/config/version.yaml new file mode 100644 index 0000000..a356ef6 --- /dev/null +++ b/backend/config/version.yaml @@ -0,0 +1,10 @@ +version: "1.1.1" +build_number: 2 +force_update: false +release_notes: "修复了离线模式问题,优化状态栏显示" +download_urls: + macos: "" + windows: "" + ios: "" + android: "" + web: "" diff --git a/backend/internal/handler/license.go b/backend/internal/handler/license.go index 387a22d..5e7e3b7 100644 --- a/backend/internal/handler/license.go +++ b/backend/internal/handler/license.go @@ -52,6 +52,17 @@ func (h *LicenseHandler) Verify(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": lic}) } +// Info GET /api/v1/license/info — 当前门店授权概况(无需 device_id) +func (h *LicenseHandler) Info(c *gin.Context) { + shopID := middleware.GetShopID(c) + lic, err := h.svc.ShopInfo(shopID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"data": nil}) + return + } + c.JSON(http.StatusOK, gin.H{"data": lic}) +} + // Deactivate POST /api/v1/license/deactivate func (h *LicenseHandler) Deactivate(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index eb788d3..28631e4 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -1,6 +1,8 @@ package handler import ( + "errors" + "fmt" "net/http" "strconv" @@ -64,8 +66,34 @@ func (h *ProductHandler) Create(c *gin.Context) { } product.ShopID = shopID - if err := h.db.Create(&product).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // Auto-generate product code if not provided (e.g. P001, P002) + // Retry up to 5 times on duplicate key to handle concurrent creates + if product.Code == "" { + var count int64 + h.db.Model(&model.Product{}). + Where("shop_id = ? AND deleted_at IS NULL", shopID). + Count(&count) + product.Code = fmt.Sprintf("P%03d", count+1) + } + + var createErr error + for attempt := 0; attempt < 5; attempt++ { + if createErr = h.db.Create(&product).Error; createErr == nil { + break + } + if !errors.Is(createErr, gorm.ErrDuplicatedKey) { + break + } + // Duplicate code: try next slot + var count int64 + h.db.Model(&model.Product{}). + Where("shop_id = ? AND deleted_at IS NULL", shopID). + Count(&count) + product.ID = 0 + product.Code = fmt.Sprintf("P%03d", count+int64(attempt)+2) + } + if createErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": product}) @@ -83,16 +111,34 @@ func (h *ProductHandler) Update(c *gin.Context) { return } - if err := c.ShouldBindJSON(&product); err != nil { + var req model.Product + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - product.ShopID = shopID // 防止篡改 - if err := h.db.Save(&product).Error; err != nil { + // 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段 + if err := h.db.Model(&product).Updates(map[string]interface{}{ + "code": req.Code, + "barcode": req.Barcode, + "name": req.Name, + "series": req.Series, + "spec": req.Spec, + "unit": req.Unit, + "category_id": req.CategoryID, + "brand": req.Brand, + "purchase_price": req.PurchasePrice, + "sale_price": req.SalePrice, + "min_stock": req.MinStock, + "remark": req.Remark, + "custom_fields": req.CustomFields, + }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + + // 重新读取完整数据返回 + h.db.Preload("Category").First(&product, product.ID) c.JSON(http.StatusOK, gin.H{"data": product}) } diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index c32acf6..b3b5792 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "strconv" "time" @@ -93,12 +94,15 @@ func (h *StockInHandler) Create(c *gin.Context) { } req.OrderNo = orderNo - // 计算总金额 + // 计算总金额;自动生成批次号 var total float64 for i := range req.Items { req.Items[i].ShopID = shopID req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice total += req.Items[i].TotalPrice + if req.Items[i].BatchNo == "" { + req.Items[i].BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1) + } } req.TotalAmount = total @@ -109,6 +113,75 @@ func (h *StockInHandler) Create(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"data": req}) } +// Update PUT /api/v1/stock-in/orders/:id (只允许草稿状态) +func (h *StockInHandler) Update(c *gin.Context) { + shopID := middleware.GetShopID(c) + + var order model.StockInOrder + if err := h.db.Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID). + First(&order).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "入库单不存在或不可修改"}) + return + } + + var req model.StockInOrder + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockInItem{}).Error; err != nil { + return err + } + var total float64 + for i := range req.Items { + req.Items[i].ShopID = shopID + req.Items[i].OrderID = order.ID + req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice + total += req.Items[i].TotalPrice + if req.Items[i].BatchNo == "" { + req.Items[i].BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1) + } + } + updates := map[string]interface{}{ + "warehouse_id": req.WarehouseID, + "partner_id": req.PartnerID, + "order_date": req.OrderDate, + "remark": req.Remark, + "total_amount": total, + } + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + if len(req.Items) > 0 { + if err := tx.Create(&req.Items).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "updated"}) +} + +// Delete DELETE /api/v1/stock-in/orders/:id (只允许草稿状态) +func (h *StockInHandler) Delete(c *gin.Context) { + shopID := middleware.GetShopID(c) + now := timeNow() + result := h.db.Model(&model.StockInOrder{}). + Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID). + Update("deleted_at", now) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "入库单不存在或不可删除"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + // Submit PUT /api/v1/stock-in/orders/:id/submit (草稿→待审核) func (h *StockInHandler) Submit(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index 9d34076..b2a8cd4 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -136,6 +136,72 @@ func (h *StockOutHandler) Create(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"data": req}) } +// Update PUT /api/v1/stock-out/orders/:id (只允许草稿状态) +func (h *StockOutHandler) Update(c *gin.Context) { + shopID := middleware.GetShopID(c) + + var order model.StockOutOrder + if err := h.db.Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID). + First(&order).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "出库单不存在或不可修改"}) + return + } + + var req model.StockOutOrder + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockOutItem{}).Error; err != nil { + return err + } + var total float64 + for i := range req.Items { + req.Items[i].ShopID = shopID + req.Items[i].OrderID = order.ID + req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice + total += req.Items[i].TotalPrice + } + updates := map[string]interface{}{ + "warehouse_id": req.WarehouseID, + "partner_id": req.PartnerID, + "order_date": req.OrderDate, + "remark": req.Remark, + "total_amount": total, + } + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + if len(req.Items) > 0 { + if err := tx.Create(&req.Items).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "updated"}) +} + +// Delete DELETE /api/v1/stock-out/orders/:id (只允许草稿状态) +func (h *StockOutHandler) Delete(c *gin.Context) { + shopID := middleware.GetShopID(c) + now := timeNow() + result := h.db.Model(&model.StockOutOrder{}). + Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID). + Update("deleted_at", now) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "出库单不存在或不可删除"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + // Submit PUT /api/v1/stock-out/orders/:id/submit func (h *StockOutHandler) Submit(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/version.go b/backend/internal/handler/version.go new file mode 100644 index 0000000..33059f7 --- /dev/null +++ b/backend/internal/handler/version.go @@ -0,0 +1,67 @@ +package handler + +import ( + "log" + "net/http" + "os" + "path/filepath" + "runtime" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" +) + +type versionConfig struct { + Version string `yaml:"version"` + BuildNumber int `yaml:"build_number"` + ForceUpdate bool `yaml:"force_update"` + ReleaseNotes string `yaml:"release_notes"` + DownloadURLs map[string]string `yaml:"download_urls"` +} + +// GetVersion GET /version +func GetVersion(c *gin.Context) { + cfg, err := loadVersionConfig() + if err != nil { + log.Printf("[version] failed to load version config: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "version config unavailable"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "version": cfg.Version, + "build_number": cfg.BuildNumber, + "force_update": cfg.ForceUpdate, + "release_notes": cfg.ReleaseNotes, + "download_urls": cfg.DownloadURLs, + }) +} + +func loadVersionConfig() (*versionConfig, error) { + // 查找 config/version.yaml,相对于可执行文件或源码目录 + candidates := []string{ + "config/version.yaml", + filepath.Join(sourceDir(), "config/version.yaml"), + } + for _, path := range candidates { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var cfg versionConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil + } + return nil, os.ErrNotExist +} + +// sourceDir 返回当前源文件所在目录的上两级(backend 根目录) +func sourceDir() string { + _, filename, _, ok := runtime.Caller(0) + if !ok { + return "." + } + // handler/ → internal/ → backend/ + return filepath.Join(filepath.Dir(filename), "..", "..") +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 8581baa..83e9fdb 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -29,6 +29,14 @@ func Setup(r *gin.Engine, db *gorm.DB) { financeH := handler.NewFinanceHandler(db) numberRuleH := handler.NewNumberRuleHandler(db) + // 健康检查(无需认证,用于前端连通性探测) + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // 版本信息(无需认证,用于客户端更新检查) + r.GET("/version", handler.GetVersion) + v1 := r.Group("/api/v1") // 公开路由(无需登录) @@ -38,13 +46,14 @@ func Setup(r *gin.Engine, db *gorm.DB) { auth.POST("/refresh", authH.Refresh) } - // 需要 JWT 的路由 + // 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作) api := v1.Group("") - api.Use(middleware.JWT()) + api.Use(middleware.JWT(), middleware.ReadOnly()) { // 许可证 license := api.Group("/license") { + license.GET("/info", licenseH.Info) license.POST("/activate", licenseH.Activate) license.GET("/verify", licenseH.Verify) license.POST("/deactivate", licenseH.Deactivate) @@ -83,6 +92,8 @@ func Setup(r *gin.Engine, db *gorm.DB) { stockIn.GET("/orders", stockInH.List) stockIn.GET("/orders/:id", stockInH.Get) stockIn.POST("/orders", stockInH.Create) + stockIn.PUT("/orders/:id", stockInH.Update) + stockIn.DELETE("/orders/:id", stockInH.Delete) stockIn.PUT("/orders/:id/submit", stockInH.Submit) stockIn.PUT("/orders/:id/approve", stockInH.Approve) stockIn.PUT("/orders/:id/reject", stockInH.Reject) @@ -94,6 +105,8 @@ func Setup(r *gin.Engine, db *gorm.DB) { stockOut.GET("/orders", stockOutH.List) stockOut.GET("/orders/:id", stockOutH.Get) stockOut.POST("/orders", stockOutH.Create) + stockOut.PUT("/orders/:id", stockOutH.Update) + stockOut.DELETE("/orders/:id", stockOutH.Delete) stockOut.PUT("/orders/:id/submit", stockOutH.Submit) stockOut.PUT("/orders/:id/approve", stockOutH.Approve) stockOut.PUT("/orders/:id/reject", stockOutH.Reject) @@ -109,8 +122,9 @@ func Setup(r *gin.Engine, db *gorm.DB) { inventory.GET("/checks/:id", inventoryH.GetCheck) } - // 用户管理 + // 用户管理(仅管理员) users := api.Group("/users") + users.Use(middleware.AdminOnly()) { users.GET("", userH.List) users.POST("", userH.Create) diff --git a/backend/internal/service/license.go b/backend/internal/service/license.go index 2f36e5d..ffc3b0f 100644 --- a/backend/internal/service/license.go +++ b/backend/internal/service/license.go @@ -82,6 +82,16 @@ func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License, return &lic, nil } +// ShopInfo 返回门店当前授权信息(取最新一条有效许可证) +func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, error) { + var lic model.License + if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). + Order("id DESC").First(&lic).Error; err != nil { + return nil, ErrLicenseNotFound + } + return &lic, nil +} + // Deactivate 解绑设备(换机时使用) func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error { return s.db.Model(&model.License{}). diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index f44e24e..a42f50f 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -62,11 +62,12 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error return errors.New("order is not in pending status") } - // 预检库存 + // 预检库存(FOR UPDATE 加锁,防止并发审核超卖) for _, item := range order.Items { var inv model.Inventory - if err := tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?", - shopID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil { + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("shop_id = ? AND warehouse_id = ? AND product_id = ?", + shopID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil { return fmt.Errorf("product %d not in inventory", item.ProductID) } if inv.Quantity < item.Quantity { @@ -96,8 +97,9 @@ func (s *StockService) updateInventory(tx *gorm.DB, shopID, warehouseID, product direction string, qty float64, refID uint64, refType string, operatorID uint64) error { var inv model.Inventory - result := tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?", - shopID, warehouseID, productID).First(&inv) + result := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("shop_id = ? AND warehouse_id = ? AND product_id = ?", + shopID, warehouseID, productID).First(&inv) qtyBefore := inv.Quantity var qtyAfter float64 @@ -137,12 +139,13 @@ func (s *StockService) updateInventory(tx *gorm.DB, shopID, warehouseID, product return tx.Create(&log).Error } -// GenerateOrderNo 生成单号(事务安全) +// GenerateOrderNo 生成单号(事务安全,FOR UPDATE 防止并发重复单号) func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string, error) { var no string err := s.db.Transaction(func(tx *gorm.DB) error { var rule model.NumberRule - result := tx.Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule) + result := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule) if result.Error != nil { // 初始化规则 rule = model.NumberRule{ShopID: shopID, Type: orderType, Prefix: orderType[:2], CurrentNo: 0} diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index c2f6671..4672351 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS `products` ( KEY `idx_shop_id` (`shop_id`), KEY `idx_category` (`category_id`), KEY `idx_deleted_at` (`deleted_at`), + UNIQUE KEY `uk_product_code` (`shop_id`, `code`), FULLTEXT KEY `ft_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品'; diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index cf666ee..53a1317 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -2,12 +2,12 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_state.dart'; - -const _baseUrl = 'http://localhost:8080/api/v1'; +import '../config/app_config.dart'; +import '../../providers/connectivity_provider.dart'; /// Public Dio instance for unauthenticated calls (login / refresh) final _publicDio = Dio(BaseOptions( - baseUrl: _baseUrl, + baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), headers: {'Content-Type': 'application/json'}, @@ -31,6 +31,9 @@ final apiClientProvider = Provider((ref) { ref.read(authStateProvider.notifier).logout(); } }, + onConnectionError: () { + ref.read(connectivityProvider.notifier).forceCheck(); + }, ); ref.onDispose(client.dispose); return client; @@ -45,9 +48,10 @@ class ApiClient { String? refreshToken, void Function(String newToken)? onTokenRefreshed, void Function()? onAuthFailed, + void Function()? onConnectionError, }) { _dio = Dio(BaseOptions( - baseUrl: _baseUrl, + baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), headers: { @@ -56,43 +60,46 @@ class ApiClient { }, )); - // 401 auto-refresh 拦截器 - if (refreshToken != null) { - _dio.interceptors.add( - InterceptorsWrapper( - onError: (DioException e, ErrorInterceptorHandler handler) async { - // 如果这个 client 已被替换,忽略所有回调 - if (_disposed) { - return handler.next(e); - } - if (e.response?.statusCode == 401 && refreshToken.isNotEmpty) { - debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...'); - try { - final resp = await _publicDio.post('/auth/refresh', data: { - 'refresh_token': refreshToken, - }); - final newToken = - resp.data['data']['access_token'] as String; - // 原地更新 Dio 默认 headers,后续所有请求生效 - _dio.options.headers['Authorization'] = 'Bearer $newToken'; - if (!_disposed) onTokenRefreshed?.call(newToken); - // 用新 token 重试原请求(此时 _dio 仍存活,不会 adapter closed) - final opts = e.requestOptions; - opts.headers['Authorization'] = 'Bearer $newToken'; - final retryResp = await _dio.fetch(opts); - return handler.resolve(retryResp); - } catch (refreshErr) { - debugPrint('[ApiClient] refresh failed: $refreshErr'); - if (!_disposed) onAuthFailed?.call(); - } - } else if (e.response?.statusCode == 401) { - debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token'); - } + // 网络错误 + 401 拦截器 + _dio.interceptors.add( + InterceptorsWrapper( + onError: (DioException e, ErrorInterceptorHandler handler) async { + if (_disposed) return handler.next(e); + + // 网络层错误(连接拒绝、超时等)→ 立即触发连通性检测 + final isNetworkError = e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.sendTimeout; + if (isNetworkError) { + onConnectionError?.call(); return handler.next(e); - }, - ), - ); - } + } + + if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) { + debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...'); + try { + final resp = await _publicDio.post('/auth/refresh', data: { + 'refresh_token': refreshToken, + }); + final newToken = resp.data['data']['access_token'] as String; + _dio.options.headers['Authorization'] = 'Bearer $newToken'; + if (!_disposed) onTokenRefreshed?.call(newToken); + final opts = e.requestOptions; + opts.headers['Authorization'] = 'Bearer $newToken'; + final retryResp = await _dio.fetch(opts); + return handler.resolve(retryResp); + } catch (refreshErr) { + debugPrint('[ApiClient] refresh failed: $refreshErr'); + if (!_disposed) onAuthFailed?.call(); + } + } else if (e.response?.statusCode == 401) { + debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token'); + } + return handler.next(e); + }, + ), + ); } /// 取消所有进行中的请求,标记实例为已废弃 diff --git a/client/lib/core/config/app_config.dart b/client/lib/core/config/app_config.dart new file mode 100644 index 0000000..d8bb204 --- /dev/null +++ b/client/lib/core/config/app_config.dart @@ -0,0 +1,17 @@ +/// 集中管理应用配置,通过 --dart-define=BASE_URL=... 注入环境变量 +/// +/// 开发默认值:http://localhost:8080 +/// 生产部署示例:flutter run --dart-define=BASE_URL=http://192.168.1.100:8080 +class AppConfig { + const AppConfig._(); + + static const _baseUrl = String.fromEnvironment( + 'BASE_URL', + defaultValue: 'http://localhost:8080', + ); + + static String get baseUrl => _baseUrl; + static String get apiBaseUrl => '$_baseUrl/api/v1'; + static String get healthUrl => '$_baseUrl/health'; + static String get versionUrl => '$_baseUrl/version'; +} diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index fbd4d09..16b39c1 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -82,12 +82,22 @@ final appRouterProvider = Provider((ref) { GoRoute( path: '/stock-in/new', pageBuilder: (_, __) => _noTransition(const StockInFormScreen())), + GoRoute( + path: '/stock-in/edit/:id', + pageBuilder: (_, state) => _noTransition( + StockInFormScreen( + editOrderId: int.parse(state.pathParameters['id']!)))), GoRoute( path: '/stock-out', pageBuilder: (_, __) => _noTransition(const StockOutListScreen())), GoRoute( path: '/stock-out/new', pageBuilder: (_, __) => _noTransition(const StockOutFormScreen())), + GoRoute( + path: '/stock-out/edit/:id', + pageBuilder: (_, state) => _noTransition( + StockOutFormScreen( + editOrderId: int.parse(state.pathParameters['id']!)))), GoRoute( path: '/inventory', pageBuilder: (_, __) => diff --git a/client/lib/providers/connectivity_provider.dart b/client/lib/providers/connectivity_provider.dart new file mode 100644 index 0000000..dc739bc --- /dev/null +++ b/client/lib/providers/connectivity_provider.dart @@ -0,0 +1,43 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/config/app_config.dart'; + +final connectivityProvider = + StateNotifierProvider((ref) { + return ConnectivityNotifier(); +}); + +class ConnectivityNotifier extends StateNotifier { + ConnectivityNotifier() : super(true) { + _check(); // immediate first check + _timer = Timer.periodic(const Duration(seconds: 30), (_) => _check()); + } + + Timer? _timer; + + // Dedicated lightweight Dio — short timeouts, no interceptors + final _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 3), + receiveTimeout: const Duration(seconds: 3), + )); + + /// 立即触发一次检测(供外部调用,如 API 请求失败时) + Future forceCheck() => _check(); + + Future _check() async { + try { + await _dio.get(AppConfig.healthUrl); + if (!state) state = true; + } catch (_) { + if (state) state = false; + } + } + + @override + void dispose() { + _timer?.cancel(); + _dio.close(force: true); + super.dispose(); + } +} diff --git a/client/lib/providers/inventory_provider.dart b/client/lib/providers/inventory_provider.dart index fbf34e7..1c23513 100644 --- a/client/lib/providers/inventory_provider.dart +++ b/client/lib/providers/inventory_provider.dart @@ -18,11 +18,19 @@ class InventoryListNotifier extends AsyncNotifier> { int _page = 1; int? _warehouseId; String _keyword = ''; + PageResult? _cache; @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return _fetch(); + try { + final result = await _fetch(); + _cache = result; + return result; + } catch (_) { + if (_cache != null) return _cache!; + rethrow; + } } Future> _fetch() { @@ -53,10 +61,16 @@ class InventoryListNotifier extends AsyncNotifier> { void reload() { state = const AsyncValue.loading(); - _fetch().then( - (result) => state = AsyncValue.data(result), - onError: (e, st) => state = AsyncValue.error(e, st), - ); + _fetch().then((result) { + _cache = result; + state = AsyncValue.data(result); + }, onError: (e, st) { + if (_cache != null) { + state = AsyncValue.data(_cache!); + } else { + state = AsyncValue.error(e, st); + } + }); } } @@ -67,11 +81,19 @@ final inventoryLogProvider = class InventoryLogNotifier extends AsyncNotifier> { int _page = 1; + PageResult? _cache; @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return _fetch(); + try { + final result = await _fetch(); + _cache = result; + return result; + } catch (_) { + if (_cache != null) return _cache!; + rethrow; + } } Future> _fetch() { @@ -88,9 +110,15 @@ class InventoryLogNotifier extends AsyncNotifier> { void reload() { state = const AsyncValue.loading(); - _fetch().then( - (result) => state = AsyncValue.data(result), - onError: (e, st) => state = AsyncValue.error(e, st), - ); + _fetch().then((result) { + _cache = result; + state = AsyncValue.data(result); + }, onError: (e, st) { + if (_cache != null) { + state = AsyncValue.data(_cache!); + } else { + state = AsyncValue.error(e, st); + } + }); } } diff --git a/client/lib/providers/license_provider.dart b/client/lib/providers/license_provider.dart new file mode 100644 index 0000000..cac4ab5 --- /dev/null +++ b/client/lib/providers/license_provider.dart @@ -0,0 +1,78 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; + +class LicenseInfo { + final String type; // trial / monthly / annual / lifetime + final bool isActive; + final DateTime? expiresAt; + final DateTime? activatedAt; + + const LicenseInfo({ + required this.type, + required this.isActive, + this.expiresAt, + this.activatedAt, + }); + + factory LicenseInfo.fromJson(Map json) { + return LicenseInfo( + type: json['type'] as String? ?? 'trial', + isActive: json['is_active'] as bool? ?? false, + expiresAt: json['expires_at'] != null + ? DateTime.tryParse(json['expires_at'] as String) + : null, + activatedAt: json['activated_at'] != null + ? DateTime.tryParse(json['activated_at'] as String) + : null, + ); + } + + String get typeLabel { + switch (type) { + case 'monthly': return '月度授权'; + case 'annual': return '年度授权'; + case 'lifetime': return '永久授权'; + default: return '试用版'; + } + } + + /// 是否已过期 + bool get isExpired => + expiresAt != null && DateTime.now().isAfter(expiresAt!); + + /// 距到期剩余天数(null = 永久) + int? get daysRemaining { + if (expiresAt == null) return null; + final diff = expiresAt!.difference(DateTime.now()).inDays; + return diff < 0 ? 0 : diff; + } +} + +final licenseProvider = + AsyncNotifierProvider(LicenseNotifier.new); + +class LicenseNotifier extends AsyncNotifier { + @override + Future build() async { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return _fetch(); + } + + Future _fetch() async { + try { + final client = ref.read(apiClientProvider); + final resp = await client.get('/license/info'); + final data = resp.data['data']; + if (data == null) return null; + return LicenseInfo.fromJson(data as Map); + } catch (_) { + return null; + } + } + + Future reload() async { + state = const AsyncValue.loading(); + state = AsyncValue.data(await _fetch()); + } +} diff --git a/client/lib/providers/number_rule_provider.dart b/client/lib/providers/number_rule_provider.dart index 3647e34..09e6954 100644 --- a/client/lib/providers/number_rule_provider.dart +++ b/client/lib/providers/number_rule_provider.dart @@ -14,16 +14,34 @@ final numberRuleListProvider = ); class NumberRuleListNotifier extends AsyncNotifier> { + List _cache = []; + @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return ref.read(numberRuleRepositoryProvider).list(); + try { + final result = await ref.read(numberRuleRepositoryProvider).list(); + _cache = result; + return result; + } catch (_) { + if (_cache.isNotEmpty) return _cache; + rethrow; + } } Future reload() async { state = const AsyncValue.loading(); - state = await AsyncValue.guard( - () => ref.read(numberRuleRepositoryProvider).list()); + try { + final result = await ref.read(numberRuleRepositoryProvider).list(); + _cache = result; + state = AsyncValue.data(result); + } catch (e, st) { + if (_cache.isNotEmpty) { + state = AsyncValue.data(_cache); + } else { + state = AsyncValue.error(e, st); + } + } } Future updateRule(int id, Map data) async { diff --git a/client/lib/providers/partner_provider.dart b/client/lib/providers/partner_provider.dart index 7bcc213..890a0b1 100644 --- a/client/lib/providers/partner_provider.dart +++ b/client/lib/providers/partner_provider.dart @@ -25,13 +25,21 @@ class PartnerListNotifier extends AsyncNotifier> { final String? type; int _page = 1; String _keyword = ''; + PageResult? _cache; PartnerListNotifier({this.type}); @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return _fetch(); + try { + final result = await _fetch(); + _cache = result; + return result; + } catch (_) { + if (_cache != null) return _cache!; + rethrow; + } } Future> _fetch() { @@ -55,10 +63,16 @@ class PartnerListNotifier extends AsyncNotifier> { void reload() { state = const AsyncValue.loading(); - _fetch().then( - (result) => state = AsyncValue.data(result), - onError: (e, st) => state = AsyncValue.error(e, st), - ); + _fetch().then((result) { + _cache = result; + state = AsyncValue.data(result); + }, onError: (e, st) { + if (_cache != null) { + state = AsyncValue.data(_cache!); + } else { + state = AsyncValue.error(e, st); + } + }); } Future createPartner(Map data) async { diff --git a/client/lib/providers/product_provider.dart b/client/lib/providers/product_provider.dart index 30a7164..856c395 100644 --- a/client/lib/providers/product_provider.dart +++ b/client/lib/providers/product_provider.dart @@ -18,11 +18,19 @@ class ProductListNotifier extends AsyncNotifier> { int _page = 1; String _keyword = ''; int? _categoryId; + PageResult? _cache; @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return _fetch(); + try { + final result = await _fetch(); + _cache = result; + return result; + } catch (_) { + if (_cache != null) return _cache!; + rethrow; + } } Future> _fetch() { @@ -54,10 +62,16 @@ class ProductListNotifier extends AsyncNotifier> { void reload() { state = const AsyncValue.loading(); - _fetch().then( - (result) => state = AsyncValue.data(result), - onError: (e, st) => state = AsyncValue.error(e, st), - ); + _fetch().then((result) { + _cache = result; + state = AsyncValue.data(result); + }, onError: (e, st) { + if (_cache != null) { + state = AsyncValue.data(_cache!); + } else { + state = AsyncValue.error(e, st); + } + }); } Future createProduct(Map data) async { diff --git a/client/lib/providers/stock_in_provider.dart b/client/lib/providers/stock_in_provider.dart index 3716273..455467a 100644 --- a/client/lib/providers/stock_in_provider.dart +++ b/client/lib/providers/stock_in_provider.dart @@ -20,11 +20,19 @@ class StockInListNotifier extends AsyncNotifier> { String _status = ''; String? _startDate; String? _endDate; + PageResult? _cache; @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return _fetch(); + try { + final result = await _fetch(); + _cache = result; + return result; + } catch (_) { + if (_cache != null) return _cache!; + rethrow; + } } Future> _fetch() { @@ -56,10 +64,16 @@ class StockInListNotifier extends AsyncNotifier> { void reload() { state = const AsyncValue.loading(); - _fetch().then( - (result) => state = AsyncValue.data(result), - onError: (e, st) => state = AsyncValue.error(e, st), - ); + _fetch().then((result) { + _cache = result; + state = AsyncValue.data(result); + }, onError: (e, st) { + if (_cache != null) { + state = AsyncValue.data(_cache!); + } else { + state = AsyncValue.error(e, st); + } + }); } Future createOrder(Map data) async { @@ -67,6 +81,11 @@ class StockInListNotifier extends AsyncNotifier> { reload(); } + Future deleteOrder(int id) async { + await ref.read(stockInRepositoryProvider).delete(id); + reload(); + } + Future submitOrder(int id) async { await ref.read(stockInRepositoryProvider).submit(id); reload(); diff --git a/client/lib/providers/stock_out_provider.dart b/client/lib/providers/stock_out_provider.dart index 2a0f5d1..5a091d0 100644 --- a/client/lib/providers/stock_out_provider.dart +++ b/client/lib/providers/stock_out_provider.dart @@ -20,11 +20,19 @@ class StockOutListNotifier extends AsyncNotifier> { String _status = ''; String? _startDate; String? _endDate; + PageResult? _cache; @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return _fetch(); + try { + final result = await _fetch(); + _cache = result; + return result; + } catch (_) { + if (_cache != null) return _cache!; + rethrow; + } } Future> _fetch() { @@ -56,10 +64,16 @@ class StockOutListNotifier extends AsyncNotifier> { void reload() { state = const AsyncValue.loading(); - _fetch().then( - (result) => state = AsyncValue.data(result), - onError: (e, st) => state = AsyncValue.error(e, st), - ); + _fetch().then((result) { + _cache = result; + state = AsyncValue.data(result); + }, onError: (e, st) { + if (_cache != null) { + state = AsyncValue.data(_cache!); + } else { + state = AsyncValue.error(e, st); + } + }); } Future createOrder(Map data) async { @@ -67,6 +81,11 @@ class StockOutListNotifier extends AsyncNotifier> { reload(); } + Future deleteOrder(int id) async { + await ref.read(stockOutRepositoryProvider).delete(id); + reload(); + } + Future submitOrder(int id) async { await ref.read(stockOutRepositoryProvider).submit(id); reload(); diff --git a/client/lib/providers/update_provider.dart b/client/lib/providers/update_provider.dart new file mode 100644 index 0000000..dcbb3e0 --- /dev/null +++ b/client/lib/providers/update_provider.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../core/config/app_config.dart'; + +// ── 数据模型 ──────────────────────────────────────────────── +class AppUpdateInfo { + final String latestVersion; + final int buildNumber; + final bool forceUpdate; + final String releaseNotes; + final Map downloadUrls; + final bool hasUpdate; + + const AppUpdateInfo({ + required this.latestVersion, + required this.buildNumber, + required this.forceUpdate, + required this.releaseNotes, + required this.downloadUrls, + required this.hasUpdate, + }); +} + +// ── Provider ──────────────────────────────────────────────── +/// null = 检查失败或无更新数据(不影响主流程) +/// AppUpdateInfo with hasUpdate=false = 已是最新版 +/// AppUpdateInfo with hasUpdate=true = 有新版本 +final updateProvider = + AsyncNotifierProvider(UpdateNotifier.new); + +class UpdateNotifier extends AsyncNotifier { + String get _checkUrl => AppConfig.versionUrl; + Timer? _timer; + bool _dismissed = false; // 用户已手动关闭非强制更新提示 + + final _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + )); + + @override + Future build() async { + ref.onDispose(() { + _timer?.cancel(); + _dio.close(force: true); + }); + + // 启动后 3 秒延迟首次检查(避免与登录请求竞争) + await Future.delayed(const Duration(seconds: 3)); + final result = await _check(); + + // 每小时检查一次 + _timer = Timer.periodic(const Duration(hours: 1), (_) async { + _dismissed = false; + final r = await _check(); + state = AsyncValue.data(r); + }); + + return result; + } + + /// 手动触发一次检查(供设置页"检查更新"按钮调用) + Future forceCheck() async { + _dismissed = false; + state = const AsyncValue.loading(); + state = AsyncValue.data(await _check()); + } + + /// 用户点击"稍后再说"后调用,隐藏 banner(直到下次定时刷新) + void dismiss() { + _dismissed = true; + // 保留数据但 UI 通过 dismissed 状态判断是否显示 + state = AsyncValue.data(state.valueOrNull); + } + + bool get isDismissed => _dismissed; + + Future _check() async { + try { + final resp = await _dio.get(_checkUrl); + final data = resp.data as Map; + + final latestVersion = data['version'] as String? ?? '0.0.0'; + final buildNumber = data['build_number'] as int? ?? 0; + final forceUpdate = data['force_update'] as bool? ?? false; + final releaseNotes = data['release_notes'] as String? ?? ''; + final rawUrls = data['download_urls'] as Map? ?? {}; + final downloadUrls = + rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? '')); + + final info = await PackageInfo.fromPlatform(); + final hasUpdate = _isNewer(latestVersion, info.version); + + return AppUpdateInfo( + latestVersion: latestVersion, + buildNumber: buildNumber, + forceUpdate: forceUpdate, + releaseNotes: releaseNotes, + downloadUrls: downloadUrls, + hasUpdate: hasUpdate, + ); + } catch (_) { + return null; // 检查失败静默处理,不影响主业务 + } + } + + /// 语义化版本比较:latest > current → true + bool _isNewer(String latest, String current) { + final l = _parse(latest); + final c = _parse(current); + for (var i = 0; i < 3; i++) { + if (l[i] > c[i]) return true; + if (l[i] < c[i]) return false; + } + return false; + } + + List _parse(String v) { + final parts = v.split('.').map((s) => int.tryParse(s) ?? 0).toList(); + while (parts.length < 3) parts.add(0); + return parts; + } +} + +// ── 版本号 Provider(供状态栏 / 门店信息面板使用)────────── +final appVersionProvider = FutureProvider((ref) async { + final info = await PackageInfo.fromPlatform(); + return 'v${info.version}'; +}); + +// ── 打开下载链接工具函数 ──────────────────────────────────── +Future launchUpdateUrl(Map downloadUrls) async { + String? urlStr; + + if (kIsWeb) { + urlStr = downloadUrls['web']; + } else if (Platform.isMacOS) { + urlStr = downloadUrls['macos']; + } else if (Platform.isWindows) { + urlStr = downloadUrls['windows']; + } else if (Platform.isIOS) { + urlStr = downloadUrls['ios']; + } else if (Platform.isAndroid) { + urlStr = downloadUrls['android']; + } else { + // Linux 等 + urlStr = downloadUrls['web']; + } + + if (urlStr == null || urlStr.isEmpty) return; + + final uri = Uri.parse(urlStr); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } +} diff --git a/client/lib/providers/user_provider.dart b/client/lib/providers/user_provider.dart index 07631d2..e7857d3 100644 --- a/client/lib/providers/user_provider.dart +++ b/client/lib/providers/user_provider.dart @@ -14,16 +14,34 @@ final userListProvider = ); class UserListNotifier extends AsyncNotifier> { + List _cache = []; + @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return ref.read(userRepositoryProvider).list(); + try { + final result = await ref.read(userRepositoryProvider).list(); + _cache = result; + return result; + } catch (_) { + if (_cache.isNotEmpty) return _cache; + rethrow; + } } Future reload() async { state = const AsyncValue.loading(); - state = await AsyncValue.guard( - () => ref.read(userRepositoryProvider).list()); + try { + final result = await ref.read(userRepositoryProvider).list(); + _cache = result; + state = AsyncValue.data(result); + } catch (e, st) { + if (_cache.isNotEmpty) { + state = AsyncValue.data(_cache); + } else { + state = AsyncValue.error(e, st); + } + } } Future createUser(Map data) async { diff --git a/client/lib/providers/warehouse_provider.dart b/client/lib/providers/warehouse_provider.dart index 7b126d3..d0ef81a 100644 --- a/client/lib/providers/warehouse_provider.dart +++ b/client/lib/providers/warehouse_provider.dart @@ -14,16 +14,34 @@ final warehouseListProvider = ); class WarehouseListNotifier extends AsyncNotifier> { + List _cache = []; + @override - Future> build() { + Future> build() async { ref.watch(authStateProvider.select((s) => s.user?.shopId)); - return ref.read(warehouseRepositoryProvider).list(); + try { + final result = await ref.read(warehouseRepositoryProvider).list(); + _cache = result; + return result; + } catch (_) { + if (_cache.isNotEmpty) return _cache; + rethrow; + } } Future reload() async { state = const AsyncValue.loading(); - state = await AsyncValue.guard( - () => ref.read(warehouseRepositoryProvider).list()); + try { + final result = await ref.read(warehouseRepositoryProvider).list(); + _cache = result; + state = AsyncValue.data(result); + } catch (e, st) { + if (_cache.isNotEmpty) { + state = AsyncValue.data(_cache); + } else { + state = AsyncValue.error(e, st); + } + } } Future createWarehouse(Map data) async { diff --git a/client/lib/repositories/stock_in_repository.dart b/client/lib/repositories/stock_in_repository.dart index f2f6d79..236b142 100644 --- a/client/lib/repositories/stock_in_repository.dart +++ b/client/lib/repositories/stock_in_repository.dart @@ -63,6 +63,28 @@ class StockInRepository { } } + Future update(int id, Map data) async { + try { + await _client.put('/stock-in/orders/$id', data: data); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '修改入库单失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future delete(int id) async { + try { + await _client.delete('/stock-in/orders/$id'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '删除入库单失败', + statusCode: e.response?.statusCode, + ); + } + } + Future submit(int id) async { try { await _client.put('/stock-in/orders/$id/submit'); diff --git a/client/lib/repositories/stock_out_repository.dart b/client/lib/repositories/stock_out_repository.dart index cc8e313..3958da7 100644 --- a/client/lib/repositories/stock_out_repository.dart +++ b/client/lib/repositories/stock_out_repository.dart @@ -63,6 +63,28 @@ class StockOutRepository { } } + Future update(int id, Map data) async { + try { + await _client.put('/stock-out/orders/$id', data: data); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '修改出库单失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future delete(int id) async { + try { + await _client.delete('/stock-out/orders/$id'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '删除出库单失败', + statusCode: e.response?.statusCode, + ); + } + } + Future submit(int id) async { try { await _client.put('/stock-out/orders/$id/submit'); diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index ac158fe..c64513c 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -4,6 +4,7 @@ import '../../core/theme/app_theme.dart'; import '../../models/finance.dart'; import '../../providers/finance_provider.dart'; import '../../widgets/data_table_card.dart'; +import '../../widgets/multi_select_dropdown.dart'; import '../../widgets/page_scaffold.dart'; class FinanceScreen extends ConsumerWidget { @@ -42,6 +43,21 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { // We drive fetches by maintaining a Future locally, bypassing the global provider late Future> _future; + List _allRecords = []; + + Set _filterType = {}; + Set _filterPartner = {}; + Set _hiddenCols = {}; + + static const _colDefs = [ + ColDef('date', '日期', required: true), + ColDef('type', '类型'), + ColDef('partner', '往来单位'), + ColDef('ref', '关联单据', minWidth: 900), + ColDef('amount', '金额'), + ColDef('balance', '余额'), + ColDef('remark', '备注', minWidth: 1000), + ]; @override void initState() { @@ -60,13 +76,29 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { page: _page, pageSize: 50, ) - .then((r) => r.data); + .then((r) { + _allRecords = r.data; + return r.data; + }); } void _refetch() { setState(() => _fetch()); } + List _applyFilters(List all) { + return all.where((r) { + if (_filterType.isNotEmpty && !_filterType.contains(r.typeLabel)) { + return false; + } + if (_filterPartner.isNotEmpty && + !_filterPartner.contains(r.partnerName ?? '')) { + return false; + } + return true; + }).toList(); + } + @override Widget build(BuildContext context) { return FutureBuilder>( @@ -76,20 +108,29 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { + if (_allRecords.isNotEmpty) { + return Column( + children: [ + _OfflineBanner(onRetry: _refetch), + Expanded(child: _buildContent(_applyFilters(_allRecords))), + ], + ); + } return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:${snap.error}', - style: const TextStyle(color: AppTheme.danger)), + const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), const SizedBox(height: 12), - ElevatedButton( - onPressed: _refetch, child: const Text('重试')), + const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)), + const SizedBox(height: 12), + ElevatedButton(onPressed: _refetch, child: const Text('重试')), ], ), ); } - return _buildContent(snap.data ?? []); + final filtered = _applyFilters(_allRecords); + return _buildContent(filtered); }, ); } @@ -99,6 +140,106 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { final totalBalance = records.fold(0.0, (s, r) => s + r.balance); final totalPaid = totalAmount - totalBalance; + // Derive filter options from all loaded records + final typeOptions = _allRecords + .map((r) => r.typeLabel) + .toSet() + .toList() + ..sort(); + final partnerOptions = _allRecords + .map((r) => r.partnerName ?? '') + .where((s) => s.isNotEmpty) + .toSet() + .toList() + ..sort(); + + // Build visible columns (manual hide + responsive auto-hide by screen width) + final screenWidth = MediaQuery.of(context).size.width; + final visibleCols = _colDefs + .where((c) => + !_hiddenCols.contains(c.key) && + (c.minWidth == null || screenWidth >= c.minWidth!)) + .toList(); + + final columns = visibleCols + .map((c) => DataColumn( + label: Text(c.label), + numeric: c.key == 'amount' || c.key == 'balance', + )) + .toList(); + + DataCell buildFinanceCell(String key, FinanceRecord r) { + switch (key) { + case 'date': + return DataCell(Text( + r.recordDate?.substring(0, 10) ?? '-', + style: const TextStyle(fontSize: 12), + )); + case 'type': + return DataCell(_TypeBadge(r.typeLabel)); + case 'partner': + return DataCell(SizedBox( + width: 160, + child: Text(r.partnerName ?? '-', + overflow: TextOverflow.ellipsis), + )); + case 'ref': + return DataCell(Text( + r.refType != null && r.refId != null + ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' + : '-', + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: AppTheme.primary), + )); + case 'amount': + return DataCell(Text( + '¥${r.amount.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.w500), + )); + case 'balance': + return DataCell(Text( + '¥${r.balance.toStringAsFixed(2)}', + style: TextStyle( + color: + r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + )); + case 'remark': + return DataCell(SizedBox( + width: 160, + child: Text(r.remark ?? '-', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + )); + default: + return const DataCell(SizedBox()); + } + } + + final rows = records.isEmpty + ? [ + DataRow( + cells: List.generate( + visibleCols.length, + (i) => i == 0 + ? const DataCell(Text('暂无记录', + style: TextStyle(color: AppTheme.textSecondary))) + : const DataCell(SizedBox()), + ), + ), + ] + : records + .map((r) => DataRow( + cells: visibleCols + .map((c) => buildFinanceCell(c.key, r)) + .toList(), + )) + .toList(); + return Column( children: [ // Summary bar (only for type-filtered tabs) @@ -143,85 +284,49 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { _fetch(); }); }, - toolbar: Row( + toolbar: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Spacer(), - _MonthSelector( - value: _month, - onChanged: (v) { - _month = v; - _page = 1; - _refetch(); - }, + // Filter bar + month selector + column toggle + Row( + children: [ + if (typeOptions.length > 1) + MultiSelectDropdown( + label: '类型', + options: typeOptions, + selected: _filterType, + onChanged: (v) => setState(() => _filterType = v), + ), + if (typeOptions.length > 1) const SizedBox(width: 8), + if (partnerOptions.length > 1) + MultiSelectDropdown( + label: '往来单位', + options: partnerOptions, + selected: _filterPartner, + onChanged: (v) => + setState(() => _filterPartner = v), + ), + const Spacer(), + _MonthSelector( + value: _month, + onChanged: (v) { + _month = v; + _page = 1; + _refetch(); + }, + ), + const SizedBox(width: 8), + ColumnToggleButton( + columns: _colDefs, + hidden: _hiddenCols, + onChanged: (v) => setState(() => _hiddenCols = v), + ), + ], ), ], ), - columns: const [ - DataColumn(label: Text('日期')), - DataColumn(label: Text('类型')), - DataColumn(label: Text('往来单位')), - DataColumn(label: Text('关联单据')), - DataColumn(label: Text('金额'), numeric: true), - DataColumn(label: Text('余额'), numeric: true), - DataColumn(label: Text('备注')), - ], - rows: records.isEmpty - ? [ - DataRow(cells: [ - const DataCell(SizedBox()), - const DataCell(Text('暂无记录', - style: TextStyle(color: AppTheme.textSecondary))), - const DataCell(SizedBox()), - const DataCell(SizedBox()), - const DataCell(SizedBox()), - const DataCell(SizedBox()), - const DataCell(SizedBox()), - ]) - ] - : records - .map((r) => DataRow(cells: [ - DataCell(Text( - r.recordDate?.substring(0, 10) ?? '-', - style: const TextStyle(fontSize: 12), - )), - DataCell(_TypeBadge(r.typeLabel)), - DataCell(SizedBox( - width: 160, - child: Text(r.partnerName ?? '-', - overflow: TextOverflow.ellipsis), - )), - DataCell(Text( - r.refType != null && r.refId != null - ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' - : '-', - style: const TextStyle( - fontSize: 11, - fontFamily: 'monospace', - color: AppTheme.primary), - )), - DataCell(Text( - '¥${r.amount.toStringAsFixed(2)}', - style: const TextStyle(fontWeight: FontWeight.w500), - )), - DataCell(Text( - '¥${r.balance.toStringAsFixed(2)}', - style: TextStyle( - color: r.balance > 0 - ? AppTheme.danger - : AppTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - )), - DataCell(SizedBox( - width: 160, - child: Text(r.remark ?? '-', - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary)), - )), - ])) - .toList(), + columns: columns, + rows: rows, ), ), ], @@ -365,3 +470,34 @@ class _MonthSelector extends StatelessWidget { ); } } + +class _OfflineBanner extends StatelessWidget { + final VoidCallback onRetry; + const _OfflineBanner({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: const Color(0xFFFFF8E1), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)), + const SizedBox(width: 8), + const Expanded( + child: Text('网络不可用,当前显示离线缓存数据', + style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)), + ), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFF57F17), + padding: const EdgeInsets.symmetric(horizontal: 8)), + child: const Text('重试', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + } +} diff --git a/client/lib/screens/inventory/batch_tracking_screen.dart b/client/lib/screens/inventory/batch_tracking_screen.dart index 13ca3b9..00b8aba 100644 --- a/client/lib/screens/inventory/batch_tracking_screen.dart +++ b/client/lib/screens/inventory/batch_tracking_screen.dart @@ -4,6 +4,7 @@ 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'; class BatchTrackingScreen extends ConsumerStatefulWidget { const BatchTrackingScreen({super.key}); @@ -17,6 +18,26 @@ class _BatchTrackingScreenState extends ConsumerState { int _page = 1; int _total = 0; late Future> _future; + List _records = []; + + Set _filterStatus = {}; + Set _filterWarehouse = {}; + Set _filterSupplier = {}; + Set _hiddenCols = {}; + + static const _colDefs = [ + ColDef('product', '商品', required: true), + ColDef('spec', '规格', minWidth: 1100), + ColDef('batch', '批次号', minWidth: 1000), + ColDef('order_no', '入库单号', minWidth: 900), + ColDef('supplier', '供应商', minWidth: 1100), + ColDef('warehouse', '仓库'), + ColDef('date', '入库日期', minWidth: 1000), + ColDef('qty', '数量'), + ColDef('price', '单价', minWidth: 900), + ColDef('status', '状态'), + ColDef('buyer', '买家/时间'), + ]; @override void initState() { @@ -30,12 +51,30 @@ class _BatchTrackingScreenState extends ConsumerState { .listProducts(page: _page, pageSize: 20) .then((r) { _total = r.total; + _records = r.data; return r.data; }); } void _refetch() => setState(() => _fetch()); + List _applyFilters( + List all) { + return all.where((r) { + if (_filterStatus.isNotEmpty) { + final label = r.isSoldOut ? '已卖出' : '在售'; + if (!_filterStatus.contains(label)) return false; + } + if (_filterWarehouse.isNotEmpty) { + if (!_filterWarehouse.contains(r.warehouseName ?? '')) return false; + } + if (_filterSupplier.isNotEmpty) { + if (!_filterSupplier.contains(r.supplierName ?? '')) return false; + } + return true; + }).toList(); + } + @override Widget build(BuildContext context) { return FutureBuilder>( @@ -45,25 +84,84 @@ class _BatchTrackingScreenState extends ConsumerState { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { + // 有缓存数据时展示缓存,顶部加提示条 + if (_records.isNotEmpty) { + return Column( + children: [ + _OfflineBanner(onRetry: _refetch), + Expanded(child: _buildTable(_applyFilters(_records))), + ], + ); + } return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:${snap.error}', - style: const TextStyle(color: AppTheme.danger)), + const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), const SizedBox(height: 12), - ElevatedButton( - onPressed: _refetch, child: const Text('重试')), + const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)), + const SizedBox(height: 12), + ElevatedButton(onPressed: _refetch, child: const Text('重试')), ], ), ); } - return _buildTable(snap.data ?? []); + final filtered = _applyFilters(_records); + return _buildTable(filtered); }, ); } Widget _buildTable(List records) { + // Derive filter options from all loaded records + final warehouseOptions = _records + .map((r) => r.warehouseName ?? '') + .where((s) => s.isNotEmpty) + .toSet() + .toList() + ..sort(); + final supplierOptions = _records + .map((r) => r.supplierName ?? '') + .where((s) => s.isNotEmpty) + .toSet() + .toList() + ..sort(); + + // Build visible columns (respect manual hide + responsive auto-hide) + final screenWidth = MediaQuery.of(context).size.width; + final visibleCols = _colDefs + .where((c) => + !_hiddenCols.contains(c.key) && + (c.minWidth == null || screenWidth >= c.minWidth!)) + .toList(); + + final columns = visibleCols + .map((c) => DataColumn( + label: Text(c.label), + numeric: c.key == 'qty' || c.key == 'price', + )) + .toList(); + + final rows = records.isEmpty + ? [ + DataRow( + cells: List.generate( + visibleCols.length, + (i) => i == 1 + ? const DataCell(Text('暂无记录', + style: TextStyle(color: AppTheme.textSecondary))) + : const DataCell(SizedBox()), + ), + ), + ] + : records + .map((r) => DataRow( + cells: visibleCols + .map((c) => _buildCell(c.key, r)) + .toList(), + )) + .toList(); + return DataTableCard( totalCount: _total, page: _page, @@ -71,141 +169,158 @@ class _BatchTrackingScreenState extends ConsumerState { _page = p; _fetch(); }), - toolbar: Row( + toolbar: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('已审核入库商品(含库存与销售状态)', - style: TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - const Spacer(), - IconButton( - icon: const Icon(Icons.refresh, size: 18), - onPressed: () => setState(() { - _page = 1; - _fetch(); - }), - tooltip: '刷新', + // Top row: description + refresh + column toggle + Row( + children: [ + const Text('已审核入库商品(含库存与销售状态)', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: () => setState(() { + _page = 1; + _fetch(); + }), + tooltip: '刷新', + ), + const SizedBox(width: 4), + ColumnToggleButton( + columns: _colDefs, + hidden: _hiddenCols, + onChanged: (v) => setState(() => _hiddenCols = v), + ), + ], + ), + // Filter bar row + const SizedBox(height: 6), + Row( + children: [ + MultiSelectDropdown( + label: '状态', + options: const ['在售', '已卖出'], + selected: _filterStatus, + onChanged: (v) => setState(() => _filterStatus = v), + ), + const SizedBox(width: 8), + if (warehouseOptions.length > 1) + MultiSelectDropdown( + label: '仓库', + options: warehouseOptions, + selected: _filterWarehouse, + onChanged: (v) => setState(() => _filterWarehouse = v), + ), + if (warehouseOptions.length > 1) const SizedBox(width: 8), + if (supplierOptions.length > 1) + MultiSelectDropdown( + label: '供应商', + options: supplierOptions, + selected: _filterSupplier, + onChanged: (v) => setState(() => _filterSupplier = v), + ), + ], ), ], ), - columns: const [ - DataColumn(label: Text('商品')), - DataColumn(label: Text('规格')), - DataColumn(label: Text('批次号')), - DataColumn(label: Text('入库单号')), - DataColumn(label: Text('供应商')), - DataColumn(label: Text('仓库')), - DataColumn(label: Text('入库日期')), - DataColumn(label: Text('数量'), numeric: true), - DataColumn(label: Text('单价'), numeric: true), - DataColumn(label: Text('状态')), - DataColumn(label: Text('买家/时间')), - ], - rows: records.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()), - DataCell(SizedBox()), - DataCell(SizedBox()), - ]) - ] - : records.map((r) { - final batchText = - (r.batchNo != null && r.batchNo!.isNotEmpty) - ? r.batchNo! - : null; - return DataRow(cells: [ - 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')), - ], - )), - DataCell(Text(r.productSpec ?? '-', - style: const TextStyle(fontSize: 12))), - DataCell(batchText != null - ? Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(3), - ), - child: Text(batchText, - style: const TextStyle( - fontSize: 12, - color: AppTheme.primary, - fontFamily: 'monospace')), - ) - : const Text('无批次', - style: TextStyle( - fontSize: 12, - color: AppTheme.textSecondary))), - DataCell(Text(r.orderNo ?? '-', - style: const TextStyle( - fontSize: 11, - color: AppTheme.primary, - fontFamily: 'monospace'))), - DataCell(Text(r.supplierName ?? '-', - style: const TextStyle(fontSize: 12))), - DataCell(Text(r.warehouseName ?? '-', - style: const TextStyle(fontSize: 12))), - DataCell(Text( - r.orderDate?.substring(0, 10) ?? '-', - style: const TextStyle(fontSize: 12))), - DataCell(Text( - '${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}', - style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text( - '¥${r.unitPrice.toStringAsFixed(2)}', - style: const TextStyle(fontSize: 13))), - DataCell(_StatusBadge(r.isSoldOut)), - DataCell(r.isSoldOut - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - r.buyerName?.isNotEmpty == true - ? r.buyerName! - : '未知买家', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500), - ), - if (r.soldAt != null) - Text(r.soldAt!.length > 10 - ? r.soldAt!.substring(0, 10) - : r.soldAt!, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textSecondary)), - ], - ) - : const Text('-', - style: - TextStyle(color: AppTheme.textSecondary))), - ]); - }).toList(), + columns: columns, + rows: rows, ); } + + DataCell _buildCell(String key, ProductTrackingRecord r) { + final batchText = + (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')), + ], + )); + case 'spec': + return DataCell(Text(r.productSpec ?? '-', + style: const TextStyle(fontSize: 12))); + case 'batch': + return DataCell(batchText != null + ? Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(3), + ), + child: Text(batchText, + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontFamily: 'monospace')), + ) + : const Text('无批次', + style: TextStyle( + fontSize: 12, color: AppTheme.textSecondary))); + case 'order_no': + return DataCell(Text(r.orderNo ?? '-', + style: const TextStyle( + fontSize: 11, + color: AppTheme.primary, + fontFamily: 'monospace'))); + case 'supplier': + return DataCell(Text(r.supplierName ?? '-', + style: const TextStyle(fontSize: 12))); + case 'warehouse': + return DataCell(Text(r.warehouseName ?? '-', + style: const TextStyle(fontSize: 12))); + case 'date': + return DataCell(Text(r.orderDate?.substring(0, 10) ?? '-', + style: const TextStyle(fontSize: 12))); + case 'qty': + return DataCell(Text( + '${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}', + style: const TextStyle(fontWeight: FontWeight.w500))); + case 'price': + return DataCell(Text('¥${r.unitPrice.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 13))); + case 'status': + return DataCell(_StatusBadge(r.isSoldOut)); + case 'buyer': + return DataCell(r.isSoldOut + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + r.buyerName?.isNotEmpty == true ? r.buyerName! : '未知买家', + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w500), + ), + if (r.soldAt != null) + Text( + r.soldAt!.length > 10 + ? r.soldAt!.substring(0, 10) + : r.soldAt!, + style: const TextStyle( + fontSize: 11, color: AppTheme.textSecondary)), + ], + ) + : const Text('-', + style: TextStyle(color: AppTheme.textSecondary))); + default: + return const DataCell(SizedBox()); + } + } } class _StatusBadge extends StatelessWidget { @@ -233,3 +348,34 @@ class _StatusBadge extends StatelessWidget { ); } } + +class _OfflineBanner extends StatelessWidget { + final VoidCallback onRetry; + const _OfflineBanner({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: const Color(0xFFFFF8E1), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)), + const SizedBox(width: 8), + const Expanded( + child: Text('网络不可用,当前显示离线缓存数据', + style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)), + ), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFF57F17), + padding: const EdgeInsets.symmetric(horizontal: 8)), + child: const Text('重试', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + } +} diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index e49f0ad..47de14b 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -63,8 +63,10 @@ class _InventoryListScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => @@ -275,8 +277,10 @@ class _InventoryListScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => @@ -379,8 +383,10 @@ class _InventoryListScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => diff --git a/client/lib/screens/partners/partners_screen.dart b/client/lib/screens/partners/partners_screen.dart index 817dbd8..62f04ad 100644 --- a/client/lib/screens/partners/partners_screen.dart +++ b/client/lib/screens/partners/partners_screen.dart @@ -53,8 +53,10 @@ class _PartnersScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => @@ -92,8 +94,10 @@ class _PartnersScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index 5d1fc19..1803ed8 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -109,8 +109,10 @@ class _ProductsScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index f4b8f38..6cd641d 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.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/theme/app_theme.dart'; import '../../models/number_rule.dart'; import '../../models/user.dart'; import '../../models/warehouse.dart'; +import '../../providers/license_provider.dart'; import '../../providers/number_rule_provider.dart'; +import '../../providers/update_provider.dart'; import '../../providers/user_provider.dart'; import '../../providers/warehouse_provider.dart'; @@ -16,10 +21,19 @@ class SettingsScreen extends ConsumerStatefulWidget { } class _SettingsScreenState extends ConsumerState { + // System params local state (UI only, no backend yet) + String _sysName = '酒库管理系统'; + String _sysCurrency = '人民币(CNY)'; + String _sysDateFormat = 'YYYY-MM-DD'; + String _sysTimezone = 'Asia/Shanghai (UTC+8)'; + bool _requireStockInApproval = true; + bool _requireStockOutApproval = true; + bool _allowOverstock = false; + @override Widget build(BuildContext context) { return DefaultTabController( - length: 4, + length: 5, child: Column( children: [ Container( @@ -37,6 +51,7 @@ class _SettingsScreenState extends ConsumerState { Tab(text: '仓库管理'), Tab(text: '编号规则'), Tab(text: '系统参数'), + Tab(text: '关于'), ], ), ), @@ -48,6 +63,7 @@ class _SettingsScreenState extends ConsumerState { _buildWarehousesTab(), _buildNumberRulesTab(), _buildSystemParamsTab(), + _buildAboutTab(), ], ), ), @@ -82,8 +98,10 @@ class _SettingsScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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(userListProvider.notifier).reload(), @@ -189,8 +207,10 @@ class _SettingsScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => @@ -320,8 +340,10 @@ class _SettingsScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => @@ -480,17 +502,55 @@ class _SettingsScreenState extends ConsumerState { const Text('基本设置', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), const Divider(height: 24), - _ParamRow(label: '系统名称', value: '酒库管理系统'), - _ParamRow(label: '货币单位', value: '人民币(CNY)'), - _ParamRow(label: '日期格式', value: 'YYYY-MM-DD'), - _ParamRow(label: '时区', value: 'Asia/Shanghai (UTC+8)'), + _ParamRow( + label: '系统名称', + value: _sysName, + onEdit: () => _showEditParamDialog('系统名称', _sysName, + (v) => setState(() => _sysName = v)), + ), + _ParamRow( + label: '货币单位', + value: _sysCurrency, + onEdit: () => _showEditParamDialog('货币单位', _sysCurrency, + (v) => setState(() => _sysCurrency = v)), + ), + _ParamRow( + label: '日期格式', + value: _sysDateFormat, + onEdit: () => _showEditParamDialog('日期格式', _sysDateFormat, + (v) => setState(() => _sysDateFormat = v)), + ), + _ParamRow( + label: '时区', + value: _sysTimezone, + onEdit: () => _showEditParamDialog('时区', _sysTimezone, + (v) => setState(() => _sysTimezone = v)), + ), const SizedBox(height: 16), const Text('审核设置', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), const Divider(height: 24), - _ParamRow(label: '入库单需要审核', value: '是', isSwitch: true), - _ParamRow(label: '出库单需要审核', value: '是', isSwitch: true), - _ParamRow(label: '允许超量出库', value: '否', isSwitch: false), + _ParamRow( + label: '入库单需要审核', + value: _requireStockInApproval ? '是' : '否', + switchValue: _requireStockInApproval, + onSwitchChanged: (v) => + setState(() => _requireStockInApproval = v), + ), + _ParamRow( + label: '出库单需要审核', + value: _requireStockOutApproval ? '是' : '否', + switchValue: _requireStockOutApproval, + onSwitchChanged: (v) => + setState(() => _requireStockOutApproval = v), + ), + _ParamRow( + label: '允许超量出库', + value: _allowOverstock ? '是' : '否', + switchValue: _allowOverstock, + onSwitchChanged: (v) => + setState(() => _allowOverstock = v), + ), ], ), ), @@ -499,12 +559,24 @@ class _SettingsScreenState extends ConsumerState { Row( children: [ ElevatedButton( - onPressed: () {}, + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('设置已保存'), + backgroundColor: AppTheme.success)); + }, child: const Text('保存设置'), ), const SizedBox(width: 8), OutlinedButton( - onPressed: () {}, + onPressed: () => setState(() { + _sysName = '酒库管理系统'; + _sysCurrency = '人民币(CNY)'; + _sysDateFormat = 'YYYY-MM-DD'; + _sysTimezone = 'Asia/Shanghai (UTC+8)'; + _requireStockInApproval = true; + _requireStockOutApproval = true; + _allowOverstock = false; + }), child: const Text('重置默认'), ), ], @@ -514,6 +586,295 @@ class _SettingsScreenState extends ConsumerState { ); } + // ── 关于 Tab ───────────────────────────────────────────── + Widget _buildAboutTab() { + final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; + final updateInfo = ref.watch(updateProvider).valueOrNull; + final licenseAsync = ref.watch(licenseProvider); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── 版本信息 ── + _AboutSection( + title: '版本信息', + children: [ + _AboutRow(label: '当前版本', value: appVersion), + if (updateInfo != null && updateInfo.hasUpdate) + _AboutRow( + label: '最新版本', + value: 'v${updateInfo.latestVersion}', + valueColor: AppTheme.success, + trailing: TextButton( + onPressed: () => launchUpdateUrl(updateInfo.downloadUrls), + child: const Text('立即更新'), + ), + ) + else + _AboutRow( + label: '最新版本', + value: updateInfo != null ? '已是最新' : '检查中…', + valueColor: AppTheme.textSecondary, + trailing: TextButton( + onPressed: () => + ref.read(updateProvider.notifier).forceCheck(), + child: const Text('检查更新'), + ), + ), + ], + ), + const SizedBox(height: 20), + + // ── 授权信息 ── + _AboutSection( + title: '授权信息', + children: [ + licenseAsync.when( + loading: () => const _AboutRow(label: '授权状态', value: '加载中…'), + error: (_, __) => + const _AboutRow(label: '授权状态', value: '暂无授权信息'), + data: (lic) { + if (lic == null) { + return const _AboutRow(label: '授权状态', value: '未激活'); + } + return Column( + children: [ + _AboutRow(label: '授权类型', value: lic.typeLabel), + _AboutRow( + label: '授权状态', + value: lic.isExpired + ? '已过期' + : lic.isActive + ? '正常' + : '已停用', + valueColor: lic.isExpired + ? AppTheme.danger + : lic.isActive + ? AppTheme.success + : AppTheme.textSecondary, + ), + if (lic.expiresAt != null) + _AboutRow( + label: '到期时间', + value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!), + trailing: lic.daysRemaining != null && + lic.daysRemaining! <= 30 + ? Chip( + label: Text( + lic.isExpired + ? '已过期' + : '剩余 ${lic.daysRemaining} 天', + style: const TextStyle( + fontSize: 11, color: Colors.white), + ), + backgroundColor: lic.isExpired + ? AppTheme.danger + : Colors.orange, + padding: EdgeInsets.zero, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ) + : null, + ) + else + const _AboutRow(label: '到期时间', value: '永久有效'), + if (lic.activatedAt != null) + _AboutRow( + label: '激活时间', + value: DateFormat('yyyy-MM-dd') + .format(lic.activatedAt!), + ), + ], + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + onPressed: () => _showRenewDialog(), + icon: const Icon(Icons.card_membership, size: 16), + label: const Text('续费 / 升级授权'), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + + // ── 关于我们 ── + _AboutSection( + title: '关于我们', + children: [ + const _AboutRow(label: '开发商', value: '酒库科技有限公司'), + const _AboutRow(label: '官方网站', value: 'https://jiu.example.com'), + const _AboutRow(label: '联系邮箱', value: 'support@jiu.example.com'), + const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: () async { + final uri = Uri.parse('mailto:support@jiu.example.com' + '?subject=酒库管理系统咨询'); + if (await canLaunchUrl(uri)) launchUrl(uri); + }, + icon: const Icon(Icons.email_outlined, size: 16), + label: const Text('发送邮件'), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + + // ── 意见反馈 ── + _AboutSection( + title: '意见反馈', + children: [ + const _AboutRow( + label: '问题反馈', + value: '遇到 Bug 或有功能建议,欢迎告知我们', + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + ElevatedButton.icon( + onPressed: () => _showFeedbackDialog(isBug: true), + icon: const Icon(Icons.bug_report_outlined, size: 16), + label: const Text('反馈 Bug'), + ), + OutlinedButton.icon( + onPressed: () => _showFeedbackDialog(isBug: false), + icon: const Icon(Icons.lightbulb_outline, size: 16), + label: const Text('功能建议'), + ), + ], + ), + ], + ), + ], + ), + ); + } + + void _showRenewDialog() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('续费 / 升级授权'), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('请联系我们获取续费报价:'), + SizedBox(height: 12), + SelectableText('📧 support@jiu.example.com', + style: TextStyle(fontSize: 13)), + SizedBox(height: 6), + SelectableText('📞 400-000-0000', + style: TextStyle(fontSize: 13)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('关闭'), + ), + ElevatedButton( + onPressed: () async { + await Clipboard.setData( + const ClipboardData(text: 'support@jiu.example.com')); + if (ctx.mounted) { + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('邮箱已复制到剪贴板')), + ); + } + }, + child: const Text('复制邮箱'), + ), + ], + ), + ); + } + + void _showFeedbackDialog({required bool isBug}) { + final ctrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(isBug ? '反馈 Bug' : '功能建议'), + content: SizedBox( + width: 400, + child: TextField( + controller: ctrl, + maxLines: 6, + decoration: InputDecoration( + hintText: isBug + ? '请描述问题的复现步骤和预期行为…' + : '请描述您希望增加的功能…', + border: const OutlineInputBorder(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () async { + final subject = Uri.encodeComponent(isBug ? 'Bug反馈' : '功能建议'); + final body = Uri.encodeComponent(ctrl.text); + final uri = Uri.parse( + 'mailto:support@jiu.example.com?subject=$subject&body=$body'); + if (await canLaunchUrl(uri)) launchUrl(uri); + if (ctx.mounted) Navigator.pop(ctx); + }, + child: const Text('通过邮件发送'), + ), + ], + ), + ); + } + + void _showEditParamDialog( + String label, String current, ValueChanged onSave) { + final ctrl = TextEditingController(text: current); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('修改$label'), + content: SizedBox( + width: 320, + child: TextField( + controller: ctrl, + autofocus: true, + decoration: InputDecoration(labelText: label), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('取消')), + ElevatedButton( + onPressed: () { + final v = ctrl.text.trim(); + if (v.isNotEmpty) onSave(v); + Navigator.of(ctx).pop(); + }, + child: const Text('确定'), + ), + ], + ), + ); + } + void _showAddUserDialog(BuildContext context) { showDialog( context: context, @@ -922,9 +1283,17 @@ class _RoleBadge extends StatelessWidget { class _ParamRow extends StatelessWidget { final String label; final String value; - final bool? isSwitch; + final bool? switchValue; + final ValueChanged? onSwitchChanged; + final VoidCallback? onEdit; - const _ParamRow({required this.label, required this.value, this.isSwitch}); + const _ParamRow({ + required this.label, + required this.value, + this.switchValue, + this.onSwitchChanged, + this.onEdit, + }); @override Widget build(BuildContext context) { @@ -935,19 +1304,93 @@ class _ParamRow extends StatelessWidget { SizedBox( width: 180, child: Text(label, - style: const TextStyle(fontSize: 14, color: AppTheme.textSecondary)), + style: const TextStyle( + fontSize: 14, color: AppTheme.textSecondary)), ), - if (isSwitch != null) + if (switchValue != null) Switch( - value: isSwitch!, - onChanged: (_) {}, + value: switchValue!, + onChanged: onSwitchChanged, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ) else Text(value, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w500)), const Spacer(), - TextButton(onPressed: () {}, child: const Text('修改', style: TextStyle(fontSize: 12))), + if (onEdit != null) + TextButton( + onPressed: onEdit, + child: const Text('修改', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + } +} + +// ── 关于页辅助 widgets ────────────────────────────────────── + +class _AboutSection extends StatelessWidget { + final String title; + final List children; + const _AboutSection({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const Divider(height: 24), + ...children, + ], + ), + ), + ); + } +} + +class _AboutRow extends StatelessWidget { + final String label; + final String value; + final Color? valueColor; + final Widget? trailing; + + const _AboutRow({ + required this.label, + required this.value, + this.valueColor, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 88, + child: Text(label, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + ), + Expanded( + child: Text(value, + style: TextStyle( + fontSize: 13, + color: valueColor ?? AppTheme.textPrimary, + fontWeight: FontWeight.w500)), + ), + if (trailing != null) trailing!, ], ), ); diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 9e6bd33..c4f5e11 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -5,6 +5,8 @@ import 'package:intl/intl.dart'; import 'dart:async'; import '../../core/auth/auth_state.dart'; import '../../core/theme/app_theme.dart'; +import '../../providers/connectivity_provider.dart'; +import '../../providers/update_provider.dart'; class AppShell extends ConsumerStatefulWidget { final Widget child; @@ -16,14 +18,55 @@ class AppShell extends ConsumerStatefulWidget { class _AppShellState extends ConsumerState { bool _sidebarExpanded = true; - final String _loginTime = - DateFormat('HH:mm:ss').format(DateTime.now()); + final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now()); + bool _forceDialogShown = false; + + void _showForceUpdateDialog( + BuildContext context, AppUpdateInfo info) { + if (_forceDialogShown) return; + _forceDialogShown = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => PopScope( + canPop: false, + child: AlertDialog( + title: const Row( + children: [ + Icon(Icons.system_update, color: AppTheme.primary), + SizedBox(width: 8), + Text('发现新版本'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('当前版本需要更新至 v${info.latestVersion} 才能继续使用。'), + if (info.releaseNotes.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(info.releaseNotes, + style: const TextStyle( + color: AppTheme.textSecondary, fontSize: 13)), + ], + ], + ), + actions: [ + ElevatedButton( + onPressed: () => launchUpdateUrl(info.downloadUrls), + child: const Text('立即更新'), + ), + ], + ), + ), + ); + } final List<_NavItem> _navItems = const [ _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: '财务管理', @@ -36,8 +79,13 @@ class _AppShellState extends ConsumerState { @override Widget build(BuildContext context) { final user = ref.watch(authStateProvider).user; + final isOnline = ref.watch(connectivityProvider); final location = GoRouterState.of(context).matchedLocation; final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0; + final updateNotifier = ref.watch(updateProvider.notifier); + final updateInfo = ref.watch(updateProvider).valueOrNull; + final appVersion = + ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; return Scaffold( body: Column( @@ -58,24 +106,26 @@ class _AppShellState extends ConsumerState { tooltip: _sidebarExpanded ? '收起侧边栏' : '展开侧边栏', ), const SizedBox(width: 4), - const Icon(Icons.wine_bar, color: Colors.white, size: 22), - const SizedBox(width: 8), - const Text( - '酒库管理系统', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - letterSpacing: 0.5), - ), + _ShopButton(user: user, version: appVersion), const Spacer(), if (user != null) ...[ - const Icon(Icons.business, - color: Colors.white70, size: 14), - const SizedBox(width: 4), - Text(user.shopNo, - style: const TextStyle( - color: Colors.white70, fontSize: 13)), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _showShopPanel(context, user, version: appVersion), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.business, + color: Colors.white70, size: 14), + const SizedBox(width: 4), + Text(user.shopNo, + style: const TextStyle( + color: Colors.white70, fontSize: 13)), + ], + ), + ), + ), const SizedBox(width: 20), const Icon(Icons.person_outline, color: Colors.white70, size: 14), @@ -158,36 +208,152 @@ class _AppShellState extends ConsumerState { Expanded( child: Column( children: [ + // Update banner(非强制更新) + if (updateInfo != null && + updateInfo.hasUpdate && + !updateInfo.forceUpdate && + !updateNotifier.isDismissed) + Container( + width: double.infinity, + color: const Color(0xFFFFF8E1), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + child: Row( + children: [ + const Icon(Icons.system_update, + size: 16, color: Color(0xFFF57F17)), + const SizedBox(width: 8), + Expanded( + child: Text( + '发现新版本 v${updateInfo.latestVersion}' + '${updateInfo.releaseNotes.isNotEmpty ? " · ${updateInfo.releaseNotes}" : ""}', + style: const TextStyle( + color: Color(0xFF5D4037), + fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: () => launchUpdateUrl( + updateInfo.downloadUrls), + style: TextButton.styleFrom( + foregroundColor: + const Color(0xFFF57F17)), + child: const Text('立即更新'), + ), + TextButton( + onPressed: updateNotifier.dismiss, + style: TextButton.styleFrom( + foregroundColor: + const Color(0xFF9E9E9E)), + child: const Text('稍后再说'), + ), + ], + ), + ), + // 强制更新 dialog(用 postFrameCallback 避免 build 中 showDialog) + if (updateInfo != null && + updateInfo.hasUpdate && + updateInfo.forceUpdate) + Builder(builder: (ctx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _showForceUpdateDialog(ctx, updateInfo); + }); + return const SizedBox.shrink(); + }), + // Offline banner + if (!isOnline) + Container( + width: double.infinity, + color: AppTheme.danger, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + child: const Row( + children: [ + Icon(Icons.wifi_off, + size: 16, color: Colors.white), + SizedBox(width: 8), + Text( + '网络连接已断开 · 当前处于只读模式,所有写操作已禁用', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500), + ), + ], + ), + ), Expanded(child: widget.child), // Status bar - Container( - height: 28, - color: const Color(0xFF37474F), - padding: - const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - if (user != null) ...[ - _StatusItem( - icon: Icons.store, - text: '门店编号:${user.shopNo}'), - const _StatusDivider(), - _StatusItem( - icon: Icons.person, - text: '登录用户:${user.username}'), - const _StatusDivider(), - _StatusItem( - icon: Icons.login, - text: '登录时间:$_loginTime'), - const _StatusDivider(), - ], - const _ClockWidget(), - const Spacer(), - const _StatusItem( - icon: Icons.info_outline, - text: 'v1.0.0'), - ], - ), + LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + // Three tiers: wide / medium / narrow + final wide = w >= 650; + final medium = w >= 190; + final iconOnly = !medium; + + return Container( + height: 28, + color: isOnline + ? const Color(0xFF37474F) + : AppTheme.danger, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + if (!isOnline) ...[ + const Icon(Icons.wifi_off, + size: 11, color: Colors.white70), + if (!iconOnly) ...[ + const SizedBox(width: 4), + const Text('离线', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600)), + ], + const _StatusDivider(), + ], + if (isOnline && user != null) ...[ + _StatusItem( + icon: Icons.store, + text: user.shopNo, + iconOnly: iconOnly), + const _StatusDivider(), + _StatusItem( + icon: Icons.person, + text: user.username, + iconOnly: iconOnly), + if (wide) ...[ + const _StatusDivider(), + _StatusItem( + icon: Icons.login, + text: '登录时间:$_loginTime'), + const _StatusDivider(), + const _ClockWidget(), + ] else + const _StatusDivider(), + ], + const Spacer(), + _StatusItem( + icon: isOnline + ? Icons.cloud_done_outlined + : Icons.cloud_off_outlined, + text: isOnline ? '已连接' : '连接已断开', + iconOnly: iconOnly, + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.info_outline, + text: ref + .watch(appVersionProvider) + .valueOrNull ?? + 'v1.0.0', + iconOnly: iconOnly), + ], + ), + ); + }, ), ], ), @@ -284,7 +450,9 @@ class _SidebarItem extends StatelessWidget { class _StatusItem extends StatelessWidget { final IconData icon; final String text; - const _StatusItem({required this.icon, required this.text}); + final bool iconOnly; + const _StatusItem( + {required this.icon, required this.text, this.iconOnly = false}); @override Widget build(BuildContext context) { @@ -292,9 +460,11 @@ class _StatusItem extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 11, color: Colors.white54), - const SizedBox(width: 4), - Text(text, - style: const TextStyle(color: Colors.white54, fontSize: 11)), + if (!iconOnly) ...[ + const SizedBox(width: 4), + Text(text, + style: const TextStyle(color: Colors.white54, fontSize: 11)), + ], ], ); } @@ -344,6 +514,128 @@ class _ClockWidgetState extends State<_ClockWidget> { } } +void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'}) { + showDialog( + context: context, + builder: (ctx) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: const BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + child: Row( + children: [ + const Icon(Icons.store, color: Colors.white, size: 20), + const SizedBox(width: 10), + const Text('门店信息', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600)), + const Spacer(), + IconButton( + onPressed: () => Navigator.pop(ctx), + icon: const Icon(Icons.close, color: Colors.white70, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + // Info rows + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _InfoRow(icon: Icons.tag, label: '门店编号', value: u.shopNo), + const SizedBox(height: 14), + _InfoRow(icon: Icons.person, label: '登录账号', value: u.username), + const SizedBox(height: 14), + _InfoRow(icon: Icons.badge_outlined, label: '姓名', value: u.realName), + const SizedBox(height: 14), + _InfoRow(icon: Icons.info_outline, label: '系统版本', value: version), + ], + ), + ), + ], + ), + ), + ), + ); +} + +class _ShopButton extends StatelessWidget { + final AuthUser? user; + final String version; + const _ShopButton({this.user, this.version = 'v1.0.0'}); + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + if (user != null) _showShopPanel(context, user!, version: version); + }, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wine_bar, color: Colors.white, size: 22), + SizedBox(width: 8), + Text( + '酒库管理系统', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: 0.5), + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + const _InfoRow({required this.icon, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 10), + SizedBox( + width: 72, + child: Text(label, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + ), + Expanded( + child: Text(value, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textPrimary, + fontWeight: FontWeight.w500)), + ), + ], + ); + } +} + class _HoverMenuItem extends StatefulWidget { final IconData icon; final String 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 aeeb91c..b3a19e3 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -8,9 +8,11 @@ import '../../providers/partner_provider.dart'; import '../../providers/product_provider.dart'; import '../../providers/stock_in_provider.dart'; import '../../providers/warehouse_provider.dart'; +import '../../repositories/stock_in_repository.dart'; class StockInFormScreen extends ConsumerStatefulWidget { - const StockInFormScreen({super.key}); + final int? editOrderId; + const StockInFormScreen({super.key, this.editOrderId}); @override ConsumerState createState() => _StockInFormScreenState(); @@ -23,13 +25,54 @@ class _StockInFormScreenState extends ConsumerState { int? _partnerId; DateTime _orderDate = DateTime.now(); bool _submitting = false; + bool _loadingEdit = false; final List<_ItemRow> _items = []; + bool get _isEdit => widget.editOrderId != null; + @override void initState() { super.initState(); - _items.add(_ItemRow()); + if (_isEdit) { + _loadEditOrder(); + } else { + _items.add(_ItemRow()); + } + } + + Future _loadEditOrder() async { + setState(() => _loadingEdit = true); + try { + final order = await ref + .read(stockInRepositoryProvider) + .get(widget.editOrderId!); + setState(() { + _warehouseId = order.warehouseId; + _partnerId = order.partnerId; + if (order.orderDate != null) { + _orderDate = DateTime.tryParse(order.orderDate!) ?? DateTime.now(); + } + _remarkCtrl.text = order.remark ?? ''; + _items.clear(); + for (final item in order.items ?? []) { + final row = _ItemRow(); + row.productId = item.productId; + row.qtyCtrl.text = item.quantity.toStringAsFixed(0); + row.priceCtrl.text = item.unitPrice.toStringAsFixed(2); + _items.add(row); + } + if (_items.isEmpty) _items.add(_ItemRow()); + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('加载失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _loadingEdit = false); + } } @override @@ -101,7 +144,14 @@ class _StockInFormScreenState extends ConsumerState { }; try { - await ref.read(stockInListProvider.notifier).createOrder(data); + if (_isEdit) { + await ref + .read(stockInRepositoryProvider) + .update(widget.editOrderId!, data); + ref.read(stockInListProvider.notifier).reload(); + } else { + await ref.read(stockInListProvider.notifier).createOrder(data); + } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -144,8 +194,8 @@ class _StockInFormScreenState extends ConsumerState { tooltip: '返回', ), const SizedBox(width: 8), - const Text('新建入库单', - style: TextStyle( + Text(_isEdit ? '修改入库单' : '新建入库单', + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), OutlinedButton( @@ -174,6 +224,9 @@ class _StockInFormScreenState extends ConsumerState { ), ), const Divider(height: 1), + if (_loadingEdit) + const Expanded(child: Center(child: CircularProgressIndicator())), + if (!_loadingEdit) Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), 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 b08958c..acfa7e0 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -6,6 +6,7 @@ import '../../models/stock_in.dart'; import '../../providers/stock_in_provider.dart'; import '../../repositories/stock_in_repository.dart'; import '../../widgets/data_table_card.dart'; +import '../../widgets/multi_select_dropdown.dart'; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; @@ -19,6 +20,19 @@ class StockInListScreen extends ConsumerStatefulWidget { class _StockInListScreenState extends ConsumerState { String _statusFilter = ''; DateTimeRange? _dateRange; + Set _filterWarehouse = {}; + Set _filterSupplier = {}; + Set _hiddenCols = {}; + + static const _colDefs = [ + ColDef('order_no', '入库单号', required: true), + ColDef('supplier', '供应商', minWidth: 900), + ColDef('warehouse', '仓库'), + ColDef('amount', '金额', minWidth: 800), + ColDef('status', '状态'), + ColDef('date', '日期', minWidth: 900), + ColDef('actions', '操作', required: true), + ]; String? get _startDate => _dateRange != null ? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}' @@ -64,8 +78,10 @@ class _StockInListScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => @@ -76,20 +92,55 @@ class _StockInListScreenState extends ConsumerState { ), ), data: (result) { - final List orders; + final allOrders = result.data; + final List statusFiltered; if (filterStatus == 'pending') { - orders = result.data.where((o) => o.status == 'pending').toList(); + statusFiltered = allOrders + .where((o) => o.status == 'draft' || o.status == 'pending') + .toList(); } else if (filterStatus == 'exclude_pending') { - orders = result.data.where((o) => o.status != 'pending').toList(); + statusFiltered = allOrders + .where((o) => o.status != 'draft' && o.status != 'pending') + .toList(); } else { - orders = result.data; + statusFiltered = allOrders; } + + // Derive filter options from all loaded orders + final warehouseOptions = allOrders + .map((o) => o.warehouseName ?? '') + .where((s) => s.isNotEmpty) + .toSet() + .toList() + ..sort(); + final supplierOptions = allOrders + .map((o) => o.partnerName ?? '') + .where((s) => s.isNotEmpty) + .toSet() + .toList() + ..sort(); + + // Apply multi-select filters + var orders = statusFiltered; + if (_filterWarehouse.isNotEmpty) { + orders = orders + .where((o) => _filterWarehouse.contains(o.warehouseName ?? '')) + .toList(); + } + if (_filterSupplier.isNotEmpty) { + orders = orders + .where((o) => _filterSupplier.contains(o.partnerName ?? '')) + .toList(); + } + return _buildOrderTable( orders: orders, totalCount: orders.length, page: result.page, showStatusFilter: filterStatus == 'exclude_pending', showNewButton: showNewButton, + warehouseOptions: warehouseOptions, + supplierOptions: supplierOptions, ); }, ); @@ -101,139 +152,205 @@ class _StockInListScreenState extends ConsumerState { required int page, required bool showStatusFilter, required bool showNewButton, + required List warehouseOptions, + required List supplierOptions, }) { + final screenWidth = MediaQuery.of(context).size.width; + final visibleCols = _colDefs + .where((c) => + !_hiddenCols.contains(c.key) && + (c.minWidth == null || screenWidth >= c.minWidth!)) + .toList(); + + final columns = visibleCols + .map((c) => DataColumn( + label: Text(c.label), + numeric: c.key == 'amount', + )) + .toList(); + + DataCell buildOrderCell(String key, StockInOrder o) { + switch (key) { + case 'order_no': + return DataCell(GestureDetector( + onTap: () => _showDetail(context, o.id), + child: Text(o.orderNo, + style: const TextStyle( + color: AppTheme.primary, + fontFamily: 'monospace', + fontSize: 12, + decoration: TextDecoration.underline)), + )); + case 'supplier': + return DataCell(Text(o.partnerName ?? '-')); + case 'warehouse': + return DataCell(Text(o.warehouseName ?? '-')); + case 'amount': + return DataCell(Text(o.totalAmount != null + ? '¥${o.totalAmount!.toStringAsFixed(2)}' + : '-')); + case 'status': + return DataCell(StatusBadge(_apiStatusToEnum(o.status))); + case 'date': + return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); + case 'actions': + return DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => _showDetail(context, o.id), + child: const Text('详情', + style: + TextStyle(fontSize: 12, color: AppTheme.primary)), + ), + if (o.status == 'draft') ...[ + TextButton( + onPressed: () => context.go('/stock-in/edit/${o.id}'), + child: const Text('修改', + style: TextStyle( + fontSize: 12, color: AppTheme.primary)), + ), + TextButton( + onPressed: () => _confirmDelete(context, o), + child: const Text('删除', + style: TextStyle( + fontSize: 12, color: AppTheme.danger)), + ), + TextButton( + onPressed: () => _confirmSubmit(context, o), + child: const Text('提交', + style: TextStyle( + fontSize: 12, color: AppTheme.primary)), + ), + ], + if (o.status == 'pending') ...[ + TextButton( + key: Key('btn_approve_${o.id}'), + onPressed: () => _confirmApprove(context, o), + child: const Text('通过', + style: TextStyle( + fontSize: 12, color: AppTheme.success)), + ), + TextButton( + key: Key('btn_reject_${o.id}'), + onPressed: () => _confirmReject(context, o), + child: const Text('拒绝', + style: TextStyle( + fontSize: 12, color: AppTheme.danger)), + ), + ], + ], + )); + default: + return const DataCell(SizedBox()); + } + } + + final rows = orders.isEmpty + ? [ + DataRow( + cells: List.generate( + visibleCols.length, + (i) => i == 0 + ? const DataCell(Text('暂无入库单', + style: TextStyle(color: AppTheme.textSecondary))) + : const DataCell(SizedBox()), + ), + ), + ] + : orders + .map((o) => DataRow( + cells: visibleCols + .map((c) => buildOrderCell(c.key, o)) + .toList(), + )) + .toList(); + return DataTableCard( totalCount: totalCount, page: page, onPageChanged: (p) => ref.read(stockInListProvider.notifier).setPage(p), - toolbar: Row( + toolbar: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (showNewButton) - ElevatedButton.icon( - onPressed: () => context.go('/stock-in/new'), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建入库审核单'), - ), - const Spacer(), - if (showStatusFilter) ...[ - _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) { - setState(() => _statusFilter = v ?? ''); - ref - .read(stockInListProvider.notifier) - .setStatus(v ?? ''); - }, - ), - const SizedBox(width: 8), - ], - OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null - ? '选择日期' - : '$_startDate ~ $_endDate', - style: const TextStyle(fontSize: 13), - ), + // Row 1: new button + status filter + date picker + Row( + children: [ + if (showNewButton) + ElevatedButton.icon( + onPressed: () => context.go('/stock-in/new'), + icon: const Icon(Icons.add, size: 16), + label: const Text('新建入库审核单'), + ), + const Spacer(), + if (showStatusFilter) ...[ + _StatusFilterDropdown( + value: _statusFilter, + onChanged: (v) { + setState(() => _statusFilter = v ?? ''); + ref + .read(stockInListProvider.notifier) + .setStatus(v ?? ''); + }, + ), + const SizedBox(width: 8), + ], + OutlinedButton.icon( + onPressed: _pickDateRange, + icon: const Icon(Icons.date_range, size: 16), + label: Text( + _dateRange == null + ? '选择日期' + : '$_startDate ~ $_endDate', + style: const TextStyle(fontSize: 13), + ), + ), + if (_dateRange != null) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + setState(() => _dateRange = null); + ref + .read(stockInListProvider.notifier) + .setDateRange(null, null); + }, + ), + ], + ], + ), + // Row 2: multi-select filters + column toggle + const SizedBox(height: 6), + Row( + children: [ + if (supplierOptions.length > 1) + MultiSelectDropdown( + label: '供应商', + options: supplierOptions, + selected: _filterSupplier, + onChanged: (v) => setState(() => _filterSupplier = v), + ), + if (supplierOptions.length > 1) const SizedBox(width: 8), + if (warehouseOptions.length > 1) + MultiSelectDropdown( + label: '仓库', + options: warehouseOptions, + selected: _filterWarehouse, + onChanged: (v) => setState(() => _filterWarehouse = v), + ), + const Spacer(), + ColumnToggleButton( + columns: _colDefs, + hidden: _hiddenCols, + onChanged: (v) => setState(() => _hiddenCols = v), + ), + ], ), - if (_dateRange != null) ...[ - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.clear, size: 16), - onPressed: () { - setState(() => _dateRange = null); - ref - .read(stockInListProvider.notifier) - .setDateRange(null, null); - }, - ), - ], ], ), - columns: const [ - DataColumn(label: Text('入库单号')), - DataColumn(label: Text('供应商')), - DataColumn(label: Text('仓库')), - DataColumn(label: Text('金额'), numeric: true), - DataColumn(label: Text('状态')), - DataColumn(label: Text('日期')), - DataColumn(label: Text('操作')), - ], - rows: orders.isEmpty - ? [ - const DataRow(cells: [ - DataCell(SizedBox()), - DataCell(Text('暂无入库单', - style: TextStyle(color: AppTheme.textSecondary))), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - ]) - ] - : orders - .map((o) => DataRow( - cells: [ - DataCell(Text(o.orderNo, - style: const TextStyle( - color: AppTheme.primary, - fontFamily: 'monospace', - fontSize: 12))), - DataCell(Text(o.partnerName ?? '-')), - DataCell(Text(o.warehouseName ?? '-')), - DataCell(Text(o.totalAmount != null - ? '¥${o.totalAmount!.toStringAsFixed(2)}' - : '-')), - DataCell(StatusBadge( - _apiStatusToEnum(o.status))), - DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () => _showDetail(context, o.id), - child: const Text('详情', - style: TextStyle( - fontSize: 12, - color: AppTheme.primary)), - ), - if (o.status == 'draft') - TextButton( - onPressed: () => - _confirmSubmit(context, o), - child: const Text('提交', - style: TextStyle( - fontSize: 12, - color: AppTheme.primary)), - ), - if (o.status == 'pending') ...[ - TextButton( - key: Key('btn_approve_${o.id}'), - onPressed: () => - _confirmApprove(context, o), - child: const Text('通过', - style: TextStyle( - fontSize: 12, - color: AppTheme.success)), - ), - TextButton( - key: Key('btn_reject_${o.id}'), - onPressed: () => - _confirmReject(context, o), - child: const Text('拒绝', - style: TextStyle( - fontSize: 12, - color: AppTheme.danger)), - ), - ], - ], - )), - ], - )) - .toList(), + columns: columns, + rows: rows, ); } @@ -262,6 +379,43 @@ class _StockInListScreenState extends ConsumerState { } } + Future _confirmDelete(BuildContext context, StockInOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('删除确认'), + content: Text('确认删除入库单「${o.orderNo}」?此操作不可恢复。'), + 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(stockInListProvider.notifier).deleteOrder(o.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)); + } + } + } + } + Future _confirmSubmit(BuildContext context, StockInOrder o) async { final confirmed = await showDialog( context: context, @@ -359,9 +513,7 @@ class _StockInListScreenState extends ConsumerState { ); if (confirmed == true && mounted) { try { - await ref - .read(stockInListProvider.notifier) - .rejectOrder(o.id); + await ref.read(stockInListProvider.notifier).rejectOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( content: Text('已拒绝'), backgroundColor: AppTheme.accent)); @@ -441,9 +593,17 @@ class _StockInDetailDialogState extends State<_StockInDetailDialog> { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { - return Center( - child: Text('加载失败:${snap.error}', - style: const TextStyle(color: AppTheme.danger))); + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), + SizedBox(height: 12), + Text('暂无数据,网络不可用', + style: TextStyle(color: AppTheme.textSecondary)), + ], + ), + ); } return _buildContent(snap.data!); }, 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 0f69d6f..72623e2 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -9,9 +9,11 @@ import '../../providers/partner_provider.dart'; import '../../providers/product_provider.dart'; import '../../providers/stock_out_provider.dart'; import '../../providers/warehouse_provider.dart'; +import '../../repositories/stock_out_repository.dart'; class StockOutFormScreen extends ConsumerStatefulWidget { - const StockOutFormScreen({super.key}); + final int? editOrderId; + const StockOutFormScreen({super.key, this.editOrderId}); @override ConsumerState createState() => _StockOutFormScreenState(); @@ -24,15 +26,57 @@ class _StockOutFormScreenState extends ConsumerState { int? _partnerId; DateTime _orderDate = DateTime.now(); bool _submitting = false; + bool _loadingEdit = false; // productId → available quantity in selected warehouse Map _inventoryMap = {}; final List<_ItemRow> _items = []; + bool get _isEdit => widget.editOrderId != null; + @override void initState() { super.initState(); - _items.add(_ItemRow()); + if (_isEdit) { + _loadEditOrder(); + } else { + _items.add(_ItemRow()); + } + } + + Future _loadEditOrder() async { + setState(() => _loadingEdit = true); + try { + final order = await ref + .read(stockOutRepositoryProvider) + .get(widget.editOrderId!); + setState(() { + _warehouseId = order.warehouseId; + _partnerId = order.partnerId; + if (order.orderDate != null) { + _orderDate = DateTime.tryParse(order.orderDate!) ?? DateTime.now(); + } + _remarkCtrl.text = order.remark ?? ''; + _items.clear(); + for (final item in order.items ?? []) { + final row = _ItemRow(); + row.productId = item.productId; + row.qtyCtrl.text = item.quantity.toStringAsFixed(0); + row.priceCtrl.text = item.unitPrice.toStringAsFixed(2); + _items.add(row); + } + if (_items.isEmpty) _items.add(_ItemRow()); + }); + if (_warehouseId != null) await _loadInventory(_warehouseId!); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('加载失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _loadingEdit = false); + } } @override @@ -117,7 +161,14 @@ class _StockOutFormScreenState extends ConsumerState { }; try { - await ref.read(stockOutListProvider.notifier).createOrder(data); + if (_isEdit) { + await ref + .read(stockOutRepositoryProvider) + .update(widget.editOrderId!, data); + ref.read(stockOutListProvider.notifier).reload(); + } else { + await ref.read(stockOutListProvider.notifier).createOrder(data); + } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -160,8 +211,8 @@ class _StockOutFormScreenState extends ConsumerState { tooltip: '返回', ), const SizedBox(width: 8), - const Text('新建出库单', - style: TextStyle( + Text(_isEdit ? '修改出库单' : '新建出库单', + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), OutlinedButton( @@ -190,6 +241,9 @@ class _StockOutFormScreenState extends ConsumerState { ), ), const Divider(height: 1), + if (_loadingEdit) + const Expanded(child: Center(child: CircularProgressIndicator())), + if (!_loadingEdit) Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), 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 2fed8e6..172c59a 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -6,6 +6,7 @@ import '../../models/stock_out.dart'; import '../../providers/stock_out_provider.dart'; import '../../repositories/stock_out_repository.dart'; import '../../widgets/data_table_card.dart'; +import '../../widgets/multi_select_dropdown.dart'; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; @@ -20,6 +21,19 @@ class StockOutListScreen extends ConsumerStatefulWidget { class _StockOutListScreenState extends ConsumerState { String _statusFilter = ''; DateTimeRange? _dateRange; + Set _filterWarehouse = {}; + Set _filterCustomer = {}; + Set _hiddenCols = {}; + + static const _colDefs = [ + ColDef('order_no', '出库单号', required: true), + ColDef('customer', '客户', minWidth: 900), + ColDef('warehouse', '仓库'), + ColDef('amount', '金额', minWidth: 800), + ColDef('status', '状态'), + ColDef('date', '日期', minWidth: 900), + ColDef('actions', '操作', required: true), + ]; String? get _startDate => _dateRange != null ? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}' @@ -65,8 +79,10 @@ class _StockOutListScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('加载失败:$e', - style: const TextStyle(color: AppTheme.danger)), + 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: () => @@ -77,20 +93,55 @@ class _StockOutListScreenState extends ConsumerState { ), ), data: (result) { - final List orders; + final allOrders = result.data; + final List statusFiltered; if (filterStatus == 'pending') { - orders = result.data.where((o) => o.status == 'pending').toList(); + statusFiltered = allOrders + .where((o) => o.status == 'draft' || o.status == 'pending') + .toList(); } else if (filterStatus == 'exclude_pending') { - orders = result.data.where((o) => o.status != 'pending').toList(); + statusFiltered = allOrders + .where((o) => o.status != 'draft' && o.status != 'pending') + .toList(); } else { - orders = result.data; + statusFiltered = allOrders; } + + // Derive filter options from all loaded orders + final warehouseOptions = allOrders + .map((o) => o.warehouseName ?? '') + .where((s) => s.isNotEmpty) + .toSet() + .toList() + ..sort(); + final customerOptions = allOrders + .map((o) => o.partnerName ?? '') + .where((s) => s.isNotEmpty) + .toSet() + .toList() + ..sort(); + + // Apply multi-select filters + var orders = statusFiltered; + if (_filterWarehouse.isNotEmpty) { + orders = orders + .where((o) => _filterWarehouse.contains(o.warehouseName ?? '')) + .toList(); + } + if (_filterCustomer.isNotEmpty) { + orders = orders + .where((o) => _filterCustomer.contains(o.partnerName ?? '')) + .toList(); + } + return _buildOrderTable( orders: orders, totalCount: orders.length, page: result.page, showStatusFilter: filterStatus == 'exclude_pending', showNewButton: showNewButton, + warehouseOptions: warehouseOptions, + customerOptions: customerOptions, ); }, ); @@ -102,139 +153,205 @@ class _StockOutListScreenState extends ConsumerState { required int page, required bool showStatusFilter, required bool showNewButton, + required List warehouseOptions, + required List customerOptions, }) { + final screenWidth = MediaQuery.of(context).size.width; + final visibleCols = _colDefs + .where((c) => + !_hiddenCols.contains(c.key) && + (c.minWidth == null || screenWidth >= c.minWidth!)) + .toList(); + + final columns = visibleCols + .map((c) => DataColumn( + label: Text(c.label), + numeric: c.key == 'amount', + )) + .toList(); + + DataCell buildOrderCell(String key, StockOutOrder o) { + switch (key) { + case 'order_no': + return DataCell(GestureDetector( + onTap: () => _showDetail(context, o.id), + child: Text(o.orderNo, + style: const TextStyle( + color: AppTheme.primary, + fontFamily: 'monospace', + fontSize: 12, + decoration: TextDecoration.underline)), + )); + case 'customer': + return DataCell(Text(o.partnerName ?? '-')); + case 'warehouse': + return DataCell(Text(o.warehouseName ?? '-')); + case 'amount': + return DataCell(Text(o.totalAmount != null + ? '¥${o.totalAmount!.toStringAsFixed(2)}' + : '-')); + case 'status': + return DataCell(StatusBadge(_apiStatusToEnum(o.status))); + case 'date': + return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); + case 'actions': + return DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => _showDetail(context, o.id), + child: const Text('详情', + style: + TextStyle(fontSize: 12, color: AppTheme.primary)), + ), + if (o.status == 'draft') ...[ + TextButton( + onPressed: () => context.go('/stock-out/edit/${o.id}'), + child: const Text('修改', + style: TextStyle( + fontSize: 12, color: AppTheme.primary)), + ), + TextButton( + onPressed: () => _confirmDelete(context, o), + child: const Text('删除', + style: TextStyle( + fontSize: 12, color: AppTheme.danger)), + ), + TextButton( + onPressed: () => _confirmSubmit(context, o), + child: const Text('提交', + style: TextStyle( + fontSize: 12, color: AppTheme.primary)), + ), + ], + if (o.status == 'pending') ...[ + TextButton( + key: Key('btn_approve_${o.id}'), + onPressed: () => _confirmApprove(context, o), + child: const Text('通过', + style: TextStyle( + fontSize: 12, color: AppTheme.success)), + ), + TextButton( + key: Key('btn_reject_${o.id}'), + onPressed: () => _confirmReject(context, o), + child: const Text('拒绝', + style: TextStyle( + fontSize: 12, color: AppTheme.danger)), + ), + ], + ], + )); + default: + return const DataCell(SizedBox()); + } + } + + final rows = orders.isEmpty + ? [ + DataRow( + cells: List.generate( + visibleCols.length, + (i) => i == 0 + ? const DataCell(Text('暂无出库单', + style: TextStyle(color: AppTheme.textSecondary))) + : const DataCell(SizedBox()), + ), + ), + ] + : orders + .map((o) => DataRow( + cells: visibleCols + .map((c) => buildOrderCell(c.key, o)) + .toList(), + )) + .toList(); + return DataTableCard( totalCount: totalCount, page: page, onPageChanged: (p) => ref.read(stockOutListProvider.notifier).setPage(p), - toolbar: Row( + toolbar: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (showNewButton) - ElevatedButton.icon( - onPressed: () => context.go('/stock-out/new'), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建出库审核单'), - ), - const Spacer(), - if (showStatusFilter) ...[ - _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) { - setState(() => _statusFilter = v ?? ''); - ref - .read(stockOutListProvider.notifier) - .setStatus(v ?? ''); - }, - ), - const SizedBox(width: 8), - ], - OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null - ? '选择日期' - : '$_startDate ~ $_endDate', - style: const TextStyle(fontSize: 13), - ), + // Row 1: new button + status filter + date picker + Row( + children: [ + if (showNewButton) + ElevatedButton.icon( + onPressed: () => context.go('/stock-out/new'), + icon: const Icon(Icons.add, size: 16), + label: const Text('新建出库审核单'), + ), + const Spacer(), + if (showStatusFilter) ...[ + _StatusFilterDropdown( + value: _statusFilter, + onChanged: (v) { + setState(() => _statusFilter = v ?? ''); + ref + .read(stockOutListProvider.notifier) + .setStatus(v ?? ''); + }, + ), + const SizedBox(width: 8), + ], + OutlinedButton.icon( + onPressed: _pickDateRange, + icon: const Icon(Icons.date_range, size: 16), + label: Text( + _dateRange == null + ? '选择日期' + : '$_startDate ~ $_endDate', + style: const TextStyle(fontSize: 13), + ), + ), + if (_dateRange != null) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + setState(() => _dateRange = null); + ref + .read(stockOutListProvider.notifier) + .setDateRange(null, null); + }, + ), + ], + ], + ), + // Row 2: multi-select filters + column toggle + const SizedBox(height: 6), + Row( + children: [ + if (customerOptions.length > 1) + MultiSelectDropdown( + label: '客户', + options: customerOptions, + selected: _filterCustomer, + onChanged: (v) => setState(() => _filterCustomer = v), + ), + if (customerOptions.length > 1) const SizedBox(width: 8), + if (warehouseOptions.length > 1) + MultiSelectDropdown( + label: '仓库', + options: warehouseOptions, + selected: _filterWarehouse, + onChanged: (v) => setState(() => _filterWarehouse = v), + ), + const Spacer(), + ColumnToggleButton( + columns: _colDefs, + hidden: _hiddenCols, + onChanged: (v) => setState(() => _hiddenCols = v), + ), + ], ), - if (_dateRange != null) ...[ - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.clear, size: 16), - onPressed: () { - setState(() => _dateRange = null); - ref - .read(stockOutListProvider.notifier) - .setDateRange(null, null); - }, - ), - ], ], ), - columns: const [ - DataColumn(label: Text('出库单号')), - DataColumn(label: Text('客户/往来单位')), - DataColumn(label: Text('仓库')), - DataColumn(label: Text('金额'), numeric: true), - DataColumn(label: Text('状态')), - DataColumn(label: Text('日期')), - DataColumn(label: Text('操作')), - ], - rows: orders.isEmpty - ? [ - const DataRow(cells: [ - DataCell(SizedBox()), - DataCell(Text('暂无出库单', - style: TextStyle(color: AppTheme.textSecondary))), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - ]) - ] - : orders - .map((o) => DataRow( - cells: [ - DataCell(Text(o.orderNo, - style: const TextStyle( - color: AppTheme.primary, - fontFamily: 'monospace', - fontSize: 12))), - DataCell(Text(o.partnerName ?? '-')), - DataCell(Text(o.warehouseName ?? '-')), - DataCell(Text(o.totalAmount != null - ? '¥${o.totalAmount!.toStringAsFixed(2)}' - : '-')), - DataCell(StatusBadge( - _apiStatusToEnum(o.status))), - DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () => _showDetail(context, o.id), - child: const Text('详情', - style: TextStyle( - fontSize: 12, - color: AppTheme.primary)), - ), - if (o.status == 'draft') - TextButton( - onPressed: () => - _confirmSubmit(context, o), - child: const Text('提交', - style: TextStyle( - fontSize: 12, - color: AppTheme.primary)), - ), - if (o.status == 'pending') ...[ - TextButton( - key: Key('btn_approve_${o.id}'), - onPressed: () => - _confirmApprove(context, o), - child: const Text('通过', - style: TextStyle( - fontSize: 12, - color: AppTheme.success)), - ), - TextButton( - key: Key('btn_reject_${o.id}'), - onPressed: () => - _confirmReject(context, o), - child: const Text('拒绝', - style: TextStyle( - fontSize: 12, - color: AppTheme.danger)), - ), - ], - ], - )), - ], - )) - .toList(), + columns: columns, + rows: rows, ); } @@ -263,6 +380,43 @@ class _StockOutListScreenState extends ConsumerState { } } + Future _confirmDelete(BuildContext context, StockOutOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('删除确认'), + content: Text('确认删除出库单「${o.orderNo}」?此操作不可恢复。'), + 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(stockOutListProvider.notifier).deleteOrder(o.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)); + } + } + } + } + Future _confirmSubmit( BuildContext context, StockOutOrder o) async { final confirmed = await showDialog( @@ -364,9 +518,7 @@ class _StockOutListScreenState extends ConsumerState { ); if (confirmed == true && mounted) { try { - await ref - .read(stockOutListProvider.notifier) - .rejectOrder(o.id); + await ref.read(stockOutListProvider.notifier).rejectOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( content: Text('已拒绝'), backgroundColor: AppTheme.accent)); @@ -446,9 +598,17 @@ class _StockOutDetailDialogState extends State<_StockOutDetailDialog> { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { - return Center( - child: Text('加载失败:${snap.error}', - style: const TextStyle(color: AppTheme.danger))); + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), + SizedBox(height: 12), + Text('暂无数据,网络不可用', + style: TextStyle(color: AppTheme.textSecondary)), + ], + ), + ); } return _buildContent(snap.data!); }, diff --git a/client/lib/widgets/data_table_card.dart b/client/lib/widgets/data_table_card.dart index e36ff53..b4735fe 100644 --- a/client/lib/widgets/data_table_card.dart +++ b/client/lib/widgets/data_table_card.dart @@ -27,33 +27,38 @@ class DataTableCard extends StatelessWidget { children: [ if (toolbar != null) Container( - height: 52, color: AppTheme.surface, - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: toolbar!, ), if (toolbar != null) const Divider(height: 1), Expanded( - child: SingleChildScrollView( - child: SizedBox( - width: double.infinity, - child: DataTable( - headingRowColor: WidgetStateProperty.all( - const Color(0xFFF0F4FF)), - headingTextStyle: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: + BoxConstraints(minWidth: constraints.maxWidth), + child: DataTable( + headingRowColor: WidgetStateProperty.all( + const Color(0xFFF0F4FF)), + headingTextStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark, + ), + dataTextStyle: const TextStyle( + fontSize: 13, color: AppTheme.textPrimary), + columnSpacing: 24, + horizontalMargin: 16, + dataRowMinHeight: 40, + dataRowMaxHeight: 56, + dividerThickness: 0.5, + columns: columns, + rows: rows, + ), ), - dataTextStyle: const TextStyle( - fontSize: 13, color: AppTheme.textPrimary), - columnSpacing: 24, - horizontalMargin: 16, - dataRowMinHeight: 40, - dataRowMaxHeight: 48, - dividerThickness: 0.5, - columns: columns, - rows: rows, ), ), ), diff --git a/client/lib/widgets/multi_select_dropdown.dart b/client/lib/widgets/multi_select_dropdown.dart new file mode 100644 index 0000000..1314b46 --- /dev/null +++ b/client/lib/widgets/multi_select_dropdown.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import '../core/theme/app_theme.dart'; + +/// Compact button → dialog with checkboxes for multi-select filtering +class MultiSelectDropdown extends StatelessWidget { + final String label; + final List options; + final Set selected; + final ValueChanged> onChanged; + + const MultiSelectDropdown({ + super.key, + required this.label, + required this.options, + required this.selected, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final active = selected.isNotEmpty; + return OutlinedButton( + onPressed: options.isEmpty ? null : () => _show(context), + style: OutlinedButton.styleFrom( + foregroundColor: active ? AppTheme.primary : AppTheme.textSecondary, + side: BorderSide(color: active ? AppTheme.primary : AppTheme.border), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.filter_list, size: 13), + const SizedBox(width: 4), + Text( + active ? '$label (${selected.length})' : label, + style: const TextStyle(fontSize: 12), + ), + if (active) ...[ + const SizedBox(width: 4), + GestureDetector( + onTap: () => onChanged({}), + child: const Icon(Icons.close, size: 12), + ), + ], + ], + ), + ); + } + + void _show(BuildContext context) { + showDialog( + context: context, + barrierColor: Colors.black12, + builder: (_) => _MultiSelectDialog( + label: label, + options: options, + initial: selected, + onApply: onChanged, + ), + ); + } +} + +class _MultiSelectDialog extends StatefulWidget { + final String label; + final List options; + final Set initial; + final ValueChanged> onApply; + + const _MultiSelectDialog({ + required this.label, + required this.options, + required this.initial, + required this.onApply, + }); + + @override + State<_MultiSelectDialog> createState() => _MultiSelectDialogState(); +} + +class _MultiSelectDialogState extends State<_MultiSelectDialog> { + late Set _selected; + + @override + void initState() { + super.initState(); + _selected = Set.from(widget.initial); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.label, style: const TextStyle(fontSize: 15)), + contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + content: SizedBox( + width: 220, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row(children: [ + TextButton( + onPressed: () => + setState(() => _selected = Set.from(widget.options)), + child: const Text('全选', style: TextStyle(fontSize: 12)), + ), + TextButton( + onPressed: () => setState(() => _selected = {}), + child: const Text('清空', style: TextStyle(fontSize: 12)), + ), + ]), + const Divider(height: 1), + ...widget.options.map((opt) => CheckboxListTile( + title: Text(opt, style: const TextStyle(fontSize: 13)), + value: _selected.contains(opt), + dense: true, + onChanged: (v) => setState(() { + if (v == true) { + _selected.add(opt); + } else { + _selected.remove(opt); + } + }), + )), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () { + widget.onApply(_selected); + Navigator.of(context).pop(); + }, + child: const Text('应用'), + ), + ], + ); + } +} + +/// Column definition for column visibility toggle +class ColDef { + final String key; + final String label; + final bool required; // if true, cannot be hidden + /// Screen width below which this column is automatically hidden + final double? minWidth; + + const ColDef(this.key, this.label, {this.required = false, this.minWidth}); +} + +/// Button that lets users toggle column visibility +class ColumnToggleButton extends StatelessWidget { + final List columns; + final Set hidden; + final ValueChanged> onChanged; + + const ColumnToggleButton({ + super.key, + required this.columns, + required this.hidden, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () => _show(context), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + side: const BorderSide(color: AppTheme.border), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.view_column_outlined, size: 13), + SizedBox(width: 4), + Text('显示字段', style: TextStyle(fontSize: 12)), + ], + ), + ); + } + + void _show(BuildContext context) { + showDialog( + context: context, + barrierColor: Colors.black12, + builder: (_) => _ColumnToggleDialog( + columns: columns, + initial: hidden, + onApply: onChanged, + ), + ); + } +} + +class _ColumnToggleDialog extends StatefulWidget { + final List columns; + final Set initial; + final ValueChanged> onApply; + + const _ColumnToggleDialog({ + required this.columns, + required this.initial, + required this.onApply, + }); + + @override + State<_ColumnToggleDialog> createState() => _ColumnToggleDialogState(); +} + +class _ColumnToggleDialogState extends State<_ColumnToggleDialog> { + late Set _hidden; + + @override + void initState() { + super.initState(); + _hidden = Set.from(widget.initial); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('显示字段', style: TextStyle(fontSize: 15)), + contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + content: SizedBox( + width: 220, + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.columns + .map((col) => CheckboxListTile( + title: Text(col.label, + style: const TextStyle(fontSize: 13)), + value: !_hidden.contains(col.key), + dense: true, + onChanged: col.required + ? null // required columns cannot be hidden + : (v) => setState(() { + if (v == true) { + _hidden.remove(col.key); + } else { + _hidden.add(col.key); + } + }), + )) + .toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () { + widget.onApply(_hidden); + Navigator.of(context).pop(); + }, + child: const Text('应用'), + ), + ], + ); + } +} diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..cc667fc 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,12 @@ import FlutterMacOS import Foundation +import package_info_plus import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + 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 1385d0f..bdfeb77 100644 --- a/client/macos/Podfile.lock +++ b/client/macos/Podfile.lock @@ -1,22 +1,34 @@ PODS: - FlutterMacOS (1.0.0) + - package_info_plus (0.0.1): + - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS DEPENDENCIES: - 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: FlutterMacOS: :path: Flutter/ephemeral + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/client/pubspec.lock b/client/pubspec.lock index 0be570a..1521d39 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -120,6 +120,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_mock_adapter: dependency: "direct dev" description: @@ -224,6 +232,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -405,6 +429,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -429,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -438,5 +534,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 714ddf6..4e09f0b 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: dio: ^5.4.3+1 shared_preferences: ^2.3.0 intl: ^0.19.0 + package_info_plus: ^8.0.0 + url_launcher: ^6.3.0 dev_dependencies: flutter_test: diff --git a/scripts/dev.sh b/scripts/dev.sh index 016fc0d..197ad28 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -117,9 +117,26 @@ case "$COMMAND" in COMMAND=run RUN_BACKEND=false ;; + 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 的进程,不杀连接客户端) + 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 + error "端口 8080 仍被占用,请手动处理" + else + success "后端已停止,端口 8080 已释放" + fi + exit 0 + ;; *) error "未知命令: $COMMAND" - echo "用法: sh scripts/dev.sh [run [--force] | seed | reset | clear | --backend-only | --frontend-only]" + echo "用法: sh scripts/dev.sh [run [--force] | seed | reset | clear | stop | --backend-only | --frontend-only]" exit 1 ;; esac