Compare commits

..

14 Commits

Author SHA1 Message Date
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
wangjia a5bdb79f81 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>
2026-06-15 14:23:03 +08:00
wangjia c4b4259a3d feat(client): 表格加横向滚动条 + 库存页列显隐
- DataTableCard 横向/纵向 ScrollView 各加 Scrollbar(thumbVisibility:true),
  Windows 鼠标用户可拖动滚动条查看右侧截断的列(6 个表格全受益)
- 库存查询页新增「显示字段」按钮,可按需隐藏生产日期/供应商/备注等列,
  列变少后横向溢出大幅缓解;商品编码/名称/操作三列锁定不可隐藏

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 12:35:57 +08:00
wangjia 68e0ca30ad chore: release v1.0.39
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-windows (push) Successful in 1m42s
Deploy / build-macos (push) Successful in 1m23s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m50s
2026-06-15 12:16:52 +08:00
wangjia a8c684ad0c fix(client): 单实例锁改用 TCP loopback socket
文件锁在 Windows 上行为不一致,改用绑定本地端口(54317)作为互斥锁,
进程退出时 OS 自动释放,Windows/macOS 均可靠生效。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 12:09:34 +08:00
wangjia 0779bf57e3 fix(client): 修复标签打印预览三个问题
- 打印完成后自动关闭预览弹窗
- 打印机列表改为仅显示热敏打印机,无热敏打印机时显示"无打印机可用"并禁用打印按钮
- 库存列表表格行"打标签"按钮改为调用预览弹窗(之前仍走旧直接打印逻辑)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 12:04:03 +08:00
wangjia d26a64f149 chore: release v1.0.38
Deploy / build-linux-web (push) Successful in 46s
Deploy / build-windows (push) Successful in 1m42s
Deploy / build-macos (push) Successful in 1m26s
Deploy / build-android (push) Successful in 1m3s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m53s
2026-06-15 11:52:11 +08:00
wangjia 104b98f4ac feat(client): 标签打印预览弹窗(打印机选择 + 份数 + 所见即所得预览)
- 新增 LabelData 模型统一聚合单张标签字段(qrBytes/copies 等)
- 抽取 _renderLabelBitmap 共享位图绘制逻辑,TSPL 裸发与预览渲染完全一致
- 新增 renderLabelPreview / listLabelPrinters / detectDefaultPrinter 公开 API
- printProductLabel 新增 printerName 参数,透传到热敏裸发路径
- 新建 LabelPreviewDialog:缩略图条+大图主视图,打印机下拉+记忆(SharedPrefs)
  份数调整、勾选跳过、逐张打印进度状态显示
