Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64ac17d6b5 | |||
| a5bdb79f81 | |||
| c4b4259a3d | |||
| 68e0ca30ad | |||
| a8c684ad0c | |||
| 0779bf57e3 |
@@ -5,6 +5,29 @@ 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.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.0.38] - 2026-06-15
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -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", "./client/build/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")
|
||||||
|
|||||||
@@ -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,91 @@ 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)
|
||||||
|
out := strings.Replace(idxHTML, "</head>", ogTags+"</head>", 1)
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildProductOG 构造基础 Open Graph meta 标签字符串。
|
||||||
|
// 所有值经 html.EscapeString 转义,防止 XSS 或破坏 HTML 结构。
|
||||||
|
// 无图片时不输出 og:image 行。
|
||||||
|
func buildProductOG(product model.Product, shopName, publicURL string) string {
|
||||||
|
// og:title:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列
|
||||||
|
title := product.Name
|
||||||
|
if product.Brand != "" && !strings.Contains(product.Name, product.Brand) {
|
||||||
|
title = product.Brand + title
|
||||||
|
}
|
||||||
|
if product.Series != "" {
|
||||||
|
title = title + "(" + product.Series + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
// 公开路由(无需登录)
|
// 公开路由(无需登录)
|
||||||
|
|||||||
@@ -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 49152–65535.
|
||||||
|
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;
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
|||||||
Future<ui.Image> _renderLabelBitmap({
|
Future<ui.Image> _renderLabelBitmap({
|
||||||
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,
|
||||||
@@ -223,24 +224,38 @@ Future<ui.Image> _renderLabelBitmap({
|
|||||||
// 扫码溯源:居中于二维码正下方
|
// 扫码溯源:居中于二维码正下方
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return recorder.endRecording().toImage(w, h);
|
return recorder.endRecording().toImage(w, h);
|
||||||
@@ -253,6 +268,7 @@ Future<ui.Image> _renderLabelBitmap({
|
|||||||
Future<bool> _printFlatLabelThermal({
|
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,
|
||||||
@@ -265,7 +281,7 @@ Future<bool> _printFlatLabelThermal({
|
|||||||
|
|
||||||
const w = 320, h = 160;
|
const w = 320, h = 160;
|
||||||
final img = await _renderLabelBitmap(
|
final img = await _renderLabelBitmap(
|
||||||
shop: shop, name: name, degSpec: degSpec, date: date, qrBytes: qrBytes);
|
shop: shop, name: name, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes);
|
||||||
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||||
final rgba = bd!.buffer.asUint8List();
|
final rgba = bd!.buffer.asUint8List();
|
||||||
|
|
||||||
@@ -323,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)
|
||||||
: '—';
|
: '—';
|
||||||
@@ -332,6 +348,7 @@ 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,
|
||||||
@@ -398,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)),
|
||||||
@@ -473,7 +496,7 @@ Future<void> printProductLabelImpl({
|
|||||||
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
|
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
|
||||||
final specVal = (label.spec ?? '').isNotEmpty ? label.spec! : '';
|
final specVal = (label.spec ?? '').isNotEmpty ? label.spec! : '';
|
||||||
final seriesVal = (label.series ?? '').isNotEmpty ? label.series! : '';
|
final seriesVal = (label.series ?? '').isNotEmpty ? label.series! : '';
|
||||||
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||||
final dateVal = (label.productionDate ?? '').isNotEmpty
|
final dateVal = (label.productionDate ?? '').isNotEmpty
|
||||||
? (label.productionDate!.length > 10
|
? (label.productionDate!.length > 10
|
||||||
? label.productionDate!.substring(0, 10)
|
? label.productionDate!.substring(0, 10)
|
||||||
@@ -482,6 +505,7 @@ Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
|
|||||||
final img = await _renderLabelBitmap(
|
final img = await _renderLabelBitmap(
|
||||||
shop: label.shopName,
|
shop: label.shopName,
|
||||||
name: label.name,
|
name: label.name,
|
||||||
|
code: label.code,
|
||||||
degSpec: degSpecVal,
|
degSpec: degSpecVal,
|
||||||
date: dateVal,
|
date: dateVal,
|
||||||
qrBytes: label.qrBytes!,
|
qrBytes: label.qrBytes!,
|
||||||
@@ -501,7 +525,7 @@ Future<List<String>> listLabelPrintersImpl() async {
|
|||||||
return (r.stdout as String)
|
return (r.stdout as String)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.where((s) => s.isNotEmpty)
|
.where((s) => s.isNotEmpty && _isThermalName(s))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -511,7 +535,10 @@ Future<List<String>> listLabelPrintersImpl() async {
|
|||||||
}
|
}
|
||||||
if (Platform.isWindows) {
|
if (Platform.isWindows) {
|
||||||
try {
|
try {
|
||||||
return (await Printing.listPrinters()).map((p) => p.name).toList();
|
return (await Printing.listPrinters())
|
||||||
|
.map((p) => p.name)
|
||||||
|
.where((n) => _isThermalName(n))
|
||||||
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[label] listPrinters 失败: $e');
|
debugPrint('[label] listPrinters 失败: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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';
|
||||||
@@ -36,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
|
||||||
@@ -222,6 +240,91 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 库存查询:宽屏表格单元格(按列 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()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// 库存查询:窄屏卡片
|
/// 库存查询:窄屏卡片
|
||||||
Widget _inventoryCard(Inventory item) {
|
Widget _inventoryCard(Inventory item) {
|
||||||
return MobileListCard(
|
return MobileListCard(
|
||||||
@@ -392,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(窄屏横向滚动,宽屏等分)
|
||||||
@@ -481,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,
|
||||||
@@ -503,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
|
||||||
@@ -572,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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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]),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
|
|||||||
List<String> _printers = [];
|
List<String> _printers = [];
|
||||||
String? _printer;
|
String? _printer;
|
||||||
bool _printing = false;
|
bool _printing = false;
|
||||||
|
bool _printersChecked = false;
|
||||||
String _status = '';
|
String _status = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -63,6 +64,7 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_printers = printers;
|
_printers = printers;
|
||||||
_printer = selected;
|
_printer = selected;
|
||||||
|
_printersChecked = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +149,7 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
|
|||||||
_printing = false;
|
_printing = false;
|
||||||
_status = '完成,共打印 $done 张';
|
_status = '完成,共打印 $done 张';
|
||||||
});
|
});
|
||||||
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,8 +332,9 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _printers.isEmpty
|
child: _printers.isEmpty
|
||||||
? const Text('检测中或未检测到打印机...',
|
? Text(
|
||||||
style: TextStyle(
|
_printersChecked ? '无打印机可用' : '正在检测打印机...',
|
||||||
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppTheme.textSecondary))
|
color: AppTheme.textSecondary))
|
||||||
: DropdownButtonHideUnderline(
|
: DropdownButtonHideUnderline(
|
||||||
@@ -419,7 +423,9 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: (_printing || totalCount == 0) ? null : _doPrint,
|
onPressed: (_printing || totalCount == 0 || _printer == null)
|
||||||
|
? null
|
||||||
|
: _doPrint,
|
||||||
child: Text(
|
child: Text(
|
||||||
_printing ? '打印中...' : '打印 $totalCount 张'),
|
_printing ? '打印中...' : '打印 $totalCount 张'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user