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
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:
@@ -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
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
// 公开路由(无需登录)
|
// 公开路由(无需登录)
|
||||||
|
|||||||
@@ -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!,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user