feat(shop): 门店 logo 上传 + 侧边栏品牌替换
Deploy / deploy (push) Failing after 8s

- 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-05-24 20:03:43 +08:00
parent 831dbc5959
commit a05f9bd4ec
10 changed files with 320 additions and 93 deletions
+42 -13
View File
@@ -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,
})
}
+57
View File
@@ -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})
}