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/), 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). 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 ## [1.0.39] - 2026-06-15
### 修复 ### 修复
+3
View File
@@ -43,6 +43,7 @@ type StorageConfig struct {
UploadDir string `mapstructure:"upload_dir"` UploadDir string `mapstructure:"upload_dir"`
BaseURL string `mapstructure:"base_url"` BaseURL string `mapstructure:"base_url"`
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码 PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入
} }
var C Config var C Config
@@ -66,6 +67,7 @@ func Load() {
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR") _ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL") _ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL") _ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
// 默认值 // 默认值
viper.SetDefault("server.port", "8080") viper.SetDefault("server.port", "8080")
@@ -78,6 +80,7 @@ func Load() {
viper.SetDefault("storage.upload_dir", "./uploads/images") viper.SetDefault("storage.upload_dir", "./uploads/images")
viper.SetDefault("storage.base_url", "http://localhost:8080/images") viper.SetDefault("storage.base_url", "http://localhost:8080/images")
viper.SetDefault("storage.public_url", "http://localhost:8081") viper.SetDefault("storage.public_url", "http://localhost:8081")
viper.SetDefault("storage.web_dir", "./client/build/web")
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file found, using defaults and env vars") log.Println("[config] no config file found, using defaults and env vars")
+91
View File
@@ -3,13 +3,16 @@ package handler
import ( import (
"errors" "errors"
"fmt" "fmt"
"html"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/model"
) )
@@ -274,3 +277,91 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
"page_size": pageSize, "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) r.GET("/version", handler.GetVersion)
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
r.GET("/product/:public_id", publicH.ProductPage)
v1 := r.Group("/api/v1") 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({ Future<ui.Image> _renderLabelBitmap({
required String shop, required String shop,
required String name, required String name,
required String code,
required String degSpec, required String degSpec,
required String date, required String date,
required Uint8List qrBytes, required Uint8List qrBytes,
@@ -226,9 +227,11 @@ Future<ui.Image> _renderLabelBitmap({
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充 // 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充
const lx = 22.0; const lx = 22.0;
const textMaxW = qrLeft - lx - 6; // 164 const textMaxW = qrLeft - lx - 6; // 164
final codeText = code.isNotEmpty ? '编号:$code' : '';
final rows = <(String, double, bool)>[ final rows = <(String, double, bool)>[
(shop, 0.9, true), (shop, 0.9, true),
(name, 1.5, true), (name, 1.5, true),
if (codeText.isNotEmpty) (codeText, 1.0, false),
if (degSpec.isNotEmpty) (degSpec, 1.05, false), if (degSpec.isNotEmpty) (degSpec, 1.05, false),
if (date.isNotEmpty) (date, 1.0, false), if (date.isNotEmpty) (date, 1.0, false),
]; ];
@@ -253,6 +256,7 @@ Future<ui.Image> _renderLabelBitmap({
Future<bool> _printFlatLabelThermal({ Future<bool> _printFlatLabelThermal({
required String shop, required String shop,
required String name, required String name,
required String code,
required String degSpec, required String degSpec,
required String date, required String date,
required Uint8List qrBytes, required Uint8List qrBytes,
@@ -265,7 +269,7 @@ Future<bool> _printFlatLabelThermal({
const w = 320, h = 160; const w = 320, h = 160;
final img = await _renderLabelBitmap( 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 bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List(); final rgba = bd!.buffer.asUint8List();
@@ -332,6 +336,7 @@ Future<void> printProductLabelImpl({
if (await _printFlatLabelThermal( if (await _printFlatLabelThermal(
shop: shopName, shop: shopName,
name: name, name: name,
code: code,
degSpec: degSpecVal, degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行 date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes, qrBytes: qrBytes,
@@ -398,6 +403,12 @@ Future<void> printProductLabelImpl({
fontWeight: pw.FontWeight.bold, fontWeight: pw.FontWeight.bold,
color: _ink, letterSpacing: 0.3)), color: _ink, letterSpacing: 0.3)),
pw.SizedBox(height: 2.5), 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, pw.Text(degSpecVal,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
@@ -482,6 +493,7 @@ Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
final img = await _renderLabelBitmap( final img = await _renderLabelBitmap(
shop: label.shopName, shop: label.shopName,
name: label.name, name: label.name,
code: label.code,
degSpec: degSpecVal, degSpec: degSpecVal,
date: dateVal, date: dateVal,
qrBytes: label.qrBytes!, qrBytes: label.qrBytes!,
+5 -3
View File
@@ -47,10 +47,12 @@ server {
expires 0; expires 0;
} }
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理 # 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html
location ~ ^/product/ { location ~ ^/product/ {
root /opt/jiu/web; proxy_pass http://127.0.0.1:8080;
try_files $uri /app/index.html; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
} }
# 桌面客户端安装包下载(Windows .exe / macOS .zip # 桌面客户端安装包下载(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_UPLOAD_DIR=/opt/jiu/images
STORAGE_BASE_URL=https://jiu.51yanmei.com/images STORAGE_BASE_URL=https://jiu.51yanmei.com/images
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
STORAGE_WEB_DIR=/opt/jiu/web
DB_PASSWORD=CHANGE_ME_DB_PASS DB_PASSWORD=CHANGE_ME_DB_PASS