- 库存/商品详情/入库单 打标签 均接入预览弹窗,移除旧 _LabelPrintDialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:49:37 +08:00
18 changed files with 1081 additions and 574 deletions
+63
View File
@@ -5,6 +5,69 @@ 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.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
### 新功能
- 商品标签打印新增编号行(显示「编号:xxxx」),位于度数上方,方便核对入库单
- 扫码分享商品链接时,微信/企业微信/飞书/钉钉等平台的分享卡片现可显示商品名、规格描述及商品图片,不再只显示「岩美」
### 改进
- 库存页表格支持横向滚动,列过多时不再截断;支持显示/隐藏列,按需定制显示内容
## [1.0.39] - 2026-06-15
### 修复
- 打印预览弹窗:打印完成后自动关闭
- 打印预览弹窗:打印机列表改为仅显示热敏打印机,无热敏打印机时显示"无打印机可用"并禁用打印按钮
- 库存列表表格行"打标签"按钮点击无反应
- Windows/macOS 单实例限制:同一台电脑不允许重复打开多个客户端窗口
## [1.0.38] - 2026-06-15
### 新功能
- 标签打印新增「打印预览」弹窗,点击打印按钮后可先预览标签内容再确认打印
- 预览弹窗支持选择打印机(下拉列表),并记住上次选择,下次自动默认
- 支持对每张标签单独设置打印份数(默认 1 份)
- 入库单「打标签」支持多张预览:左侧缩略图条可逐张勾选/取消,右侧显示当前选中张的大图
## [1.0.37] - 2026-06-15
### 新功能
+3
View File
@@ -43,6 +43,7 @@ type StorageConfig struct {
UploadDir string `mapstructure:"upload_dir"`
BaseURL string `mapstructure:"base_url"`
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入
}
var C Config
@@ -66,6 +67,7 @@ func Load() {
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
// 默认值
viper.SetDefault("server.port", "8080")
@@ -78,6 +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", "./web")
if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file found, using defaults and env vars")
+111
View File
@@ -3,13 +3,17 @@ package handler
import (
"errors"
"fmt"
"html"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
)
@@ -274,3 +278,110 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
"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)
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))
}
// buildOGTitle 生成商品的分享标题:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列。
func buildOGTitle(product model.Product) string {
title := product.Name
if product.Brand != "" && !strings.Contains(product.Name, product.Brand) {
title = product.Brand + title
}
if product.Series != "" {
title = title + "" + product.Series + ""
}
return title
}
// 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
} 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)
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
r.GET("/product/:public_id", publicH.ProductPage)
v1 := r.Group("/api/v1")
// 公开路由(无需登录)
+11 -9
View File
@@ -1,11 +1,13 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
// Keep the RAF open so the OS lock stays alive for the lifetime of the process.
// ignore: unused_element
RandomAccessFile? _raf;
// Keep the server socket alive for the lifetime of the process.
// The OS releases the port automatically when the process exits.
ServerSocket? _server;
// Port unique to this app; chosen from the private range 4915265535.
const _kLockPort = 54317;
/// Returns true if this process successfully acquired the single-instance lock.
/// Returns false if another instance is already running.
@@ -15,11 +17,11 @@ Future<bool> acquire() async {
if (!Platform.isWindows && !Platform.isMacOS) return true;
try {
final dir = await getApplicationSupportDirectory();
final lockFile = File('${dir.path}/jiu.lock');
final raf = await lockFile.open(mode: FileMode.write);
await raf.lock(FileLock.exclusive);
_raf = raf;
_server = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
_kLockPort,
shared: false,
);
return true;
} catch (_) {
return false;
+40
View File
@@ -0,0 +1,40 @@
import 'dart:typed_data';
/// 单张标签的全部打印字段,预览和打印共用同一份数据。
class LabelData {
/// 商品 ID,多张懒加载 QR 时用于拉取二维码字节。
final int? productId;
/// 二维码字节(PNG);单张场景调用方提前拉好;多张场景由 qrFetcher 懒加载填入。
Uint8List? qrBytes;
final String name;
final String code;
final String? spec;
final String? series;
final String? batchNo;
final String? productionDate;
final String? remark;
final String shopName;
final String shopAddress;
final String shopPhone;
/// 打印份数,默认 1,可在预览弹窗里调整。
int copies;
LabelData({
this.productId,
this.qrBytes,
required this.name,
required this.code,
this.spec,
this.series,
this.batchNo,
this.productionDate,
this.remark,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
this.copies = 1,
});
}
+14
View File
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
import 'label_data.dart';
export 'label_data.dart';
import 'print_util_stub.dart'
if (dart.library.js_interop) 'print_util_web.dart';
@@ -18,6 +20,7 @@ Future<void> printProductLabel({
String shopName = '',
String shopAddress = '',
String shopPhone = '',
String? printerName,
}) =>
printProductLabelImpl(
qrBytes: qrBytes,
@@ -31,8 +34,19 @@ Future<void> printProductLabel({
shopName: shopName,
shopAddress: shopAddress,
shopPhone: shopPhone,
printerName: printerName,
);
/// 渲染标签预览图(PNG 字节);Web 平台返回 null。
Future<Uint8List?> renderLabelPreview(LabelData label) =>
renderLabelPreviewImpl(label);
/// 枚举当前系统所有可用打印机名;Web 平台返回空列表。
Future<List<String>> listLabelPrinters() => listLabelPrintersImpl();
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。
Future<String?> detectDefaultPrinter() => detectDefaultPrinterImpl();
Future<void> printStockInOrder(StockInOrder order) =>
printStockInOrderImpl(order);
+125 -25
View File
@@ -22,6 +22,7 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import 'label_data.dart';
Future<pw.Font> _loadFont() async {
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
@@ -188,21 +189,16 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
return size;
}
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
Future<bool> _printFlatLabelThermal({
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出
Future<ui.Image> _renderLabelBitmap({
required String shop,
required String name,
required String code,
required String degSpec,
required String date,
required Uint8List qrBytes,
}) async {
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
final printer = await _findThermalPrinter();
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
const w = 320, h = 160; // 40×20mm @203dpi
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(
@@ -228,27 +224,64 @@ Future<bool> _printFlatLabelThermal({
// 扫码溯源:居中于二维码正下方
_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 (degSpec.isNotEmpty) (degSpec, 1.05, false),
if (date.isNotEmpty) (date, 1.0, false),
if (shop.isNotEmpty) (shop, kShopSize, true),
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;
}
final img = await recorder.endRecording().toImage(w, h);
return recorder.endRecording().toImage(w, h);
}
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({
required String shop,
required String name,
required String code,
required String degSpec,
required String date,
required Uint8List qrBytes,
String? printerName,
}) async {
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
final printer = printerName ?? await _findThermalPrinter();
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();
@@ -290,6 +323,7 @@ Future<void> printProductLabelImpl({
String shopName = '',
String shopAddress = '',
String shopPhone = '',
String? printerName,
}) async {
try {
final font = await _loadFont();
@@ -305,7 +339,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)
: '';
@@ -314,9 +348,11 @@ Future<void> printProductLabelImpl({
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
code: code,
degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes)) {
qrBytes: qrBytes,
printerName: printerName)) {
return;
}
@@ -379,6 +415,12 @@ Future<void> printProductLabelImpl({
fontWeight: pw.FontWeight.bold,
color: _ink, letterSpacing: 0.3)),
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,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
@@ -449,6 +491,64 @@ Future<void> printProductLabelImpl({
}
}
/// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。
/// Web 平台或 qrBytes 为空时抛异常。
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 dateVal = (label.productionDate ?? '').isNotEmpty
? (label.productionDate!.length > 10
? label.productionDate!.substring(0, 10)
: label.productionDate!)
: '';
final img = await _renderLabelBitmap(
shop: label.shopName,
name: label.name,
code: label.code,
degSpec: degSpecVal,
date: dateVal,
qrBytes: label.qrBytes!,
);
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
return bd!.buffer.asUint8List();
}
/// 枚举当前系统上所有可用打印机名。
/// macOS: CUPS 队列(lpstat -e);Windows: Printing.listPrinters();其他返回空列表。
Future<List<String>> listLabelPrintersImpl() async {
if (kIsWeb) return [];
if (Platform.isMacOS) {
try {
final r = await Process.run('lpstat', ['-e']);
if (r.exitCode == 0) {
return (r.stdout as String)
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty && _isThermalName(s))
.toList();
}
} catch (e) {
debugPrint('[label] lpstat -e 失败: $e');
}
return [];
}
if (Platform.isWindows) {
try {
return (await Printing.listPrinters())
.map((p) => p.name)
.where((n) => _isThermalName(n))
.toList();
} catch (e) {
debugPrint('[label] listPrinters 失败: $e');
}
}
return [];
}
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。
Future<String?> detectDefaultPrinterImpl() => _findThermalPrinter();
pw.Widget _buildOrderDoc({
required pw.Font font,
required pw.Font bold,
+10
View File
@@ -5,6 +5,7 @@ import 'package:web/web.dart' as web;
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
import 'label_data.dart';
void _openPrintWindow(String html) {
final win = web.window.open('', '_blank');
@@ -27,6 +28,7 @@ Future<void> printProductLabelImpl({
String shopName = '',
String shopAddress = '',
String shopPhone = '',
String? printerName,
}) async {
final base64Img = base64Encode(qrBytes);
@@ -279,6 +281,14 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
}
}
// ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)─────────────────────────
Future<Uint8List?> renderLabelPreviewImpl(LabelData label) async => null;
Future<List<String>> listLabelPrintersImpl() async => [];
Future<String?> detectDefaultPrinterImpl() async => null;
// ── 出库单 ──────────────────────────────────────────────────────────────────
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
@@ -12,10 +12,12 @@ import '../../core/auth/auth_state.dart';
import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/dialog_util.dart';
import '../../widgets/label_preview_dialog.dart';
import '../../providers/product_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/tab_state_provider.dart';
@@ -34,6 +36,24 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {};
Set<String> _filterSeries = {};
Set<String> _hiddenCols = {};
static const _colDefs = [
ColDef('code', '商品编码', required: true),
ColDef('name', '商品名称', required: true),
ColDef('spec', '规格'),
ColDef('series', '系列'),
ColDef('batch', '批次号'),
ColDef('warehouse', '仓库'),
ColDef('qty', '库存量'),
ColDef('price', '单价'),
ColDef('prodDate', '生产日期'),
ColDef('inTime', '入库时间'),
ColDef('supplier', '供应商'),
ColDef('remark', '备注'),
ColDef('status', '状态'),
ColDef('actions', '操作', required: true),
];
@override
@@ -200,22 +220,109 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
await ref.read(productRepositoryProvider).getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (!context.mounted) return;
await safePrint(
context,
() => printProductLabel(
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
),
final label = LabelData(
productId: item.productId,
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
/// 库存查询:宽屏表格单元格(按列 key 返回对应 DataCell
DataCell _buildInventoryCell(String key, Inventory item, BuildContext context) {
return switch (key) {
'code' => DataCell(Text(
item.productCode.isEmpty ? '-' : item.productCode,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
'name' => DataCell(item.productId != null
? GestureDetector(
onTap: () => context.push('/products/${item.productId}'),
child: SizedBox(
width: 180,
child: Text(
item.productName.isEmpty ? '-' : item.productName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppTheme.primary,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primary,
),
),
),
)
: Text(item.productName.isEmpty ? '-' : item.productName,
overflow: TextOverflow.ellipsis)),
'spec' => DataCell(Text(item.spec.isEmpty ? '-' : item.spec)),
'series' => DataCell(Text(item.series.isEmpty ? '-' : item.series)),
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
'qty' => DataCell(Text(
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: item.quantity == 0
? AppTheme.danger
: (item.minStock != null && item.quantity < item.minStock!)
? AppTheme.accent
: AppTheme.textPrimary))),
'price' => DataCell(Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')),
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
'inTime' => DataCell(Text(
item.createdAt != null && item.createdAt!.length >= 10
? item.createdAt!.substring(0, 10)
: '-',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
'supplier' => DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)),
'remark' => DataCell(Tooltip(
message: item.remark.isEmpty ? '' : item.remark,
waitDuration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => _editRemark(context, item),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.remark.isEmpty
? ''
: item.remark.length > 4
? '${item.remark.substring(0, 4)}'
: item.remark,
style: TextStyle(
color: item.remark.isEmpty
? AppTheme.textSecondary
: AppTheme.textPrimary,
),
),
const SizedBox(width: 4),
const Icon(Icons.edit_outlined, size: 12, color: AppTheme.textSecondary),
],
),
))),
'status' => DataCell(_InventoryStatusBadge(item)),
'actions' => DataCell(item.productId != null
? TextButton(
onPressed: () => _printLabel(context, item),
child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
)
: const SizedBox()),
_ => const DataCell(SizedBox()),
};
}
/// 库存查询:窄屏卡片
@@ -388,6 +495,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
? items
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
final visibleCols =
_colDefs.where((c) => !_hiddenCols.contains(c.key)).toList();
return Column(
children: [
// Summary cards(窄屏横向滚动,宽屏等分)
@@ -477,6 +587,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const SizedBox(width: 8),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
const Spacer(),
SizedBox(
width: 220,
@@ -499,68 +615,48 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
mobileCards:
filteredItems.map(_inventoryCard).toList(),
columns: [
const DataColumn(label: Text('商品编码')),
const DataColumn(label: Text('商品名称')),
DataColumn(
label: FilterableColumnHeader(
text: '规格',
options: specOptions,
selected: _filterSpec,
onChanged: (v) {
setState(() => _filterSpec = v);
ref.read(inventoryListProvider.notifier).setSpec(v.toList());
},
),
),
DataColumn(
label: FilterableColumnHeader(
text: '系列',
options: seriesOptions,
selected: _filterSeries,
onChanged: (v) {
setState(() => _filterSeries = v);
ref.read(inventoryListProvider.notifier).setSeries(v.toList());
},
),
),
const DataColumn(label: Text('批次号')),
DataColumn(
label: FilterableColumnHeader(
text: '仓库',
options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) => setState(() => _filterWarehouse = v),
),
),
const DataColumn(label: Text('库存量'), numeric: true),
const DataColumn(label: Text('单价'), numeric: true),
const DataColumn(label: Text('生产日期')),
const DataColumn(label: Text('入库时间')),
const DataColumn(label: Text('供应商')),
const DataColumn(label: Text('备注')),
const DataColumn(label: Text('状态')),
const DataColumn(label: Text('操作')),
],
columns: visibleCols.map((c) {
final label = switch (c.key) {
'spec' => FilterableColumnHeader(
text: c.label,
options: specOptions,
selected: _filterSpec,
onChanged: (v) {
setState(() => _filterSpec = v);
ref.read(inventoryListProvider.notifier).setSpec(v.toList());
},
),
'series' => FilterableColumnHeader(
text: c.label,
options: seriesOptions,
selected: _filterSeries,
onChanged: (v) {
setState(() => _filterSeries = v);
ref.read(inventoryListProvider.notifier).setSeries(v.toList());
},
),
'warehouse' => FilterableColumnHeader(
text: c.label,
options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) => setState(() => _filterWarehouse = v),
),
_ => Text(c.label),
};
return DataColumn(
label: label,
numeric: c.key == 'qty' || c.key == 'price',
);
}).toList(),
rows: items.isEmpty
? [
const DataRow(cells: [
DataCell(SizedBox()),
DataCell(Text('暂无库存数据',
DataRow(cells: [
const DataCell(SizedBox()),
const DataCell(Text('暂无库存数据',
style: TextStyle(
color: AppTheme.textSecondary))),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
for (int i = 2; i < visibleCols.length; i++)
const DataCell(SizedBox()),
])
]
: filteredItems
@@ -568,129 +664,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
color: WidgetStateProperty.resolveWith(
(states) {
if (item.quantity == 0) {
return AppTheme.danger.withOpacity(0.04);
return AppTheme.danger.withValues(alpha: 0.04);
}
if (item.minStock != null &&
item.quantity < item.minStock!) {
return AppTheme.accent.withOpacity(0.04);
return AppTheme.accent.withValues(alpha: 0.04);
}
return null;
}),
cells: [
DataCell(Text(
item.productCode.isEmpty ? '-' : item.productCode,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppTheme.textSecondary))),
DataCell(item.productId != null
? GestureDetector(
onTap: () => context.push('/products/${item.productId}'),
child: SizedBox(
width: 180,
child: Text(
item.productName.isEmpty ? '-' : item.productName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppTheme.primary,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primary,
),
),
),
)
: Text(item.productName.isEmpty ? '-' : item.productName,
overflow: TextOverflow.ellipsis)),
DataCell(Text(item.spec.isEmpty ? '-' : item.spec)),
DataCell(Text(item.series.isEmpty ? '-' : item.series)),
DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
DataCell(Text(
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: item.quantity == 0
? AppTheme.danger
: (item.minStock != null &&
item.quantity <
item.minStock!)
? AppTheme.accent
: AppTheme.textPrimary),
)),
DataCell(Text(
item.unitPrice != null
? '¥${item.unitPrice!.toStringAsFixed(2)}'
: '-',
)),
DataCell(Text(item.productionDate ?? '-')),
DataCell(Text(
item.createdAt != null && item.createdAt!.length >= 10
? item.createdAt!.substring(0, 10)
: '-',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
)),
DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)),
DataCell(
Tooltip(
message: item.remark.isEmpty ? '' : item.remark,
waitDuration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => _editRemark(context, item),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.remark.isEmpty
? ''
: item.remark.length > 4
? '${item.remark.substring(0, 4)}'
: item.remark,
style: TextStyle(
color: item.remark.isEmpty
? AppTheme.textSecondary
: AppTheme.textPrimary,
),
),
const SizedBox(width: 4),
const Icon(Icons.edit_outlined,
size: 12, color: AppTheme.textSecondary),
],
),
),
),
),
DataCell(_InventoryStatusBadge(item)),
DataCell(
item.productId != null
? TextButton(
onPressed: () async {
final qrBytes = await ref
.read(productRepositoryProvider)
.getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (context.mounted) {
await safePrint(context, () => printProductLabel(
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
));
}
},
child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
)
: const SizedBox(),
),
],
cells: visibleCols
.map((c) => _buildInventoryCell(c.key, item, context))
.toList(),
))
.toList(),
),
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_config.dart';
import '../../core/utils/print_util.dart';
import '../../widgets/label_preview_dialog.dart';
import '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../models/product_image.dart';
@@ -656,8 +657,6 @@ class _QRCodeDialog extends ConsumerStatefulWidget {
class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
late Future<Uint8List> _future;
Uint8List? _bytes;
bool _printing = false;
String _printStatus = '';
@override
void initState() {
@@ -670,25 +669,25 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
});
}
Future<void> _print() async {
if (_bytes == null || _printing) return;
setState(() { _printing = true; _printStatus = '正在打印...'; });
void _print() {
if (_bytes == null) return;
final p = widget.product;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
try {
await safePrint(context, () => printProductLabel(
qrBytes: _bytes!,
name: p.name,
code: p.code,
spec: p.spec,
series: p.series,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
));
} finally {
if (mounted) setState(() { _printing = false; _printStatus = ''; });
}
final label = LabelData(
productId: p.id,
qrBytes: _bytes,
name: p.name,
code: p.code,
spec: p.spec,
series: p.series,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
@override
@@ -717,15 +716,9 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
child: const Text('关闭'),
),
FilledButton.icon(
onPressed: _bytes == null ? null : (_printing ? null : _print),
icon: _printing
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.print, size: 16),
label: Text(_printing
? (_printStatus.isNotEmpty ? _printStatus : '准备中...')
: '打印'),
onPressed: _bytes == null ? null : _print,
icon: const Icon(Icons.print, size: 16),
label: const Text('打印'),
),
],
);
@@ -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; });
}
@@ -1,6 +1,7 @@
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, printProductLabel;
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, LabelData;
import '../../widgets/label_preview_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -15,7 +16,6 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart' show productRepositoryProvider;
@@ -365,14 +365,25 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (!context.mounted) return;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
await showDialog(
final labels = order.items
.map((item) => LabelData(
productId: item.productId,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
))
.toList();
showAppDialog(
context: context,
builder: (_) => _LabelPrintDialog(
order: order,
productRepo: ref.read(productRepositoryProvider),
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
builder: (_) => LabelPreviewDialog(
labels: labels,
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
),
);
},
@@ -996,149 +1007,3 @@ class _StatusFilterDropdown extends StatelessWidget {
}
}
class _LabelPrintDialog extends StatefulWidget {
final StockInOrder order;
final ProductRepository productRepo;
final String shopName;
final String shopAddress;
final String shopPhone;
const _LabelPrintDialog({
required this.order,
required this.productRepo,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
});
@override
State<_LabelPrintDialog> createState() => _LabelPrintDialogState();
}
class _LabelPrintDialogState extends State<_LabelPrintDialog> {
late final List<bool> _selected;
bool _printing = false;
String _status = '';
@override
void initState() {
super.initState();
_selected = List.filled(widget.order.items.length, true);
}
Future<void> _print() async {
setState(() { _printing = true; _status = '正在打印...'; });
int done = 0;
for (int i = 0; i < widget.order.items.length; i++) {
if (!_selected[i]) continue;
final item = widget.order.items[i];
try {
final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId);
await printProductLabel(
qrBytes: qrBytes,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: widget.shopName,
shopAddress: widget.shopAddress,
shopPhone: widget.shopPhone,
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _status = '${i + 1}行打印失败:$e');
}
}
if (mounted) setState(() { _printing = false; _status = '完成,共打印 $done'; });
}
@override
Widget build(BuildContext context) {
final items = widget.order.items;
return Dialog(
child: Container(
width: context.dialogWidth(520),
constraints: const BoxConstraints(maxHeight: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('打印商品标签',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = items[i];
return CheckboxListTile(
value: _selected[i],
onChanged: _printing
? null
: (v) => setState(() => _selected[i] = v ?? false),
title: Text(
'${item.productCode ?? ''} ${item.productName ?? ''}',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${item.productSeries ?? ''} ${item.productSpec ?? ''}',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_status,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_printing || !_selected.contains(true)) ? null : _print,
icon: const Icon(Icons.print_outlined, size: 16),
label: Text(_printing ? '打印中...' : '打印选中'),
),
],
),
),
],
),
),
);
}
}
@@ -1,7 +1,6 @@
import '../../repositories/product_repository.dart';
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder, printProductLabel;
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -920,149 +919,3 @@ class _StatusFilterDropdown extends StatelessWidget {
}
}
class _LabelPrintDialog extends StatefulWidget {
final StockOutOrder order;
final ProductRepository productRepo;
final String shopName;
final String shopAddress;
final String shopPhone;
const _LabelPrintDialog({
required this.order,
required this.productRepo,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
});
@override
State<_LabelPrintDialog> createState() => _LabelPrintDialogState();
}
class _LabelPrintDialogState extends State<_LabelPrintDialog> {
late final List<bool> _selected;
bool _printing = false;
String _status = '';
@override
void initState() {
super.initState();
_selected = List.filled(widget.order.items.length, true);
}
Future<void> _print() async {
setState(() { _printing = true; _status = '正在打印...'; });
int done = 0;
for (int i = 0; i < widget.order.items.length; i++) {
if (!_selected[i]) continue;
final item = widget.order.items[i];
try {
final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId);
await printProductLabel(
qrBytes: qrBytes,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
shopName: widget.shopName,
shopAddress: widget.shopAddress,
shopPhone: widget.shopPhone,
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _status = '${i + 1}行打印失败:$e');
}
}
if (mounted) {
setState(() { _printing = false; _status = '完成,共打印 $done'; });
}
}
@override
Widget build(BuildContext context) {
final items = widget.order.items;
return Dialog(
child: Container(
width: context.dialogWidth(520),
constraints: const BoxConstraints(maxHeight: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('打印商品标签',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = items[i];
return CheckboxListTile(
value: _selected[i],
onChanged: _printing
? null
: (v) => setState(() => _selected[i] = v ?? false),
title: Text(
'${item.productCode ?? ''} ${item.productName ?? ''}',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${item.productSeries ?? ''} ${item.productSpec ?? ''}',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_status,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_printing || !_selected.contains(true)) ? null : _print,
icon: const Icon(Icons.print_outlined, size: 16),
label: Text(_printing ? '打印中...' : '打印选中'),
),
],
),
),
],
),
),
);
}
}
+30 -15
View File
@@ -36,10 +36,14 @@ class DataTableCard extends StatefulWidget {
class _DataTableCardState extends State<DataTableCard> {
// 当前悬停行的索引(-1 表示无悬停)
final _hoveredRow = ValueNotifier<int>(-1);
final _vCtrl = ScrollController();
final _hCtrl = ScrollController();
@override
void dispose() {
_hoveredRow.dispose();
_vCtrl.dispose();
_hCtrl.dispose();
super.dispose();
}
@@ -89,24 +93,35 @@ class _DataTableCardState extends State<DataTableCard> {
Widget _buildTable() {
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
builder: (context, constraints) => Scrollbar(
controller: _vCtrl,
thumbVisibility: true,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
border: TableBorder(
horizontalInside: BorderSide(
color: Colors.grey.shade200,
width: 0.5,
controller: _vCtrl,
child: Scrollbar(
controller: _hCtrl,
thumbVisibility: true,
scrollbarOrientation: ScrollbarOrientation.bottom,
child: SingleChildScrollView(
controller: _hCtrl,
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
border: TableBorder(
horizontalInside: BorderSide(
color: Colors.grey.shade200,
width: 0.5,
),
),
children: [
_buildHeaderRow(),
for (int i = 0; i < widget.rows.length; i++)
_buildDataRow(i, widget.rows[i]),
],
),
),
children: [
_buildHeaderRow(),
for (int i = 0; i < widget.rows.length; i++)
_buildDataRow(i, widget.rows[i]),
],
),
),
),
@@ -0,0 +1,440 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/errors/error_reporter.dart';
import '../core/theme/app_theme.dart';
import '../core/responsive/responsive.dart';
import '../core/utils/print_util.dart';
import '../core/utils/label_data.dart';
const _kPrinterKey = 'label_printer';
/// 标签打印预览弹窗:所见即所得预览(dart:ui 位图)+ 打印机选择 + 份数调整。
///
/// 单张模式(labels.length == 1):隐藏缩略图条,直接显示大图。
/// 多张模式:左侧竖排缩略图(带勾选),右侧大图主视图。
class LabelPreviewDialog extends StatefulWidget {
final List<LabelData> labels;
/// 多张懒加载:按 productId 拉取 QR PNG 字节。单张场景可不传(qrBytes 已在 LabelData 中)。
final Future<Uint8List> Function(int productId)? qrFetcher;
const LabelPreviewDialog({
super.key,
required this.labels,
this.qrFetcher,
});
@override
State<LabelPreviewDialog> createState() => _LabelPreviewDialogState();
}
class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
int _selectedIndex = 0;
late List<Uint8List?> _previews;
late List<bool> _include;
List<String> _printers = [];
String? _printer;
bool _printing = false;
bool _printersChecked = false;
String _status = '';
@override
void initState() {
super.initState();
_previews = List<Uint8List?>.filled(widget.labels.length, null,
growable: false);
_include = List<bool>.filled(widget.labels.length, true, growable: false);
_init();
}
Future<void> _init() async {
// ── 枚举打印机,读取上次记忆 ──
final printers = await listLabelPrinters();
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_kPrinterKey);
String? selected;
if (saved != null && printers.contains(saved)) {
selected = saved;
} else {
selected = await detectDefaultPrinter() ??
(printers.isNotEmpty ? printers.first : null);
}
if (mounted) {
setState(() {
_printers = printers;
_printer = selected;
_printersChecked = true;
});
}
// ── 逐张渲染预览(progressive)──
for (int i = 0; i < widget.labels.length; i++) {
final label = widget.labels[i];
try {
// 懒加载 QR
if (label.qrBytes == null &&
label.productId != null &&
widget.qrFetcher != null) {
label.qrBytes = await widget.qrFetcher!(label.productId!);
}
if (label.qrBytes == null) continue;
final png = await renderLabelPreview(label);
if (mounted && png != null) {
setState(() => _previews[i] = png);
}
} catch (e, st) {
debugPrint('[preview] 渲染第${i + 1}张失败: $e');
reportError(e, st);
}
}
}
int get _totalPrintCount {
int total = 0;
for (int i = 0; i < widget.labels.length; i++) {
if (_include[i]) total += widget.labels[i].copies;
}
return total;
}
Future<void> _doPrint() async {
if (_printing) return;
setState(() {
_printing = true;
_status = '正在打印...';
});
int done = 0;
final total = _totalPrintCount;
for (int i = 0; i < widget.labels.length; i++) {
if (!_include[i]) continue;
final label = widget.labels[i];
for (int c = 0; c < label.copies; c++) {
try {
// 确保 QR 已拉取
if (label.qrBytes == null &&
label.productId != null &&
widget.qrFetcher != null) {
label.qrBytes = await widget.qrFetcher!(label.productId!);
}
if (label.qrBytes == null) continue;
await printProductLabel(
qrBytes: label.qrBytes!,
name: label.name,
code: label.code,
spec: label.spec,
series: label.series,
batchNo: label.batchNo,
productionDate: label.productionDate,
remark: label.remark,
shopName: label.shopName,
shopAddress: label.shopAddress,
shopPhone: label.shopPhone,
printerName: _printer,
);
done++;
if (mounted) {
setState(() => _status = '已打印 $done/$total 张...');
}
} catch (e, st) {
reportError(e, st);
if (mounted) {
setState(() => _status = '${i + 1}张第${c + 1}份打印失败:$e');
}
}
}
}
if (mounted) {
setState(() {
_printing = false;
_status = '完成,共打印 $done';
});
Navigator.of(context).pop();
}
}
// ── 缩略图列表项 ─────────────────────────────────────────────────────────────
Widget _buildThumbnail(int i) {
final isSelected = _selectedIndex == i;
return GestureDetector(
onTap: () => setState(() => _selectedIndex = i),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
border: Border.all(
color: isSelected ? AppTheme.primary : AppTheme.border,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(6),
color: isSelected
? AppTheme.primary.withOpacity(0.04)
: AppTheme.surface,
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: Checkbox(
value: _include[i],
onChanged: _printing
? null
: (v) => setState(() => _include[i] = v ?? true),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: 4),
SizedBox(
width: 76,
height: 38,
child: _previews[i] != null
? Image.memory(_previews[i]!, fit: BoxFit.contain)
: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))),
),
const SizedBox(width: 6),
Text(
'${i + 1}',
style: TextStyle(
fontSize: 12,
color: isSelected
? AppTheme.primary
: AppTheme.textSecondary,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}
// ── 大图 + 份数控件 ──────────────────────────────────────────────────────────
Widget _buildMainPreview() {
final preview = _previews[_selectedIndex];
final label = widget.labels[_selectedIndex];
return Column(
children: [
Expanded(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 230),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.border),
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: preview != null
? Image.memory(preview, fit: BoxFit.contain)
: const Center(child: CircularProgressIndicator()),
),
),
),
),
const SizedBox(height: 8),
// 份数调节
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('打印份数:', style: TextStyle(fontSize: 13)),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 20),
onPressed: (_printing || label.copies <= 1)
? null
: () => setState(() => label.copies--),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
SizedBox(
width: 32,
child: Text(
'${label.copies}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20),
onPressed:
_printing ? null : () => setState(() => label.copies++),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
],
);
}
// ── build ────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final isMulti = widget.labels.length > 1;
final totalCount = _totalPrintCount;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Container(
width: context.dialogWidth(isMulti ? 640 : 440),
constraints: const BoxConstraints(maxHeight: 560),
child: Column(
children: [
// ── 顶栏 ──────────────────────────────────────────────────────────
Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('打印预览',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
// ── 打印机选择行 ──────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
const Text('打印机:',
style: TextStyle(fontSize: 13)),
const SizedBox(width: 8),
Expanded(
child: _printers.isEmpty
? Text(
_printersChecked ? '无打印机可用' : '正在检测打印机...',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary))
: DropdownButtonHideUnderline(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.border),
borderRadius: BorderRadius.circular(6),
),
child: DropdownButton<String>(
value: _printer,
isExpanded: true,
isDense: true,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary),
items: _printers
.map((p) => DropdownMenuItem(
value: p,
child: Text(p,
overflow:
TextOverflow.ellipsis),
))
.toList(),
onChanged: _printing
? null
: (v) async {
if (v == null) return;
setState(() => _printer = v);
final prefs = await SharedPreferences
.getInstance();
await prefs.setString(
_kPrinterKey, v);
},
),
),
),
),
],
),
),
const SizedBox(height: 8),
// ── 主体:缩略图条 + 大图 ─────────────────────────────────────────
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isMulti)
SizedBox(
width: 148,
child: ListView.builder(
itemCount: widget.labels.length,
itemBuilder: (_, i) => _buildThumbnail(i),
),
),
if (isMulti) const SizedBox(width: 8),
Expanded(child: _buildMainPreview()),
],
),
),
),
// ── 底栏 ─────────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 14),
child: Row(
children: [
if (_status.isNotEmpty)
Expanded(
child: Text(
_status,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary),
),
)
else
const Spacer(),
TextButton(
onPressed:
_printing ? null : () => Navigator.of(context).pop(),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: (_printing || totalCount == 0 || _printer == null)
? null
: _doPrint,
child: Text(
_printing ? '打印中...' : '打印 $totalCount'),
),
],
),
),
],
),
),
);
}
}
+5 -3
View File
@@ -47,10 +47,12 @@ server {
expires 0;
}
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html
location ~ ^/product/ {
root /opt/jiu/web;
try_files $uri /app/index.html;
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
+1
View File
@@ -9,4 +9,5 @@ LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
STORAGE_UPLOAD_DIR=/opt/jiu/images
STORAGE_BASE_URL=https://jiu.51yanmei.com/images
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
STORAGE_WEB_DIR=/opt/jiu/web
DB_PASSWORD=CHANGE_ME_DB_PASS