Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf36f7aa3f | |||
| 097e466372 | |||
| c1ba6c6d97 | |||
| 2b327cd365 | |||
| 07f8973477 | |||
| 3b02a848c0 | |||
| bde2d35756 |
@@ -5,6 +5,43 @@ 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.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.44] - 2026-06-15
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
|
|||||||
h.db.Model(&product).Update("public_id", product.PublicID)
|
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 != "" {
|
if product.Code != "" {
|
||||||
url += "?code=" + product.Code
|
url += "?code=" + product.Code
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -315,7 +316,9 @@ func (h *PublicHandler) ProductPage(c *gin.Context) {
|
|||||||
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
|
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
|
||||||
ogTitle := buildOGTitle(product)
|
ogTitle := buildOGTitle(product)
|
||||||
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
|
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
|
||||||
out := strings.Replace(idxHTML, "<title>岩美</title>", "<title>"+html.EscapeString(ogTitle)+"</title>", 1)
|
// 用正则替换避免缩进空格导致字符串不匹配
|
||||||
|
titleRe := regexp.MustCompile(`<title>[^<]*</title>`)
|
||||||
|
out := titleRe.ReplaceAllString(idxHTML, "<title>"+html.EscapeString(ogTitle)+"</title>")
|
||||||
out = strings.Replace(out, "</head>", ogTags+"</head>", 1)
|
out = strings.Replace(out, "</head>", ogTags+"</head>", 1)
|
||||||
// 禁止缓存,确保爬虫每次都能拿到最新 OG 标签
|
// 禁止缓存,确保爬虫每次都能拿到最新 OG 标签
|
||||||
c.Header("Cache-Control", "no-store")
|
c.Header("Cache-Control", "no-store")
|
||||||
@@ -340,14 +343,21 @@ func buildOGTitle(product model.Product) string {
|
|||||||
func buildProductOG(product model.Product, shopName, publicURL string) string {
|
func buildProductOG(product model.Product, shopName, publicURL string) string {
|
||||||
title := buildOGTitle(product)
|
title := buildOGTitle(product)
|
||||||
|
|
||||||
// og:description:规格(度数/香型/容量)| 门店名正品
|
// og:description:系列(度数/香型)· 规格(容量/单位)| 门店名正品
|
||||||
desc := product.Spec
|
var specParts []string
|
||||||
|
if product.Series != "" {
|
||||||
|
specParts = append(specParts, product.Series)
|
||||||
|
}
|
||||||
|
if product.Spec != "" {
|
||||||
|
specParts = append(specParts, product.Spec)
|
||||||
|
}
|
||||||
|
desc := strings.Join(specParts, " · ")
|
||||||
suffix := "正品"
|
suffix := "正品"
|
||||||
if shopName != "" {
|
if shopName != "" {
|
||||||
suffix = shopName + suffix
|
suffix = shopName + suffix
|
||||||
}
|
}
|
||||||
if desc != "" {
|
if desc != "" {
|
||||||
desc = desc + " | " + suffix
|
desc = desc + "\n" + suffix
|
||||||
} else {
|
} else {
|
||||||
desc = suffix
|
desc = suffix
|
||||||
}
|
}
|
||||||
@@ -359,7 +369,7 @@ func buildProductOG(product model.Product, shopName, publicURL string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// og:url
|
// og:url
|
||||||
pageURL := publicURL + "/product/" + product.PublicID
|
pageURL := publicURL + "/app/product/" + product.PublicID
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|||||||
@@ -189,7 +189,8 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
|||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||||
|
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
|
||||||
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
||||||
Future<ui.Image> _renderLabelBitmap({
|
Future<ui.Image> _renderLabelBitmap({
|
||||||
required String shop,
|
required String shop,
|
||||||
@@ -199,10 +200,13 @@ Future<ui.Image> _renderLabelBitmap({
|
|||||||
required String date,
|
required String date,
|
||||||
required Uint8List qrBytes,
|
required Uint8List qrBytes,
|
||||||
}) async {
|
}) 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 recorder = ui.PictureRecorder();
|
||||||
final canvas = ui.Canvas(
|
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()),
|
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
|
||||||
ui.Paint()..color = const ui.Color(0xFFFFFFFF));
|
ui.Paint()..color = const ui.Color(0xFFFFFFFF));
|
||||||
|
|
||||||
@@ -236,8 +240,8 @@ Future<ui.Image> _renderLabelBitmap({
|
|||||||
|
|
||||||
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
||||||
final rows = <(String, double, bool)>[
|
final rows = <(String, double, bool)>[
|
||||||
if (shop.isNotEmpty) (shop, kShopSize, true),
|
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
|
||||||
if (name.isNotEmpty) (name, nameSize, true),
|
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
|
||||||
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
|
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
|
||||||
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
|
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
|
||||||
if (date.isNotEmpty) (date, kSmallSize, false),
|
if (date.isNotEmpty) (date, kSmallSize, false),
|
||||||
@@ -258,7 +262,7 @@ Future<ui.Image> _renderLabelBitmap({
|
|||||||
yy += r.$2 * kLineH + gap;
|
yy += r.$2 * kLineH + gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
return recorder.endRecording().toImage(w, h);
|
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
||||||
@@ -279,11 +283,13 @@ Future<bool> _printFlatLabelThermal({
|
|||||||
debugPrint('[label] thermal printer = $printer');
|
debugPrint('[label] thermal printer = $printer');
|
||||||
if (printer == null) return false;
|
if (printer == null) return false;
|
||||||
|
|
||||||
|
// img 是 2× 超采样图(640×320);TSPL 输出仍是 320×160
|
||||||
const w = 320, h = 160;
|
const w = 320, h = 160;
|
||||||
final img = await _renderLabelBitmap(
|
final img = await _renderLabelBitmap(
|
||||||
shop: shop, name: name, code: code, 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();
|
||||||
|
final sw = img.width; // 640(2×宽)
|
||||||
|
|
||||||
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
|
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
|
||||||
const wbytes = (w + 7) >> 3;
|
const wbytes = (w + 7) >> 3;
|
||||||
@@ -294,12 +300,18 @@ Future<bool> _printFlatLabelThermal({
|
|||||||
for (int yy = 0; yy < h; yy++) {
|
for (int yy = 0; yy < h; yy++) {
|
||||||
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
|
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
|
||||||
for (int xx = 0; xx < w; xx++) {
|
for (int xx = 0; xx < w; xx++) {
|
||||||
final i = (yy * w + xx) * 4;
|
// 2×2 四像素平均后阈值,消除反锯齿模糊(阈值 160 = 偏灰的边缘算黑)
|
||||||
final a = rgba[i + 3];
|
double lum = 0;
|
||||||
final lum = a < 128
|
for (int dy = 0; dy < 2; dy++) {
|
||||||
? 255.0
|
for (int dx = 0; dx < 2; dx++) {
|
||||||
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
|
final i = ((yy * 2 + dy) * sw + (xx * 2 + dx)) * 4;
|
||||||
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
|
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);
|
out.add(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../core/config/app_config.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>;
|
final data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||||
setState(() { _data = data; _loading = false; });
|
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) {
|
} catch (e) {
|
||||||
setState(() { _error = e.toString(); _loading = false; });
|
setState(() { _error = e.toString(); _loading = false; });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ server {
|
|||||||
proxy_read_timeout 30s;
|
proxy_read_timeout 30s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Flutter 路由会把 /product/:id 重写为 /app/product/:id(base-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)
|
# 桌面客户端安装包下载(Windows .exe / macOS .zip)
|
||||||
location /downloads/ {
|
location /downloads/ {
|
||||||
alias /opt/jiu/downloads/;
|
alias /opt/jiu/downloads/;
|
||||||
|
|||||||
Reference in New Issue
Block a user