Compare commits

..

7 Commits

Author SHA1 Message Date
wangjia 146444ccc3 fix(client): Windows 热敏打印 GBK 编码改用 WideCharToMultiByte FFI
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-macos (push) Successful in 1m29s
Deploy / build-windows (push) Successful in 2m32s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:09:57 +08:00
wangjia 2ca65e8077 chore: release v1.0.52
Deploy / build-linux-web (push) Successful in 52s
Deploy / build-windows (push) Successful in 1m52s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 1m56s
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 21:10:54 +08:00
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
10 changed files with 205 additions and 41 deletions
+36
View File
@@ -5,6 +5,42 @@ 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.53] - 2026-06-15
### 修复
- 修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)
## [1.0.52] - 2026-06-15
### 改进
- 热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题
## [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 -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
}
+11 -4
View File
@@ -343,14 +343,21 @@ func buildOGTitle(product model.Product) string {
func buildProductOG(product model.Product, shopName, publicURL string) string {
title := buildOGTitle(product)
// og:description规格(度数/香型/容量| 门店名正品
desc := product.Spec
// 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
}
@@ -362,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")
+126 -31
View File
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ffi';
import 'package:charset_converter/charset_converter.dart';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
show
@@ -13,7 +14,8 @@ import 'package:win32/win32.dart'
EndPagePrinter,
EndDocPrinter,
ClosePrinter,
DOC_INFO_1;
DOC_INFO_1,
WideCharToMultiByte;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/services.dart';
import '../errors/error_reporter.dart';
@@ -189,7 +191,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 +202,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));
@@ -236,8 +242,8 @@ Future<ui.Image> _renderLabelBitmap({
final codeText = code.isNotEmpty ? '编号:$code' : '';
final rows = <(String, double, bool)>[
if (shop.isNotEmpty) (shop, kShopSize, true),
if (name.isNotEmpty) (name, nameSize, true),
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),
@@ -258,11 +264,55 @@ Future<ui.Image> _renderLabelBitmap({
yy += r.$2 * kLineH + gap;
}
return recorder.endRecording().toImage(w, h);
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
}
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
/// UTF-8 → GBK 字节:
/// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件;
/// macOS 走 charset_converterCoreFoundation 原生支持 GBK)。
Uint8List _encodeGbk(String text) {
if (text.isEmpty) return Uint8List(0);
if (Platform.isWindows) {
final wideStr = text.toNativeUtf16();
try {
final needed = WideCharToMultiByte(936, 0, wideStr, -1, nullptr, 0, nullptr, nullptr);
if (needed <= 1) return Uint8List(0);
final buf = calloc<Uint8>(needed);
try {
WideCharToMultiByte(936, 0, wideStr, -1, buf.cast(), needed, nullptr, nullptr);
return Uint8List.fromList(buf.asTypedList(needed - 1)); // 不含 null 终止符
} finally {
calloc.free(buf);
}
} finally {
calloc.free(wideStr);
}
}
// macOS: charset_converter(同步 CoreFoundation
// encode() 在 macOS 上是同步 channel 调用,此处以 Future.value 包装保持接口一致
throw UnsupportedError('call _encodeGbkAsync on macOS');
}
Future<Uint8List> _encodeGbkAsync(String text) async {
if (text.isEmpty) return Uint8List(0);
if (Platform.isWindows) return _encodeGbk(text);
return CharsetConverter.encode('GBK', text);
}
/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。
/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。
Future<void> _addTextCmd(
BytesBuilder buf, int x, int y, String font, String text) async {
if (text.isEmpty) return;
final gbk = await _encodeGbkAsync(text);
buf.add(ascii.encode('TEXT $x,$y,"$font",0,1,1,"'));
buf.add(gbk);
buf.add(ascii.encode('"\r\n'));
}
/// 桌面端把标签以 TSPL TEXT(内置中文点阵字体)+ QR BITMAP 混合方式裸发到热敏机。
/// TEXT 走 TSS24.BF2/TSS16.BF2GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。
/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({
@@ -279,34 +329,79 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
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();
// QR BITMAP: 124×124 dots, 右侧 x=192, y=8
const qrW = 124, qrH = 124;
const qrX = 192, qrY = 8;
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
const wbytes = (w + 7) >> 3;
final out = BytesBuilder();
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n'
'BITMAP 0,0,$wbytes,$h,1,'));
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];
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
final qrImg = (await codec.getNextFrame()).image;
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
final qrRgba = qrBd!.buffer.asUint8List();
final buf = BytesBuilder();
buf.add(ascii.encode(
'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
));
// QR BITMAP 命令
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
for (int yy = 0; yy < qrH; yy++) {
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白
for (int xx = 0; xx < qrW; xx++) {
final i = (yy * qrW + xx) * 4;
final a = qrRgba[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
: 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
}
out.add(row);
buf.add(row);
}
out.add(ascii.encode('\r\nPRINT 1,1\r\n'));
final tspl = out.toBytes();
buf.add(ascii.encode('\r\n'));
debugPrint('[label] flat tspl ${tspl.length}B -> $printer');
// "扫码溯源" 居中于 QR 正下方
// TSS16 CJK 每字 16 dots; 4字=64; QR 中心 x=192+62=254; 文字起始 x=254-32=222
await _addTextCmd(buf, 222, qrY + qrH + 2, 'TSS16.BF2', '扫码溯源');
// 左侧文字(x=4, 宽度至 x=186
// TSS24.BF2: 24×24 点阵; TSS16.BF2: 16×16 点阵
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
const kFontXL = 'TSS24.BF2'; // 大号(商品名,≤7字时使用)
final nameFont = name.runes.length <= 7 ? kFontXL : kFontL;
final nameH = nameFont == kFontXL ? 24 : 16;
final codeText = code.isNotEmpty ? '编号:$code' : '';
// (文本, 字体, 字符高度点数)
final rows = <(String, String, int)>[];
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
if (date.isNotEmpty) rows.add((date, kFontL, 16));
// 在 y=8..150 区间均匀分布各行
const kTextTop = 8;
const kTextBot = 150;
const kTextX = 4;
final totalH = rows.fold(0, (s, r) => s + r.$3);
final n = rows.length;
final gapSize = n > 0
? ((kTextBot - kTextTop - totalH) / (n + 1)).round().clamp(2, 20)
: 8;
int yPos = kTextTop + gapSize;
for (final r in rows) {
await _addTextCmd(buf, kTextX, yPos, r.$2, r.$1);
yPos += r.$3 + gapSize;
}
buf.add(ascii.encode('PRINT 1,1\r\n'));
final tspl = buf.toBytes();
debugPrint('[label] TEXT+BITMAP tspl ${tspl.length}B -> $printer');
await _sendRaw(printer, tspl);
return true;
}
@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import charset_converter
import file_picker
import file_selector_macos
import package_info_plus
@@ -13,6 +14,7 @@ import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
CharsetConverterPlugin.register(with: registry.registrar(forPlugin: "CharsetConverterPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
+12 -4
View File
@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charset_converter:
dependency: "direct main"
description:
name: charset_converter
sha256: af00b5b73b367101c661b8dfaa52abf3e533de7b3e247e3f65c535ebc4df820a
url: "https://pub.dev"
source: hosted
version: "2.4.0"
clock:
dependency: transitive
description:
@@ -513,10 +521,10 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
@@ -545,10 +553,10 @@ packages:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
+3 -1
View File
@@ -1,7 +1,7 @@
name: jiu_client
description: 酒库管理系统 - 酒店仓库管理
publish_to: 'none'
version: 1.0.3+1
version: 1.0.52+1
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -29,6 +29,8 @@ dependencies:
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
win32: ^5.5.0
ffi: ^2.1.3
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
charset_converter: ^2.1.0
dev_dependencies:
flutter_test:
@@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h"
#include <charset_converter/charset_converter_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
CharsetConverterPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CharsetConverterPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PrintingPluginRegisterWithRegistrar(
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
charset_converter
file_selector_windows
printing
url_launcher_windows
+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/;