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