From a05f9bd4ecc3284b0e9afc559575f5091549e7cc Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 24 May 2026 20:03:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(shop):=20=E9=97=A8=E5=BA=97=20logo=20?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20+=20=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=93=81?= =?UTF-8?q?=E7=89=8C=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shops 表新增 logo_url 字段,AutoMigrate 自动建列 - 后端新增 POST /api/v1/shop/logo 接口(管理员),图片裁剪为 256×256 JPEG 存储 - UpdateInfo 支持传入 logo_url - 侧边栏「岩美」替换为门店真实名称 + 自定义 logo - 无 logo 时显示店名首字文字头像(深蓝底白字) - 设置页「酒行信息」展示 logo 预览,编辑弹窗新增「更换 Logo」上传按钮 - 修复库存导入计数逻辑:total/imported/updated/errors 四项分别统计 - 库存导入去重改为双路索引(编号 + 名称|系列|规格),避免 key 不一致误判新增 - settings 页导入结果统一显示「重复 X 条」,兼容 skipped 和 updated 两个字段 Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/handler/import.go | 55 ++++++-- backend/internal/handler/shop.go | 57 ++++++++ backend/internal/model/shop.go | 1 + backend/internal/router/router.go | 1 + backend/schema/schema.sql | 1 + client/lib/models/shop.dart | 3 + client/lib/repositories/shop_repository.dart | 16 +++ .../inventory/inventory_list_screen.dart | 45 ++++-- .../lib/screens/settings/settings_screen.dart | 132 ++++++++++++++++-- client/lib/screens/shell/app_shell.dart | 102 ++++++-------- 10 files changed, 320 insertions(+), 93 deletions(-) diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index d96ba77..76c9e54 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -3,6 +3,7 @@ package handler import ( "fmt" "io" + "log" "net/http" "os" "strconv" @@ -512,9 +513,9 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { } type importResult struct { + total int imported int updated int - skipped int errors []string } var res importResult @@ -550,22 +551,37 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { } // 预加载已有导入库存(1 次查询) + // 同时建两套索引:编号索引 + 名称|系列|规格索引,供后续双路查找 var allInvs []model.Inventory h.db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID).Find(&allInvs) - invByKey := make(map[string]*model.Inventory, len(allInvs)) + invByCode := make(map[string]*model.Inventory, len(allInvs)) // key: productCode|warehouseID + invByNSS := make(map[string]*model.Inventory, len(allInvs)) // key: name|series|spec|warehouseID for i := range allInvs { inv := &allInvs[i] whID := uint64(0) if inv.WarehouseID != nil { whID = *inv.WarehouseID } - invByKey[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv + if inv.ProductCode != "" { + invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv + } + nssKey := fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whID) + invByNSS[nssKey] = inv + } + lookupInv := func(productCode, name, series, spec string, whID uint64) *model.Inventory { + if productCode != "" { + if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok { + return inv + } + } + return invByNSS[fmt.Sprintf("%s|%s|%s|%d", name, series, spec, whID)] } // Dynamic column detection from header row colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4 colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15 if len(rows) > 0 { + log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0]) for j, h := range rows[0] { switch strings.TrimSpace(h) { case "商品编号", "商品编码", "编号", "编码", "商品条码": @@ -595,6 +611,8 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { } } } + log.Printf("[import-inv] total rows=%d, colProductName=%d, colProductCode=%d, colQty=%d", + len(rows), colProductName, colProductCode, colQty) // 如果没有匹配到任何列头,返回诊断信息 detectedHeader := strings.Join(rows[0], " | ") @@ -603,9 +621,12 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { for i, row := range rows[1:] { productName := cell(row, colProductName) + if i < 5 { + log.Printf("[import-inv] row[%d] len=%d | productName=%q productCode=%q qty=%q", + i+2, len(row), productName, cell(row, colProductCode), cell(row, colQty)) + } if productName == "" { - res.skipped++ - continue + continue // 空行(文件末尾填充行),不计入 total } productCode := cell(row, colProductCode) @@ -626,6 +647,8 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { } price, _ := strconv.ParseFloat(priceStr, 64) + res.total++ // 有商品名称的行才计入总数 + // 从缓存查商品,找不到才创建 var prod *model.Product if productCode != "" { @@ -666,13 +689,12 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { unitPricePtr = &price } - // 从缓存查库存记录 whIDVal := uint64(0) if whIDPtr != nil { whIDVal = *whIDPtr } - invKey := fmt.Sprintf("%s|%d", prod.Code, whIDVal) - existing := invByKey[invKey] + + existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal) if existing != nil { updates := map[string]interface{}{ @@ -729,7 +751,11 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error())) continue } - invByKey[invKey] = &inv + // 写入两套缓存,防止同文件后续行重复插入 + if inv.ProductCode != "" { + invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whIDVal)] = &inv + } + invByNSS[fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whIDVal)] = &inv logsToCreate = append(logsToCreate, model.InventoryLog{ ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID, Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty, @@ -744,16 +770,19 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { h.db.CreateInBatches(&logsToCreate, 100) } - // 全部跳过说明列格式不匹配,返回诊断信息 - if res.imported == 0 && res.updated == 0 && res.skipped > 0 && len(res.errors) == 0 { + // total=0 说明列格式不匹配,没有解析到任何有效行 + if res.total == 0 { res.errors = append(res.errors, - fmt.Sprintf("所有行商品名称列为空,可能列格式不匹配。识别到的表头:%s", detectedHeader)) + fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader)) } + log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d", + res.total, res.imported, res.updated, len(res.errors)) + c.JSON(http.StatusOK, gin.H{ + "total": res.total, "imported": res.imported, "updated": res.updated, - "skipped": res.skipped, "errors": res.errors, }) } diff --git a/backend/internal/handler/shop.go b/backend/internal/handler/shop.go index 87e9acb..a9fc071 100644 --- a/backend/internal/handler/shop.go +++ b/backend/internal/handler/shop.go @@ -1,11 +1,19 @@ package handler import ( + "fmt" + "image" + _ "image/jpeg" + _ "image/png" "net/http" + "os" + "path/filepath" + "github.com/disintegration/imaging" "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" ) @@ -38,6 +46,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) { Address string `json:"address"` Phone string `json:"phone"` ManagerName string `json:"manager_name"` + LogoURL string `json:"logo_url"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -50,6 +59,9 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) { "phone": req.Phone, "manager_name": req.ManagerName, } + if req.LogoURL != "" { + updates["logo_url"] = req.LogoURL + } if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -59,3 +71,48 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) { h.db.First(&shop, shopID) c.JSON(http.StatusOK, shop) } + +// UploadLogo POST /api/v1/shop/logo (admin only) +func (h *ShopHandler) UploadLogo(c *gin.Context) { + shopID := middleware.GetShopID(c) + + if err := c.Request.ParseMultipartForm(2 << 20); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 2MB 限制"}) + return + } + file, _, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file)"}) + return + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"}) + return + } + + // 裁剪为正方形后缩放到 256×256 + resized := imaging.Fill(img, 256, 256, imaging.Center, imaging.Lanczos) + + subdir := filepath.Join(config.C.Storage.UploadDir, "shops", fmt.Sprintf("%d", shopID)) + if err := os.MkdirAll(subdir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"}) + return + } + fullPath := filepath.Join(subdir, "logo.jpg") + + if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(90)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"}) + return + } + + logoURL := fmt.Sprintf("/images/shops/%d/logo.jpg", shopID) + if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Update("logo_url", logoURL).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"logo_url": logoURL}) +} diff --git a/backend/internal/model/shop.go b/backend/internal/model/shop.go index 6151c89..505ad0f 100644 --- a/backend/internal/model/shop.go +++ b/backend/internal/model/shop.go @@ -8,6 +8,7 @@ type Shop struct { Phone string `gorm:"size:30" json:"phone"` BusinessHours string `gorm:"size:100" json:"business_hours"` ManagerName string `gorm:"size:50" json:"manager_name"` + LogoURL string `gorm:"column:logo_url;size:500" json:"logo_url"` BusinessLicense string `gorm:"size:500" json:"business_license"` ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"` CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6c4ed4b..51d2897 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -165,6 +165,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { { shop.GET("/info", shopH.GetInfo) shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo) + shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo) } // 编号规则 diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index 405f0d8..8895ff8 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `shops` ( `address` VARCHAR(255) DEFAULT NULL, `phone` VARCHAR(30) DEFAULT NULL, `manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人', + `logo_url` VARCHAR(500) DEFAULT '' COMMENT '门店 logo URL', `business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL', `shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组', `custom_fields` JSON DEFAULT NULL COMMENT '扩展字段', diff --git a/client/lib/models/shop.dart b/client/lib/models/shop.dart index cad8963..6de9f1d 100644 --- a/client/lib/models/shop.dart +++ b/client/lib/models/shop.dart @@ -5,6 +5,7 @@ class ShopInfo { final String address; final String phone; final String managerName; + final String logoUrl; const ShopInfo({ required this.id, @@ -13,6 +14,7 @@ class ShopInfo { required this.address, required this.phone, required this.managerName, + this.logoUrl = '', }); factory ShopInfo.fromJson(Map json) => ShopInfo( @@ -22,5 +24,6 @@ class ShopInfo { address: json['address'] as String? ?? '', phone: json['phone'] as String? ?? '', managerName: json['manager_name'] as String? ?? '', + logoUrl: json['logo_url'] as String? ?? '', ); } diff --git a/client/lib/repositories/shop_repository.dart b/client/lib/repositories/shop_repository.dart index 26e6982..72a4716 100644 --- a/client/lib/repositories/shop_repository.dart +++ b/client/lib/repositories/shop_repository.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import '../core/api/api_client.dart'; import '../core/exceptions.dart'; import '../models/shop.dart'; @@ -30,4 +31,19 @@ class ShopRepository { ); } } + + Future uploadLogo(Uint8List bytes, String filename) async { + try { + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: filename), + }); + final resp = await _client.post('/shop/logo', data: formData); + return (resp.data as Map)['logo_url'] as String? ?? ''; + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? 'Logo 上传失败', + statusCode: e.response?.statusCode, + ); + } + } } diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index ab8e8fa..d1f0105 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -171,14 +171,17 @@ class _InventoryListScreenState extends ConsumerState { stateNotifier.value = _ImportState( stage: _ImportStage.done, uploadPercent: 100, + total: data['total'] ?? 0, imported: data['imported'] ?? 0, updated: data['updated'] ?? 0, - skipped: data['skipped'] ?? 0, errors: (data['errors'] as List?)?.cast() ?? [], ); - await Future.delayed(const Duration(seconds: 2)); - closeDialog(); + final hasErrors = (data['errors'] as List?)?.isNotEmpty ?? false; + if (!hasErrors) { + await Future.delayed(const Duration(seconds: 2)); + closeDialog(); + } if (context.mounted) ref.read(inventoryListProvider.notifier).reload(); } on DioException catch (e) { processingTimer?.cancel(); @@ -926,9 +929,9 @@ class _ImportState { final _ImportStage stage; final int uploadPercent; final int processingSeconds; + final int total; final int imported; final int updated; - final int skipped; final List errors; final String? errorMsg; @@ -936,9 +939,9 @@ class _ImportState { required this.stage, required this.uploadPercent, this.processingSeconds = 0, + this.total = 0, this.imported = 0, this.updated = 0, - this.skipped = 0, this.errors = const [], this.errorMsg, }); @@ -1024,25 +1027,39 @@ class _ImportProgressDialog extends StatelessWidget { ); case _ImportStage.done: + final allFailed = state.total == 0 && state.errors.isNotEmpty; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - Icon(Icons.check_circle, color: AppTheme.success, size: 20), + Icon( + allFailed ? Icons.warning_amber_rounded : Icons.check_circle, + color: allFailed ? AppTheme.danger : AppTheme.success, + size: 20, + ), const SizedBox(width: 8), - const Text('导入成功', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + Text( + allFailed ? '导入失败' : '导入完成', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), ]), const SizedBox(height: 10), - Text('新增:${state.imported} 条', style: const TextStyle(fontSize: 13)), - if (state.updated > 0) - Text('更新:${state.updated} 条', style: const TextStyle(fontSize: 13)), - if (state.skipped > 0) - Text('跳过:${state.skipped} 条', - style: TextStyle(fontSize: 13, color: Colors.grey[600])), + Text('共 ${state.total} 行', style: const TextStyle(fontSize: 13)), + Text('成功插入:${state.imported} 行', style: const TextStyle(fontSize: 13)), + Text('重复更新:${state.updated} 行', + style: TextStyle(fontSize: 13, color: Colors.grey[600])), if (state.errors.isNotEmpty) - Text('失败:${state.errors.length} 条', + Text('失败:${state.errors.length} 行', style: TextStyle(fontSize: 13, color: AppTheme.danger)), + if (state.errors.isNotEmpty) ...[ + const SizedBox(height: 6), + ...state.errors.map((e) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(e, + style: TextStyle(fontSize: 12, color: AppTheme.danger)), + )), + ], ], ); diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index f0cff1b..7c30b7b 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -127,14 +127,24 @@ class _SettingsScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _ShopInfoRow( - label: '门店编号', - value: shop.code.isNotEmpty ? shop.code : '—'), - const Divider(height: 16), - _ShopInfoRow( - label: '门店名称', - value: shop.name.isNotEmpty ? shop.name : '—'), - const Divider(height: 16), + // Logo 预览行 + Row( + children: [ + _ShopLogoPreview(logoUrl: shop.logoUrl, shopName: shop.name, size: 64), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(shop.name.isNotEmpty ? shop.name : '—', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(shop.code.isNotEmpty ? shop.code : '—', + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + ], + ), + ], + ), + const Divider(height: 24), _ShopInfoRow( label: '门店地址', value: shop.address.isNotEmpty ? shop.address : '—'), @@ -1525,6 +1535,7 @@ class _ImportSlot { int total = 0; int imported = 0; int skipped = 0; + int updated = 0; // 进度 int uploadPercent = 0; bool isProcessing = false; @@ -1718,9 +1729,10 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { final data = (resp.data is Map) ? resp.data as Map : {}; if (mounted) setState(() { slot.success = true; - slot.total = (data['total'] ?? data['imported'] ?? 0) as int; slot.imported = (data['imported'] ?? 0) as int; slot.skipped = (data['skipped'] ?? 0) as int; + slot.updated = (data['updated'] ?? 0) as int; + slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int; slot.isProcessing = false; }); } on DioException catch (e) { @@ -2058,10 +2070,11 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { Widget? statusWidget; if (slot.hasResult) { if (slot.success == true) { + final duplicate = slot.skipped + slot.updated; final parts = [ '共 ${slot.total} 条', '新增 ${slot.imported} 条', - if (slot.skipped > 0) '重复跳过 ${slot.skipped} 条', + if (duplicate > 0) '重复 $duplicate 条', ]; statusWidget = Row( mainAxisSize: MainAxisSize.min, @@ -2177,6 +2190,54 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { } } +// ── 门店 Logo 预览 ──────────────────────────────────────── +class _ShopLogoPreview extends StatelessWidget { + final String logoUrl; + final String shopName; + final double size; + const _ShopLogoPreview({required this.logoUrl, required this.shopName, this.size = 64}); + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(size * 0.19); + if (logoUrl.isNotEmpty) { + final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl'; + return ClipRRect( + borderRadius: radius, + child: Image.network( + fullUrl, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _initial(radius), + ), + ); + } + return _initial(radius); + } + + Widget _initial(BorderRadius radius) { + final initial = shopName.isNotEmpty ? shopName.characters.first : '店'; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: const Color(0xFF0F3057), + borderRadius: radius, + ), + alignment: Alignment.center, + child: Text( + initial, + style: TextStyle( + color: Colors.white, + fontSize: size * 0.5, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + // ── 酒行信息行 ──────────────────────────────────────────── class _ShopInfoRow extends StatelessWidget { final String label; @@ -2222,6 +2283,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { late final TextEditingController _phoneCtrl; late final TextEditingController _managerCtrl; bool _saving = false; + bool _uploadingLogo = false; @override void initState() { @@ -2241,6 +2303,37 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { super.dispose(); } + Future _uploadLogo() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png'], + withData: true, + ); + if (result == null || result.files.isEmpty) return; + final file = result.files.first; + if (file.bytes == null) return; + setState(() => _uploadingLogo = true); + try { + await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name); + ref.invalidate(shopInfoProvider); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Logo 已更新'), + backgroundColor: AppTheme.success, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('上传失败:$e'), + backgroundColor: AppTheme.danger, + )); + } + } finally { + if (mounted) setState(() => _uploadingLogo = false); + } + } + Future _save() async { setState(() => _saving = true); try { @@ -2279,6 +2372,25 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Logo 上传行 + Row( + children: [ + _ShopLogoPreview( + logoUrl: ref.watch(shopInfoProvider).valueOrNull?.logoUrl ?? widget.shop.logoUrl, + shopName: _nameCtrl.text, + size: 56, + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _uploadingLogo ? null : _uploadLogo, + icon: _uploadingLogo + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.upload_outlined, size: 16), + label: const Text('更换 Logo'), + ), + ], + ), + const SizedBox(height: 16), TextField( controller: _nameCtrl, decoration: const InputDecoration(labelText: '门店名称'), diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 0fc5ce7..0016232 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -5,8 +5,10 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'dart:async'; import '../../core/auth/auth_state.dart'; +import '../../core/config/app_config.dart'; import '../../core/theme/app_theme.dart'; import '../../providers/connectivity_provider.dart'; +import '../../providers/shop_provider.dart'; import '../../providers/update_provider.dart'; class AppShell extends ConsumerStatefulWidget { @@ -575,27 +577,31 @@ void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0' ); } -class _ShopButton extends StatelessWidget { +class _ShopButton extends ConsumerWidget { final AuthUser? user; final String version; const _ShopButton({this.user, this.version = 'v1.0.0'}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final shopAsync = ref.watch(shopInfoProvider); + final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? ''; + final logoUrl = shopAsync.valueOrNull?.logoUrl ?? ''; + return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { if (user != null) _showShopPanel(context, user!, version: version); }, - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - _YanmeiMark(size: 28), - SizedBox(width: 10), + _ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28), + const SizedBox(width: 10), Text( - '岩美', - style: TextStyle( + shopName.isEmpty ? '—' : shopName, + style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, @@ -608,70 +614,54 @@ class _ShopButton extends StatelessWidget { } } -/// Brand mark widget — approximates the 岩美 logo SVG without flutter_svg. -/// Dark blue rounded rect, white mountain/wave strokes, bordeaux dot. -class _YanmeiMark extends StatelessWidget { +/// 门店 Logo:有图片显示网络图片,无图片显示店名首字文字头像。 +class _ShopLogo extends StatelessWidget { + final String logoUrl; + final String shopName; final double size; - const _YanmeiMark({this.size = 32}); + const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32}); @override Widget build(BuildContext context) { + final radius = BorderRadius.circular(size * 0.19); + if (logoUrl.isNotEmpty) { + final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl'; + return ClipRRect( + borderRadius: radius, + child: Image.network( + fullUrl, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _initial(radius), + ), + ); + } + return _initial(radius); + } + + Widget _initial(BorderRadius radius) { + final initial = shopName.isNotEmpty ? shopName.characters.first : '店'; return Container( width: size, height: size, decoration: BoxDecoration( color: const Color(0xFF0F3057), - borderRadius: BorderRadius.circular(size * 0.19), + borderRadius: radius, ), - child: CustomPaint( - painter: _YanmeiMarkPainter(), + alignment: Alignment.center, + child: Text( + initial, + style: TextStyle( + color: Colors.white, + fontSize: size * 0.5, + fontWeight: FontWeight.w600, + ), ), ); } } -class _YanmeiMarkPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final w = size.width; - final h = size.height; - final paint = Paint() - ..color = Colors.white - ..strokeWidth = w * 0.055 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round; - - // Mountain/wave path: M14 38 L22 22 L32 32 L42 22 L50 38 (on 64px grid) - final path = Path(); - path.moveTo(w * 0.219, h * 0.594); - path.lineTo(w * 0.344, h * 0.344); - path.lineTo(w * 0.500, h * 0.500); - path.lineTo(w * 0.656, h * 0.344); - path.lineTo(w * 0.781, h * 0.594); - canvas.drawPath(path, paint); - - // Horizontal baseline: M12 46 L52 46 (on 64px grid) - canvas.drawLine( - Offset(w * 0.1875, h * 0.719), - Offset(w * 0.8125, h * 0.719), - paint, - ); - - // Bordeaux dot: circle cx=32 cy=52 r=2.4 (on 64px grid) - canvas.drawCircle( - Offset(w * 0.500, h * 0.859), - w * 0.042, - Paint() - ..color = const Color(0xFFC97B86) - ..style = PaintingStyle.fill, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - class _InfoRow extends StatelessWidget { final IconData icon; final String label;