chore: release v1.0.40
Deploy / build-linux-web (push) Successful in 48s
Deploy / build-windows (push) Successful in 1m41s
Deploy / build-macos (push) Successful in 1m22s
Deploy / build-android (push) Successful in 1m9s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m49s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-15 14:23:03 +08:00
parent c4b4259a3d
commit a5bdb79f81
7 changed files with 125 additions and 4 deletions
+9
View File
@@ -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
### 修复
+3
View File
@@ -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")
+91
View File
@@ -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 标签并注入 </head> 前
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
out := strings.Replace(idxHTML, "</head>", ogTags+"</head>", 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(` <meta property="og:type" content="website">` + "\n")
sb.WriteString(` <meta property="og:site_name" content="` + html.EscapeString(siteName) + `">` + "\n")
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(pageURL) + `">` + "\n")
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(title) + `">` + "\n")
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(desc) + `">` + "\n")
// og:image:仅有图片时输出
if len(product.Images) > 0 && product.Images[0].URL != "" {
imgURL := publicURL + product.Images[0].URL
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(imgURL) + `">` + "\n")
}
return sb.String()
}
+3
View File
@@ -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")
// 公开路由(无需登录)
+13 -1
View File
@@ -194,6 +194,7 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
Future<ui.Image> _renderLabelBitmap({
required String shop,
required String name,
required String code,
required String degSpec,
required String date,
required Uint8List qrBytes,
@@ -226,9 +227,11 @@ Future<ui.Image> _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<ui.Image> _renderLabelBitmap({
Future<bool> _printFlatLabelThermal({
required String shop,
required String name,
required String code,
required String degSpec,
required String date,
required Uint8List qrBytes,
@@ -265,7 +269,7 @@ Future<bool> _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<void> printProductLabelImpl({
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
code: code,
degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes,
@@ -398,6 +403,12 @@ Future<void> 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<Uint8List> renderLabelPreviewImpl(LabelData label) async {
final img = await _renderLabelBitmap(
shop: label.shopName,
name: label.name,
code: label.code,
degSpec: degSpecVal,
date: dateVal,
qrBytes: label.qrBytes!,
+5 -3
View File
@@ -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
+1
View File
@@ -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