Compare commits

...

11 Commits

Author SHA1 Message Date
wangjia bf36f7aa3f fix(client): 标签字体细化:阈值 128,店名细体
Deploy / build-linux-web (push) Successful in 46s
Deploy / build-windows (push) Successful in 1m43s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 59s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:04:32 +08:00
wangjia 097e466372 fix(client): 标签打印 2× 超采样 + 阈值 160,消除热敏字体模糊
Deploy / build-linux-web (push) Successful in 51s
Deploy / build-windows (push) Successful in 1m46s
Deploy / build-macos (push) Successful in 1m30s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m54s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:20:42 +08:00
wangjia c1ba6c6d97 feat(backend): 二维码链接统一改为 /app/product/ 路径,消除 URL 跳变
Deploy / build-linux-web (push) Successful in 46s
Deploy / build-windows (push) Successful in 1m41s
Deploy / build-macos (push) Successful in 1m27s
Deploy / build-android (push) Successful in 58s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:55:24 +08:00
wangjia 2b327cd365 fix(backend): 修复 /app/product/ 分享无 OG 标签,描述分两行显示
Deploy / build-linux-web (push) Successful in 49s
Deploy / build-windows (push) Successful in 1m45s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 58s
Deploy / build-ios (push) Failing after 10m6s
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:52:41 +08:00
wangjia 07f8973477 fix(backend): 分享卡片描述补充系列(度数)信息
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-windows (push) Successful in 1m44s
Deploy / build-macos (push) Successful in 1m26s
Deploy / build-android (push) Successful in 58s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:36:04 +08:00
wangjia 3b02a848c0 fix(client): 商品公开页加载后更新浏览器标题为商品名称
Deploy / build-linux-web (push) Successful in 49s
Deploy / build-windows (push) Successful in 1m43s
Deploy / build-macos (push) Successful in 1m26s
Deploy / build-android (push) Successful in 58s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m46s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:26:22 +08:00
wangjia bde2d35756 fix(backend): 修复分享卡片 <title> 因缩进导致替换失效的问题
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-windows (push) Successful in 1m45s
Deploy / build-macos (push) Successful in 1m27s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:37:20 +08:00
wangjia 4ac5adac3e chore: release v1.0.44
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-windows (push) Successful in 1m44s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:31:47 +08:00
wangjia 8c2dcf49a8 chore: release v1.0.43
Deploy / build-linux-web (push) Successful in 49s
Deploy / build-windows (push) Successful in 1m44s
Deploy / build-macos (push) Successful in 1m25s
Deploy / build-android (push) Successful in 1m9s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:11:38 +08:00
wangjia f400459497 fix(backend): 修复扫码页 index.html not found
Deploy / build-linux-web (push) Successful in 50s
Deploy / build-windows (push) Successful in 1m42s
Deploy / build-macos (push) Successful in 1m28s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:11:30 +08:00
wangjia 64ac17d6b5 fix(client): 标签打印布局与字号优化
Deploy / build-linux-web (push) Successful in 49s
Deploy / build-windows (push) Successful in 1m43s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 1m9s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:00:44 +08:00
7 changed files with 160 additions and 39 deletions
+59
View File
@@ -5,6 +5,65 @@ 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.51] - 2026-06-15
### 改进
- 标签打印字体细化:阈值调回 128,店名改细体,商品名保持粗体
## [1.0.50] - 2026-06-15
### 改进
- 标签打印字体更清晰:采用 2× 超采样渲染,消除热敏打印时反锯齿导致的模糊笔划
## [1.0.49] - 2026-06-15
### 改进
- 二维码链接统一使用 `/app/product/` 路径,扫码后 URL 不再跳变,分享与打开同一个链接
## [1.0.48] - 2026-06-15
### 修复
- 修复分享 `/app/product/` 路径时微信爬虫拿不到 OG 标签的问题(nginx 补充该路径的代理规则)
- 分享卡片描述规格/系列与门店名分两行显示
## [1.0.47] - 2026-06-15
### 修复
- 微信分享卡片描述补充度数/系列信息,不再只显示规格
## [1.0.46] - 2026-06-15
### 修复
- 微信内打开商品页时顶部标题栏显示商品名称,不再显示"岩美"
## [1.0.45] - 2026-06-15
### 修复
- 修复微信分享卡片标题仍显示"岩美"的问题(HTML 缩进导致字符串匹配失败)
- 微信分享卡片描述去掉"扫码验真"后缀
## [1.0.44] - 2026-06-15
### 改进
- 微信分享卡片标题改为商品名称(之前仍显示"岩美")
- 微信分享卡片描述去掉"扫码验真"后缀
## [1.0.43] - 2026-06-15
### 修复
- 修复微信/飞书分享商品链接时卡片只显示"岩美"而不显示商品名称和图片的问题
## [1.0.42] - 2026-06-15
### 修复
- 修复商品二维码扫码页报"index.html not found"的错误
## [1.0.41] - 2026-06-15
### 改进
- 标签打印布局优化:编号字体与度数/日期统一大小,内容整体下移并填满标签底部,店名适当放大
- 标签度数与规格之间加宽间距,两个字段更易区分
## [1.0.40] - 2026-06-15
### 新功能
+1 -1
View File
@@ -80,7 +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")
viper.SetDefault("storage.web_dir", "./web")
if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file found, using defaults and env vars")
+1 -1
View File
@@ -198,7 +198,7 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
h.db.Model(&product).Update("public_id", product.PublicID)
}
url := config.C.Storage.PublicURL + "/product/" + product.PublicID
url := config.C.Storage.PublicURL + "/app/product/" + product.PublicID
if product.Code != "" {
url += "?code=" + product.Code
}
+31 -11
View File
@@ -6,6 +6,7 @@ import (
"html"
"net/http"
"os"
"regexp"
"strconv"
"strings"
@@ -313,15 +314,19 @@ func (h *PublicHandler) ProductPage(c *gin.Context) {
// 构造 OG 标签并注入 </head> 前
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
out := strings.Replace(idxHTML, "</head>", ogTags+"</head>", 1)
ogTitle := buildOGTitle(product)
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
// 用正则替换避免缩进空格导致字符串不匹配
titleRe := regexp.MustCompile(`<title>[^<]*</title>`)
out := titleRe.ReplaceAllString(idxHTML, "<title>"+html.EscapeString(ogTitle)+"</title>")
out = strings.Replace(out, "</head>", ogTags+"</head>", 1)
// 禁止缓存,确保爬虫每次都能拿到最新 OG 标签
c.Header("Cache-Control", "no-store")
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:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列
// buildOGTitle 生成商品的分享标题:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列
func buildOGTitle(product model.Product) string {
title := product.Name
if product.Brand != "" && !strings.Contains(product.Name, product.Brand) {
title = product.Brand + title
@@ -329,15 +334,30 @@ func buildProductOG(product model.Product, shopName, publicURL string) string {
if product.Series != "" {
title = title + "" + product.Series + ""
}
return title
}
// og:description:规格(度数/香型/容量)| 门店名正品 · 扫码验真
desc := product.Spec
suffix := "正品 · 扫码验真"
// buildProductOG 构造基础 Open Graph meta 标签字符串。
// 所有值经 html.EscapeString 转义,防止 XSS 或破坏 HTML 结构。
// 无图片时不输出 og:image 行。
func buildProductOG(product model.Product, shopName, publicURL string) string {
title := buildOGTitle(product)
// og:description:系列(度数/香型)· 规格(容量/单位)| 门店名正品
var specParts []string
if product.Series != "" {
specParts = append(specParts, product.Series)
}
if product.Spec != "" {
specParts = append(specParts, product.Spec)
}
desc := strings.Join(specParts, " · ")
suffix := "正品"
if shopName != "" {
suffix = shopName + suffix
}
if desc != "" {
desc = desc + " | " + suffix
desc = desc + "\n" + suffix
} else {
desc = suffix
}
@@ -349,7 +369,7 @@ func buildProductOG(product model.Product, shopName, publicURL string) string {
}
// og:url
pageURL := publicURL + "/product/" + product.PublicID
pageURL := publicURL + "/app/product/" + product.PublicID
var sb strings.Builder
sb.WriteString("\n")
+50 -26
View File
@@ -189,7 +189,8 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
return size;
}
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
Future<ui.Image> _renderLabelBitmap({
required String shop,
@@ -199,10 +200,13 @@ Future<ui.Image> _renderLabelBitmap({
required String date,
required Uint8List qrBytes,
}) async {
const w = 320, h = 160; // 40×20mm @203dpi
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const scale = 2; // 2× 超采样,内部以 640×320 绘制
const sw = w * scale, sh = h * scale;
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(
recorder, ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()));
recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFFFFFF));
@@ -224,29 +228,41 @@ Future<ui.Image> _renderLabelBitmap({
// 扫码溯源:居中于二维码正下方
_drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true);
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充
// 左侧文字:固定字号 + 自适应行间距(与二维码上下对齐,行距均匀分配剩余空间)
const lx = 22.0;
const textMaxW = qrLeft - lx - 6; // 164
// 字号固定:只有商品名按宽度自适应,其余行均固定,杜绝自动放大
const double kShopSize = 17.0; // 酒行名,粗体
const double kSmallSize = 13.0; // 编号 / 度数 / 日期,统一小字
// 商品名:仅按宽度适配,上限 26px,下限 14px
final double nameSize = _fitFont(name, textMaxW, 1000, true, 26.0).clamp(14.0, 26.0);
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),
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
if (date.isNotEmpty) (date, kSmallSize, false),
];
const topY = 4.0, botY = 156.0;
final totalWeight = rows.fold<double>(0, (a, r) => a + r.$2);
double yy = topY;
// 文字区域:整体略低于二维码顶部,底部延伸至扫码溯源文字下方
const double kTextTop = 16.0; // 比二维码顶部(8)低一些,整体下移
const double kTextBot = 150.0; // 延伸至底部,减少留白
const double kLineH = 1.35; // 行高倍数(字号 × 行高 = 该行占用高度)
final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
final int n = rows.length;
final double rawGap = n > 0 ? (kTextBot - kTextTop - totalTextH) / (n + 1) : 4.0;
final double gap = rawGap < 1.5 ? 1.5 : rawGap;
double yy = kTextTop + gap;
for (final r in rows) {
final rowH = (botY - topY) * r.$2 / totalWeight;
final fs = _fitFont(r.$1, textMaxW, rowH * 0.84, r.$3, 30);
final ty = yy + (rowH - fs * 1.25) / 2;
_drawText(canvas, r.$1, lx, ty < yy ? yy : ty, fs, textMaxW, bold: r.$3);
yy += rowH;
_drawText(canvas, r.$1, lx, yy, r.$2, textMaxW, bold: r.$3);
yy += r.$2 * kLineH + gap;
}
return recorder.endRecording().toImage(w, h);
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
}
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
@@ -267,11 +283,13 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
// img 是 2× 超采样图(640×320);TSPL 输出仍是 320×160
const w = 320, h = 160;
final img = await _renderLabelBitmap(
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();
final sw = img.width; // 6402×宽)
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
const wbytes = (w + 7) >> 3;
@@ -282,12 +300,18 @@ Future<bool> _printFlatLabelThermal({
for (int yy = 0; yy < h; yy++) {
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
for (int xx = 0; xx < w; xx++) {
final i = (yy * w + xx) * 4;
final a = rgba[i + 3];
final lum = a < 128
? 255.0
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
// 2×2 四像素平均后阈值,消除反锯齿模糊(阈值 160 = 偏灰的边缘算黑)
double lum = 0;
for (int dy = 0; dy < 2; dy++) {
for (int dx = 0; dx < 2; dx++) {
final i = ((yy * 2 + dy) * sw + (xx * 2 + dx)) * 4;
final a = rgba[i + 3];
lum += a < 128
? 255.0
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
}
}
if (lum / 4 < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
}
out.add(row);
}
@@ -327,7 +351,7 @@ Future<void> printProductLabelImpl({
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
@@ -484,7 +508,7 @@ Future<void> printProductLabelImpl({
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
final specVal = (label.spec ?? '').isNotEmpty ? label.spec! : '';
final seriesVal = (label.series ?? '').isNotEmpty ? label.series! : '';
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (label.productionDate ?? '').isNotEmpty
? (label.productionDate!.length > 10
? label.productionDate!.substring(0, 10)
@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/config/app_config.dart';
@@ -54,6 +55,13 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
);
final data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
setState(() { _data = data; _loading = false; });
// 更新浏览器标签标题(Flutter Web 会写 document.title
final name = data['name'] as String? ?? '';
if (name.isNotEmpty) {
SystemChrome.setApplicationSwitcherDescription(
ApplicationSwitcherDescription(label: name, primaryColor: 0xFF8B2331),
);
}
} catch (e) {
setState(() { _error = e.toString(); _loading = false; });
}
+10
View File
@@ -55,6 +55,16 @@ server {
proxy_read_timeout 30s;
}
# Flutter 路由会把 /product/:id 重写为 /app/product/:idbase-href=/app/
# 分享此 URL 时微信爬虫也需要 OG 标签 → 去掉 /app 前缀后转发后端
location ~ ^/app/product/ {
rewrite ^/app(/product/.+)$ $1 break;
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
location /downloads/ {
alias /opt/jiu/downloads/;