Compare commits

...

11 Commits

Author SHA1 Message Date
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
17 changed files with 1047 additions and 574 deletions
+47
View File
@@ -5,6 +5,53 @@ 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.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 ## [1.0.37] - 2026-06-15
### 新功能 ### 新功能
+3
View File
@@ -43,6 +43,7 @@ type StorageConfig struct {
UploadDir string `mapstructure:"upload_dir"` UploadDir string `mapstructure:"upload_dir"`
BaseURL string `mapstructure:"base_url"` BaseURL string `mapstructure:"base_url"`
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码 PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入
} }
var C Config var C Config
@@ -66,6 +67,7 @@ func Load() {
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR") _ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL") _ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL") _ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
// 默认值 // 默认值
viper.SetDefault("server.port", "8080") viper.SetDefault("server.port", "8080")
@@ -78,6 +80,7 @@ func Load() {
viper.SetDefault("storage.upload_dir", "./uploads/images") viper.SetDefault("storage.upload_dir", "./uploads/images")
viper.SetDefault("storage.base_url", "http://localhost:8080/images") viper.SetDefault("storage.base_url", "http://localhost:8080/images")
viper.SetDefault("storage.public_url", "http://localhost:8081") viper.SetDefault("storage.public_url", "http://localhost:8081")
viper.SetDefault("storage.web_dir", "./web")
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file found, using defaults and env vars") log.Println("[config] no config file found, using defaults and env vars")
+101
View File
@@ -3,13 +3,16 @@ package handler
import ( import (
"errors" "errors"
"fmt" "fmt"
"html"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/model"
) )
@@ -274,3 +277,101 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
"page_size": pageSize, "page_size": pageSize,
}) })
} }
// ProductPage GET /product/:public_id
// 返回注入了基础 Open Graph 标签的 Flutter index.html,供微信/飞书等社交平台爬虫生成分享卡片。
// 找不到商品时原样返回 index.html,让 Flutter 自行展示"商品不存在";爬虫拿不到 OG 标签但页面不报错。
func (h *PublicHandler) ProductPage(c *gin.Context) {
publicID := c.Param("public_id")
// 读取 Flutter 构建产物 index.html
idxPath := config.C.Storage.WebDir + "/index.html"
idxBytes, err := os.ReadFile(idxPath)
if err != nil {
c.String(http.StatusInternalServerError, "index.html not found: %s", idxPath)
return
}
idxHTML := string(idxBytes)
// 查商品(只取 OG 所需字段,轻量查询)
var product model.Product
if err := h.db.Select("id, public_id, name, brand, series, spec, shop_id").
Where("public_id = ? AND deleted_at IS NULL", publicID).
Preload("Images").
First(&product).Error; err != nil {
// 查不到商品:原样返回 index.html,让 Flutter 展示"商品不存在"
c.Data(http.StatusOK, "text/html; charset=utf-8", idxBytes)
return
}
// 查门店名
var shop model.Shop
shopName := ""
if err := h.db.Select("name").Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
shopName = shop.Name
}
// 构造 OG 标签并注入 </head> 前
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
ogTitle := buildOGTitle(product)
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
out := strings.Replace(idxHTML, "<title>岩美</title>", "<title>"+html.EscapeString(ogTitle)+"</title>", 1)
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:规格(度数/香型/容量)| 门店名正品
desc := product.Spec
suffix := "正品"
if shopName != "" {
suffix = shopName + suffix
}
if desc != "" {
desc = desc + " | " + suffix
} else {
desc = suffix
}
// og:site_name
siteName := "岩美酒库"
if shopName != "" {
siteName = shopName + " · " + siteName
}
// og:url
pageURL := publicURL + "/product/" + product.PublicID
var sb strings.Builder
sb.WriteString("\n")
sb.WriteString(` <meta property="og:type" content="website">` + "\n")
sb.WriteString(` <meta property="og:site_name" content="` + html.EscapeString(siteName) + `">` + "\n")
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(pageURL) + `">` + "\n")
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(title) + `">` + "\n")
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(desc) + `">` + "\n")
// og:image:仅有图片时输出
if len(product.Images) > 0 && product.Images[0].URL != "" {
imgURL := publicURL + product.Images[0].URL
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(imgURL) + `">` + "\n")
}
return sb.String()
}
+3
View File
@@ -45,6 +45,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
// 版本信息(无需认证,用于客户端更新检查) // 版本信息(无需认证,用于客户端更新检查)
r.GET("/version", handler.GetVersion) r.GET("/version", handler.GetVersion)
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
r.GET("/product/:public_id", publicH.ProductPage)
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
// 公开路由(无需登录) // 公开路由(无需登录)
+11 -9
View File
@@ -1,11 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; 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. // Keep the server socket alive for the lifetime of the process.
// ignore: unused_element // The OS releases the port automatically when the process exits.
RandomAccessFile? _raf; 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 true if this process successfully acquired the single-instance lock.
/// Returns false if another instance is already running. /// Returns false if another instance is already running.
@@ -15,11 +17,11 @@ Future<bool> acquire() async {
if (!Platform.isWindows && !Platform.isMacOS) return true; if (!Platform.isWindows && !Platform.isMacOS) return true;
try { try {
final dir = await getApplicationSupportDirectory(); _server = await ServerSocket.bind(
final lockFile = File('${dir.path}/jiu.lock'); InternetAddress.loopbackIPv4,
final raf = await lockFile.open(mode: FileMode.write); _kLockPort,
await raf.lock(FileLock.exclusive); shared: false,
_raf = raf; );
return true; return true;
} catch (_) { } catch (_) {
return false; 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_in.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
import 'label_data.dart';
export 'label_data.dart';
import 'print_util_stub.dart' import 'print_util_stub.dart'
if (dart.library.js_interop) 'print_util_web.dart'; if (dart.library.js_interop) 'print_util_web.dart';
@@ -18,6 +20,7 @@ Future<void> printProductLabel({
String shopName = '', String shopName = '',
String shopAddress = '', String shopAddress = '',
String shopPhone = '', String shopPhone = '',
String? printerName,
}) => }) =>
printProductLabelImpl( printProductLabelImpl(
qrBytes: qrBytes, qrBytes: qrBytes,
@@ -31,8 +34,19 @@ Future<void> printProductLabel({
shopName: shopName, shopName: shopName,
shopAddress: shopAddress, shopAddress: shopAddress,
shopPhone: shopPhone, 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) => Future<void> printStockInOrder(StockInOrder order) =>
printStockInOrderImpl(order); printStockInOrderImpl(order);
+125 -25
View File
@@ -22,6 +22,7 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
import '../../models/stock_in.dart'; import '../../models/stock_in.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import 'label_data.dart';
Future<pw.Font> _loadFont() async { Future<pw.Font> _loadFont() async {
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf'); 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; return size;
} }
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机 /// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源 /// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。 Future<ui.Image> _renderLabelBitmap({
Future<bool> _printFlatLabelThermal({
required String shop, required String shop,
required String name, required String name,
required String code,
required String degSpec, required String degSpec,
required String date, required String date,
required Uint8List qrBytes, required Uint8List qrBytes,
}) async { }) 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 const w = 320, h = 160; // 40×20mm @203dpi
final recorder = ui.PictureRecorder(); final recorder = ui.PictureRecorder();
final canvas = ui.Canvas( final canvas = ui.Canvas(
@@ -228,27 +224,64 @@ Future<bool> _printFlatLabelThermal({
// 扫码溯源:居中于二维码正下方 // 扫码溯源:居中于二维码正下方
_drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true); _drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true);
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充 // 左侧文字:固定字号 + 自适应行间距(与二维码上下对齐,行距均匀分配剩余空间)
const lx = 22.0; const lx = 22.0;
const textMaxW = qrLeft - lx - 6; // 164 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)>[ final rows = <(String, double, bool)>[
(shop, 0.9, true), if (shop.isNotEmpty) (shop, kShopSize, true),
(name, 1.5, true), if (name.isNotEmpty) (name, nameSize, true),
if (degSpec.isNotEmpty) (degSpec, 1.05, false), if (codeText.isNotEmpty) (codeText, kSmallSize, false),
if (date.isNotEmpty) (date, 1.0, 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) { for (final r in rows) {
final rowH = (botY - topY) * r.$2 / totalWeight; _drawText(canvas, r.$1, lx, yy, r.$2, textMaxW, bold: r.$3);
final fs = _fitFont(r.$1, textMaxW, rowH * 0.84, r.$3, 30); yy += r.$2 * kLineH + gap;
final ty = yy + (rowH - fs * 1.25) / 2;
_drawText(canvas, r.$1, lx, ty < yy ? yy : ty, fs, textMaxW, bold: r.$3);
yy += rowH;
} }
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 bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List(); final rgba = bd!.buffer.asUint8List();
@@ -290,6 +323,7 @@ Future<void> printProductLabelImpl({
String shopName = '', String shopName = '',
String shopAddress = '', String shopAddress = '',
String shopPhone = '', String shopPhone = '',
String? printerName,
}) async { }) async {
try { try {
final font = await _loadFont(); final font = await _loadFont();
@@ -305,7 +339,7 @@ Future<void> printProductLabelImpl({
final specVal = (spec ?? '').isNotEmpty ? spec! : ''; final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : ''; 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 final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate) ? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: ''; : '';
@@ -314,9 +348,11 @@ Future<void> printProductLabelImpl({
if (await _printFlatLabelThermal( if (await _printFlatLabelThermal(
shop: shopName, shop: shopName,
name: name, name: name,
code: code,
degSpec: degSpecVal, degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行 date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes)) { qrBytes: qrBytes,
printerName: printerName)) {
return; return;
} }
@@ -379,6 +415,12 @@ Future<void> printProductLabelImpl({
fontWeight: pw.FontWeight.bold, fontWeight: pw.FontWeight.bold,
color: _ink, letterSpacing: 0.3)), color: _ink, letterSpacing: 0.3)),
pw.SizedBox(height: 2.5), pw.SizedBox(height: 2.5),
// 商品编号(度数上方)
if (code.isNotEmpty) ...[
pw.Text('编号:$code',
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
],
// 度数(系列) + 规格 同一行, 无字段名标签 // 度数(系列) + 规格 同一行, 无字段名标签
pw.Text(degSpecVal, pw.Text(degSpecVal,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
@@ -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({ pw.Widget _buildOrderDoc({
required pw.Font font, required pw.Font font,
required pw.Font bold, 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_in.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
import 'label_data.dart';
void _openPrintWindow(String html) { void _openPrintWindow(String html) {
final win = web.window.open('', '_blank'); final win = web.window.open('', '_blank');
@@ -27,6 +28,7 @@ Future<void> printProductLabelImpl({
String shopName = '', String shopName = '',
String shopAddress = '', String shopAddress = '',
String shopPhone = '', String shopPhone = '',
String? printerName,
}) async { }) async {
final base64Img = base64Encode(qrBytes); 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 { Future<void> printStockOutOrderImpl(StockOutOrder order) async {
@@ -12,10 +12,12 @@ import '../../core/auth/auth_state.dart';
import '../../providers/inventory_provider.dart'; import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart'; import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_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 '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../core/utils/print_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_provider.dart';
import '../../providers/product_option_provider.dart'; import '../../providers/product_option_provider.dart';
import '../../providers/tab_state_provider.dart'; import '../../providers/tab_state_provider.dart';
@@ -34,6 +36,24 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Set<String> _filterWarehouse = {}; Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {}; Set<String> _filterSpec = {};
Set<String> _filterSeries = {}; 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 @override
@@ -200,22 +220,109 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
await ref.read(productRepositoryProvider).getQRCodeBytes(item.productId!); await ref.read(productRepositoryProvider).getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull; final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (!context.mounted) return; if (!context.mounted) return;
await safePrint( final label = LabelData(
context, productId: item.productId,
() => printProductLabel( qrBytes: qrBytes,
qrBytes: qrBytes, name: item.productName,
name: item.productName, code: item.productCode,
code: item.productCode, series: item.series.isEmpty ? null : item.series,
series: item.series.isEmpty ? null : item.series, spec: item.spec.isEmpty ? null : item.spec,
spec: item.spec.isEmpty ? null : item.spec, batchNo: item.batchNo.isEmpty ? null : item.batchNo,
batchNo: item.batchNo.isEmpty ? null : item.batchNo, productionDate: item.productionDate,
productionDate: item.productionDate, remark: item.remark.isEmpty ? null : item.remark,
remark: item.remark.isEmpty ? null : item.remark, shopName: shopInfo?.name ?? '',
shopName: shopInfo?.name ?? '', shopAddress: shopInfo?.address ?? '',
shopAddress: shopInfo?.address ?? '', shopPhone: shopInfo?.phone ?? '',
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
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList(); : items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
final visibleCols =
_colDefs.where((c) => !_hiddenCols.contains(c.key)).toList();
return Column( return Column(
children: [ children: [
// Summary cards(窄屏横向滚动,宽屏等分) // Summary cards(窄屏横向滚动,宽屏等分)
@@ -477,6 +587,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
icon: const Icon(Icons.download, size: 16), icon: const Icon(Icons.download, size: 16),
label: const Text('导出'), label: const Text('导出'),
), ),
const SizedBox(width: 8),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
const Spacer(), const Spacer(),
SizedBox( SizedBox(
width: 220, width: 220,
@@ -499,68 +615,48 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
), ),
mobileCards: mobileCards:
filteredItems.map(_inventoryCard).toList(), filteredItems.map(_inventoryCard).toList(),
columns: [ columns: visibleCols.map((c) {
const DataColumn(label: Text('商品编码')), final label = switch (c.key) {
const DataColumn(label: Text('商品名称')), 'spec' => FilterableColumnHeader(
DataColumn( text: c.label,
label: FilterableColumnHeader( options: specOptions,
text: '规格', selected: _filterSpec,
options: specOptions, onChanged: (v) {
selected: _filterSpec, setState(() => _filterSpec = v);
onChanged: (v) { ref.read(inventoryListProvider.notifier).setSpec(v.toList());
setState(() => _filterSpec = v); },
ref.read(inventoryListProvider.notifier).setSpec(v.toList()); ),
}, 'series' => FilterableColumnHeader(
), text: c.label,
), options: seriesOptions,
DataColumn( selected: _filterSeries,
label: FilterableColumnHeader( onChanged: (v) {
text: '系列', setState(() => _filterSeries = v);
options: seriesOptions, ref.read(inventoryListProvider.notifier).setSeries(v.toList());
selected: _filterSeries, },
onChanged: (v) { ),
setState(() => _filterSeries = v); 'warehouse' => FilterableColumnHeader(
ref.read(inventoryListProvider.notifier).setSeries(v.toList()); text: c.label,
}, options: warehouseOptions,
), selected: _filterWarehouse,
), onChanged: (v) => setState(() => _filterWarehouse = v),
const DataColumn(label: Text('批次号')), ),
DataColumn( _ => Text(c.label),
label: FilterableColumnHeader( };
text: '仓库', return DataColumn(
options: warehouseOptions, label: label,
selected: _filterWarehouse, numeric: c.key == 'qty' || c.key == 'price',
onChanged: (v) => setState(() => _filterWarehouse = v), );
), }).toList(),
),
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('操作')),
],
rows: items.isEmpty rows: items.isEmpty
? [ ? [
const DataRow(cells: [ DataRow(cells: [
DataCell(SizedBox()), const DataCell(SizedBox()),
DataCell(Text('暂无库存数据', const DataCell(Text('暂无库存数据',
style: TextStyle( style: TextStyle(
color: AppTheme.textSecondary))), color: AppTheme.textSecondary))),
DataCell(SizedBox()), for (int i = 2; i < visibleCols.length; i++)
DataCell(SizedBox()), const DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
]) ])
] ]
: filteredItems : filteredItems
@@ -568,129 +664,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
color: WidgetStateProperty.resolveWith( color: WidgetStateProperty.resolveWith(
(states) { (states) {
if (item.quantity == 0) { if (item.quantity == 0) {
return AppTheme.danger.withOpacity(0.04); return AppTheme.danger.withValues(alpha: 0.04);
} }
if (item.minStock != null && if (item.minStock != null &&
item.quantity < item.minStock!) { item.quantity < item.minStock!) {
return AppTheme.accent.withOpacity(0.04); return AppTheme.accent.withValues(alpha: 0.04);
} }
return null; return null;
}), }),
cells: [ cells: visibleCols
DataCell(Text( .map((c) => _buildInventoryCell(c.key, item, context))
item.productCode.isEmpty ? '-' : item.productCode, .toList(),
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(),
),
],
)) ))
.toList(), .toList(),
), ),
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_config.dart'; import '../../core/config/app_config.dart';
import '../../core/utils/print_util.dart'; import '../../core/utils/print_util.dart';
import '../../widgets/label_preview_dialog.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../models/product.dart'; import '../../models/product.dart';
import '../../models/product_image.dart'; import '../../models/product_image.dart';
@@ -656,8 +657,6 @@ class _QRCodeDialog extends ConsumerStatefulWidget {
class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
late Future<Uint8List> _future; late Future<Uint8List> _future;
Uint8List? _bytes; Uint8List? _bytes;
bool _printing = false;
String _printStatus = '';
@override @override
void initState() { void initState() {
@@ -670,25 +669,25 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
}); });
} }
Future<void> _print() async { void _print() {
if (_bytes == null || _printing) return; if (_bytes == null) return;
setState(() { _printing = true; _printStatus = '正在打印...'; });
final p = widget.product; final p = widget.product;
final shopInfo = ref.read(shopInfoProvider).valueOrNull; final shopInfo = ref.read(shopInfoProvider).valueOrNull;
try { final label = LabelData(
await safePrint(context, () => printProductLabel( productId: p.id,
qrBytes: _bytes!, qrBytes: _bytes,
name: p.name, name: p.name,
code: p.code, code: p.code,
spec: p.spec, spec: p.spec,
series: p.series, series: p.series,
shopName: shopInfo?.name ?? '', shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '', shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '', shopPhone: shopInfo?.phone ?? '',
)); );
} finally { showAppDialog(
if (mounted) setState(() { _printing = false; _printStatus = ''; }); context: context,
} builder: (_) => LabelPreviewDialog(labels: [label]),
);
} }
@override @override
@@ -717,15 +716,9 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
child: const Text('关闭'), child: const Text('关闭'),
), ),
FilledButton.icon( FilledButton.icon(
onPressed: _bytes == null ? null : (_printing ? null : _print), onPressed: _bytes == null ? null : _print,
icon: _printing icon: const Icon(Icons.print, size: 16),
? const SizedBox( label: const Text('打印'),
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.print, size: 16),
label: Text(_printing
? (_printStatus.isNotEmpty ? _printStatus : '准备中...')
: '打印'),
), ),
], ],
); );
@@ -1,6 +1,7 @@
import '../../core/utils/dialog_util.dart'; import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/page_scaffold.dart';
import '../../widgets/status_badge.dart'; import '../../widgets/status_badge.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../providers/inventory_provider.dart'; import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart'; import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart' show productRepositoryProvider; 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); final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (!context.mounted) return; if (!context.mounted) return;
final shopInfo = ref.read(shopInfoProvider).valueOrNull; 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, context: context,
builder: (_) => _LabelPrintDialog( builder: (_) => LabelPreviewDialog(
order: order, labels: labels,
productRepo: ref.read(productRepositoryProvider), qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
), ),
); );
}, },
@@ -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/utils/dialog_util.dart';
import '../../core/errors/error_reporter.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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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> { class _DataTableCardState extends State<DataTableCard> {
// 当前悬停行的索引(-1 表示无悬停) // 当前悬停行的索引(-1 表示无悬停)
final _hoveredRow = ValueNotifier<int>(-1); final _hoveredRow = ValueNotifier<int>(-1);
final _vCtrl = ScrollController();
final _hCtrl = ScrollController();
@override @override
void dispose() { void dispose() {
_hoveredRow.dispose(); _hoveredRow.dispose();
_vCtrl.dispose();
_hCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -89,24 +93,35 @@ class _DataTableCardState extends State<DataTableCard> {
Widget _buildTable() { Widget _buildTable() {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView( builder: (context, constraints) => Scrollbar(
controller: _vCtrl,
thumbVisibility: true,
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, controller: _vCtrl,
child: ConstrainedBox( child: Scrollbar(
constraints: BoxConstraints(minWidth: constraints.maxWidth), controller: _hCtrl,
child: Table( thumbVisibility: true,
defaultColumnWidth: const IntrinsicColumnWidth(), scrollbarOrientation: ScrollbarOrientation.bottom,
border: TableBorder( child: SingleChildScrollView(
horizontalInside: BorderSide( controller: _hCtrl,
color: Colors.grey.shade200, scrollDirection: Axis.horizontal,
width: 0.5, 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; expires 0;
} }
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理 # 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html
location ~ ^/product/ { location ~ ^/product/ {
root /opt/jiu/web; proxy_pass http://127.0.0.1:8080;
try_files $uri /app/index.html; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
} }
# 桌面客户端安装包下载(Windows .exe / macOS .zip # 桌面客户端安装包下载(Windows .exe / macOS .zip
+1
View File
@@ -9,4 +9,5 @@ LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
STORAGE_UPLOAD_DIR=/opt/jiu/images STORAGE_UPLOAD_DIR=/opt/jiu/images
STORAGE_BASE_URL=https://jiu.51yanmei.com/images STORAGE_BASE_URL=https://jiu.51yanmei.com/images
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
STORAGE_WEB_DIR=/opt/jiu/web
DB_PASSWORD=CHANGE_ME_DB_PASS DB_PASSWORD=CHANGE_ME_DB_PASS