diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f4492..9325b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.40] - 2026-06-15 + +### 新功能 +- 商品标签打印新增编号行(显示「编号:xxxx」),位于度数上方,方便核对入库单 +- 扫码分享商品链接时,微信/企业微信/飞书/钉钉等平台的分享卡片现可显示商品名、规格描述及商品图片,不再只显示「岩美」 + +### 改进 +- 库存页表格支持横向滚动,列过多时不再截断;支持显示/隐藏列,按需定制显示内容 + ## [1.0.39] - 2026-06-15 ### 修复 diff --git a/backend/config/config.go b/backend/config/config.go index 94d8ee5..61fb6a0 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -43,6 +43,7 @@ type StorageConfig struct { UploadDir string `mapstructure:"upload_dir"` BaseURL string `mapstructure:"base_url"` PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码 + WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入 } var C Config @@ -66,6 +67,7 @@ func Load() { _ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR") _ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL") _ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL") + _ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR") // 默认值 viper.SetDefault("server.port", "8080") @@ -78,6 +80,7 @@ func Load() { viper.SetDefault("storage.upload_dir", "./uploads/images") viper.SetDefault("storage.base_url", "http://localhost:8080/images") viper.SetDefault("storage.public_url", "http://localhost:8081") + viper.SetDefault("storage.web_dir", "./client/build/web") if err := viper.ReadInConfig(); err != nil { log.Println("[config] no config file found, using defaults and env vars") diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go index d2c6f78..3073b3e 100644 --- a/backend/internal/handler/public.go +++ b/backend/internal/handler/public.go @@ -3,13 +3,16 @@ package handler import ( "errors" "fmt" + "html" "net/http" + "os" "strconv" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" ) @@ -274,3 +277,91 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) { "page_size": pageSize, }) } + +// ProductPage GET /product/:public_id +// 返回注入了基础 Open Graph 标签的 Flutter index.html,供微信/飞书等社交平台爬虫生成分享卡片。 +// 找不到商品时原样返回 index.html,让 Flutter 自行展示"商品不存在";爬虫拿不到 OG 标签但页面不报错。 +func (h *PublicHandler) ProductPage(c *gin.Context) { + publicID := c.Param("public_id") + + // 读取 Flutter 构建产物 index.html + idxPath := config.C.Storage.WebDir + "/index.html" + idxBytes, err := os.ReadFile(idxPath) + if err != nil { + c.String(http.StatusInternalServerError, "index.html not found: %s", idxPath) + return + } + idxHTML := string(idxBytes) + + // 查商品(只取 OG 所需字段,轻量查询) + var product model.Product + if err := h.db.Select("id, public_id, name, brand, series, spec, shop_id"). + Where("public_id = ? AND deleted_at IS NULL", publicID). + Preload("Images"). + First(&product).Error; err != nil { + // 查不到商品:原样返回 index.html,让 Flutter 展示"商品不存在" + c.Data(http.StatusOK, "text/html; charset=utf-8", idxBytes) + return + } + + // 查门店名 + var shop model.Shop + shopName := "" + if err := h.db.Select("name").Where("id = ?", product.ShopID).First(&shop).Error; err == nil { + shopName = shop.Name + } + + // 构造 OG 标签并注入 前 + ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL) + out := strings.Replace(idxHTML, "", ogTags+"", 1) + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out)) +} + +// buildProductOG 构造基础 Open Graph meta 标签字符串。 +// 所有值经 html.EscapeString 转义,防止 XSS 或破坏 HTML 结构。 +// 无图片时不输出 og:image 行。 +func buildProductOG(product model.Product, shopName, publicURL string) string { + // og:title:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列 + title := product.Name + if product.Brand != "" && !strings.Contains(product.Name, product.Brand) { + title = product.Brand + title + } + if product.Series != "" { + title = title + "(" + product.Series + ")" + } + + // og:description:规格(度数/香型/容量)| 门店名正品 · 扫码验真 + desc := product.Spec + suffix := "正品 · 扫码验真" + if shopName != "" { + suffix = shopName + suffix + } + if desc != "" { + desc = desc + " | " + suffix + } else { + desc = suffix + } + + // og:site_name + siteName := "岩美酒库" + if shopName != "" { + siteName = shopName + " · " + siteName + } + + // og:url + pageURL := publicURL + "/product/" + product.PublicID + + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(` ` + "\n") + sb.WriteString(` ` + "\n") + sb.WriteString(` ` + "\n") + sb.WriteString(` ` + "\n") + sb.WriteString(` ` + "\n") + // og:image:仅有图片时输出 + if len(product.Images) > 0 && product.Images[0].URL != "" { + imgURL := publicURL + product.Images[0].URL + sb.WriteString(` ` + "\n") + } + return sb.String() +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 533f5d5..36f118f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -45,6 +45,9 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 版本信息(无需认证,用于客户端更新检查) r.GET("/version", handler.GetVersion) + // 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取) + r.GET("/product/:public_id", publicH.ProductPage) + v1 := r.Group("/api/v1") // 公开路由(无需登录) diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 2e00774..6c0aa23 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -194,6 +194,7 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) { Future _renderLabelBitmap({ required String shop, required String name, + required String code, required String degSpec, required String date, required Uint8List qrBytes, @@ -226,9 +227,11 @@ Future _renderLabelBitmap({ // 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充 const lx = 22.0; const textMaxW = qrLeft - lx - 6; // 164 + final codeText = code.isNotEmpty ? '编号:$code' : ''; final rows = <(String, double, bool)>[ (shop, 0.9, true), (name, 1.5, true), + if (codeText.isNotEmpty) (codeText, 1.0, false), if (degSpec.isNotEmpty) (degSpec, 1.05, false), if (date.isNotEmpty) (date, 1.0, false), ]; @@ -253,6 +256,7 @@ Future _renderLabelBitmap({ Future _printFlatLabelThermal({ required String shop, required String name, + required String code, required String degSpec, required String date, required Uint8List qrBytes, @@ -265,7 +269,7 @@ Future _printFlatLabelThermal({ const w = 320, h = 160; final img = await _renderLabelBitmap( - shop: shop, name: name, degSpec: degSpec, date: date, qrBytes: qrBytes); + shop: shop, name: name, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes); final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba); final rgba = bd!.buffer.asUint8List(); @@ -332,6 +336,7 @@ Future printProductLabelImpl({ if (await _printFlatLabelThermal( shop: shopName, name: name, + code: code, degSpec: degSpecVal, date: dateVal == '—' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行 qrBytes: qrBytes, @@ -398,6 +403,12 @@ Future printProductLabelImpl({ fontWeight: pw.FontWeight.bold, color: _ink, letterSpacing: 0.3)), pw.SizedBox(height: 2.5), + // 商品编号(度数上方) + if (code.isNotEmpty) ...[ + pw.Text('编号:$code', + style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), + pw.SizedBox(height: 1.5), + ], // 度数(系列) + 规格 同一行, 无字段名标签 pw.Text(degSpecVal, style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), @@ -482,6 +493,7 @@ Future renderLabelPreviewImpl(LabelData label) async { final img = await _renderLabelBitmap( shop: label.shopName, name: label.name, + code: label.code, degSpec: degSpecVal, date: dateVal, qrBytes: label.qrBytes!, diff --git a/deploy/nginx-jiu.conf b/deploy/nginx-jiu.conf index 09ec4a6..064f7de 100644 --- a/deploy/nginx-jiu.conf +++ b/deploy/nginx-jiu.conf @@ -47,10 +47,12 @@ server { expires 0; } - # 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理 + # 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html location ~ ^/product/ { - root /opt/jiu/web; - try_files $uri /app/index.html; + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 30s; } # 桌面客户端安装包下载(Windows .exe / macOS .zip) diff --git a/deploy/production.env.template b/deploy/production.env.template index 5ddf348..28e8e67 100644 --- a/deploy/production.env.template +++ b/deploy/production.env.template @@ -9,4 +9,5 @@ LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS STORAGE_UPLOAD_DIR=/opt/jiu/images STORAGE_BASE_URL=https://jiu.51yanmei.com/images STORAGE_PUBLIC_URL=https://jiu.51yanmei.com +STORAGE_WEB_DIR=/opt/jiu/web DB_PASSWORD=CHANGE_ME_DB_PASS