Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ca65e8077 | |||
| bf36f7aa3f | |||
| 097e466372 | |||
| c1ba6c6d97 | |||
| 2b327cd365 | |||
| 07f8973477 | |||
| 3b02a848c0 | |||
| bde2d35756 | |||
| 4ac5adac3e | |||
| 8c2dcf49a8 | |||
| f400459497 | |||
| 64ac17d6b5 | |||
| a5bdb79f81 | |||
| c4b4259a3d |
@@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.52] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题
|
||||
|
||||
## [1.0.51] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 标签打印字体细化:阈值调回 128,店名改细体,商品名保持粗体
|
||||
|
||||
## [1.0.50] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 标签打印字体更清晰:采用 2× 超采样渲染,消除热敏打印时反锯齿导致的模糊笔划
|
||||
|
||||
## [1.0.49] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 二维码链接统一使用 `/app/product/` 路径,扫码后 URL 不再跳变,分享与打开同一个链接
|
||||
|
||||
## [1.0.48] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 修复分享 `/app/product/` 路径时微信爬虫拿不到 OG 标签的问题(nginx 补充该路径的代理规则)
|
||||
- 分享卡片描述规格/系列与门店名分两行显示
|
||||
|
||||
## [1.0.47] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 微信分享卡片描述补充度数/系列信息,不再只显示规格
|
||||
|
||||
## [1.0.46] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 微信内打开商品页时顶部标题栏显示商品名称,不再显示"岩美"
|
||||
|
||||
## [1.0.45] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 修复微信分享卡片标题仍显示"岩美"的问题(HTML 缩进导致字符串匹配失败)
|
||||
- 微信分享卡片描述去掉"扫码验真"后缀
|
||||
|
||||
## [1.0.44] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 微信分享卡片标题改为商品名称(之前仍显示"岩美")
|
||||
- 微信分享卡片描述去掉"扫码验真"后缀
|
||||
|
||||
## [1.0.43] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 修复微信/飞书分享商品链接时卡片只显示"岩美"而不显示商品名称和图片的问题
|
||||
|
||||
## [1.0.42] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 修复商品二维码扫码页报"index.html not found"的错误
|
||||
|
||||
## [1.0.41] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 标签打印布局优化:编号字体与度数/日期统一大小,内容整体下移并填满标签底部,店名适当放大
|
||||
- 标签度数与规格之间加宽间距,两个字段更易区分
|
||||
|
||||
## [1.0.40] - 2026-06-15
|
||||
|
||||
### 新功能
|
||||
- 商品标签打印新增编号行(显示「编号:xxxx」),位于度数上方,方便核对入库单
|
||||
- 扫码分享商品链接时,微信/企业微信/飞书/钉钉等平台的分享卡片现可显示商品名、规格描述及商品图片,不再只显示「岩美」
|
||||
|
||||
### 改进
|
||||
- 库存页表格支持横向滚动,列过多时不再截断;支持显示/隐藏列,按需定制显示内容
|
||||
|
||||
## [1.0.39] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -43,6 +43,7 @@ type StorageConfig struct {
|
||||
UploadDir string `mapstructure:"upload_dir"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
|
||||
WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入
|
||||
}
|
||||
|
||||
var C Config
|
||||
@@ -66,6 +67,7 @@ func Load() {
|
||||
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
|
||||
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
||||
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
|
||||
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
|
||||
|
||||
// 默认值
|
||||
viper.SetDefault("server.port", "8080")
|
||||
@@ -78,6 +80,7 @@ func Load() {
|
||||
viper.SetDefault("storage.upload_dir", "./uploads/images")
|
||||
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
|
||||
viper.SetDefault("storage.public_url", "http://localhost:8081")
|
||||
viper.SetDefault("storage.web_dir", "./web")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Println("[config] no config file found, using defaults and env vars")
|
||||
|
||||
@@ -198,7 +198,7 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
|
||||
h.db.Model(&product).Update("public_id", product.PublicID)
|
||||
}
|
||||
|
||||
url := config.C.Storage.PublicURL + "/product/" + product.PublicID
|
||||
url := config.C.Storage.PublicURL + "/app/product/" + product.PublicID
|
||||
if product.Code != "" {
|
||||
url += "?code=" + product.Code
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
@@ -274,3 +278,110 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ProductPage GET /product/:public_id
|
||||
// 返回注入了基础 Open Graph 标签的 Flutter index.html,供微信/飞书等社交平台爬虫生成分享卡片。
|
||||
// 找不到商品时原样返回 index.html,让 Flutter 自行展示"商品不存在";爬虫拿不到 OG 标签但页面不报错。
|
||||
func (h *PublicHandler) ProductPage(c *gin.Context) {
|
||||
publicID := c.Param("public_id")
|
||||
|
||||
// 读取 Flutter 构建产物 index.html
|
||||
idxPath := config.C.Storage.WebDir + "/index.html"
|
||||
idxBytes, err := os.ReadFile(idxPath)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "index.html not found: %s", idxPath)
|
||||
return
|
||||
}
|
||||
idxHTML := string(idxBytes)
|
||||
|
||||
// 查商品(只取 OG 所需字段,轻量查询)
|
||||
var product model.Product
|
||||
if err := h.db.Select("id, public_id, name, brand, series, spec, shop_id").
|
||||
Where("public_id = ? AND deleted_at IS NULL", publicID).
|
||||
Preload("Images").
|
||||
First(&product).Error; err != nil {
|
||||
// 查不到商品:原样返回 index.html,让 Flutter 展示"商品不存在"
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", idxBytes)
|
||||
return
|
||||
}
|
||||
|
||||
// 查门店名
|
||||
var shop model.Shop
|
||||
shopName := ""
|
||||
if err := h.db.Select("name").Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
|
||||
shopName = shop.Name
|
||||
}
|
||||
|
||||
// 构造 OG 标签并注入 </head> 前
|
||||
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
|
||||
ogTitle := buildOGTitle(product)
|
||||
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
|
||||
// 用正则替换避免缩进空格导致字符串不匹配
|
||||
titleRe := regexp.MustCompile(`<title>[^<]*</title>`)
|
||||
out := titleRe.ReplaceAllString(idxHTML, "<title>"+html.EscapeString(ogTitle)+"</title>")
|
||||
out = strings.Replace(out, "</head>", ogTags+"</head>", 1)
|
||||
// 禁止缓存,确保爬虫每次都能拿到最新 OG 标签
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out))
|
||||
}
|
||||
|
||||
// buildOGTitle 生成商品的分享标题:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列。
|
||||
func buildOGTitle(product model.Product) string {
|
||||
title := product.Name
|
||||
if product.Brand != "" && !strings.Contains(product.Name, product.Brand) {
|
||||
title = product.Brand + title
|
||||
}
|
||||
if product.Series != "" {
|
||||
title = title + "(" + product.Series + ")"
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// buildProductOG 构造基础 Open Graph meta 标签字符串。
|
||||
// 所有值经 html.EscapeString 转义,防止 XSS 或破坏 HTML 结构。
|
||||
// 无图片时不输出 og:image 行。
|
||||
func buildProductOG(product model.Product, shopName, publicURL string) string {
|
||||
title := buildOGTitle(product)
|
||||
|
||||
// og:description:系列(度数/香型)· 规格(容量/单位)| 门店名正品
|
||||
var specParts []string
|
||||
if product.Series != "" {
|
||||
specParts = append(specParts, product.Series)
|
||||
}
|
||||
if product.Spec != "" {
|
||||
specParts = append(specParts, product.Spec)
|
||||
}
|
||||
desc := strings.Join(specParts, " · ")
|
||||
suffix := "正品"
|
||||
if shopName != "" {
|
||||
suffix = shopName + suffix
|
||||
}
|
||||
if desc != "" {
|
||||
desc = desc + "\n" + suffix
|
||||
} else {
|
||||
desc = suffix
|
||||
}
|
||||
|
||||
// og:site_name
|
||||
siteName := "岩美酒库"
|
||||
if shopName != "" {
|
||||
siteName = shopName + " · " + siteName
|
||||
}
|
||||
|
||||
// og:url
|
||||
pageURL := publicURL + "/app/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)
|
||||
|
||||
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
|
||||
r.GET("/product/:public_id", publicH.ProductPage)
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
// 公开路由(无需登录)
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ffi';
|
||||
import 'package:charset_converter/charset_converter.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:win32/win32.dart'
|
||||
show
|
||||
@@ -189,19 +190,24 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
||||
return size;
|
||||
}
|
||||
|
||||
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
|
||||
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
||||
Future<ui.Image> _renderLabelBitmap({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String code,
|
||||
required String degSpec,
|
||||
required String date,
|
||||
required Uint8List qrBytes,
|
||||
}) async {
|
||||
const w = 320, h = 160; // 40×20mm @203dpi
|
||||
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
|
||||
const scale = 2; // 2× 超采样,内部以 640×320 绘制
|
||||
const sw = w * scale, sh = h * scale;
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(
|
||||
recorder, ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()));
|
||||
recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
|
||||
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
|
||||
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
|
||||
ui.Paint()..color = const ui.Color(0xFFFFFFFF));
|
||||
|
||||
@@ -223,36 +229,63 @@ Future<ui.Image> _renderLabelBitmap({
|
||||
// 扫码溯源:居中于二维码正下方
|
||||
_drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true);
|
||||
|
||||
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充
|
||||
// 左侧文字:固定字号 + 自适应行间距(与二维码上下对齐,行距均匀分配剩余空间)
|
||||
const lx = 22.0;
|
||||
const textMaxW = qrLeft - lx - 6; // 164
|
||||
|
||||
// 字号固定:只有商品名按宽度自适应,其余行均固定,杜绝自动放大
|
||||
const double kShopSize = 17.0; // 酒行名,粗体
|
||||
const double kSmallSize = 13.0; // 编号 / 度数 / 日期,统一小字
|
||||
// 商品名:仅按宽度适配,上限 26px,下限 14px
|
||||
final double nameSize = _fitFont(name, textMaxW, 1000, true, 26.0).clamp(14.0, 26.0);
|
||||
|
||||
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
||||
final rows = <(String, double, bool)>[
|
||||
(shop, 0.9, true),
|
||||
(name, 1.5, true),
|
||||
if (degSpec.isNotEmpty) (degSpec, 1.05, false),
|
||||
if (date.isNotEmpty) (date, 1.0, false),
|
||||
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
|
||||
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
|
||||
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
|
||||
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
|
||||
if (date.isNotEmpty) (date, kSmallSize, false),
|
||||
];
|
||||
const topY = 4.0, botY = 156.0;
|
||||
final totalWeight = rows.fold<double>(0, (a, r) => a + r.$2);
|
||||
double yy = topY;
|
||||
|
||||
// 文字区域:整体略低于二维码顶部,底部延伸至扫码溯源文字下方
|
||||
const double kTextTop = 16.0; // 比二维码顶部(8)低一些,整体下移
|
||||
const double kTextBot = 150.0; // 延伸至底部,减少留白
|
||||
const double kLineH = 1.35; // 行高倍数(字号 × 行高 = 该行占用高度)
|
||||
final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
|
||||
final int n = rows.length;
|
||||
final double rawGap = n > 0 ? (kTextBot - kTextTop - totalTextH) / (n + 1) : 4.0;
|
||||
final double gap = rawGap < 1.5 ? 1.5 : rawGap;
|
||||
|
||||
double yy = kTextTop + gap;
|
||||
for (final r in rows) {
|
||||
final rowH = (botY - topY) * r.$2 / totalWeight;
|
||||
final fs = _fitFont(r.$1, textMaxW, rowH * 0.84, r.$3, 30);
|
||||
final ty = yy + (rowH - fs * 1.25) / 2;
|
||||
_drawText(canvas, r.$1, lx, ty < yy ? yy : ty, fs, textMaxW, bold: r.$3);
|
||||
yy += rowH;
|
||||
_drawText(canvas, r.$1, lx, yy, r.$2, textMaxW, bold: r.$3);
|
||||
yy += r.$2 * kLineH + gap;
|
||||
}
|
||||
|
||||
return recorder.endRecording().toImage(w, h);
|
||||
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320)
|
||||
}
|
||||
|
||||
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
||||
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
|
||||
/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。
|
||||
/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。
|
||||
Future<void> _addTextCmd(
|
||||
BytesBuilder buf, int x, int y, String font, String text) async {
|
||||
if (text.isEmpty) return;
|
||||
final gbk = await CharsetConverter.encode('GBK', text);
|
||||
buf.add(ascii.encode('TEXT $x,$y,"$font",0,1,1,"'));
|
||||
buf.add(gbk);
|
||||
buf.add(ascii.encode('"\r\n'));
|
||||
}
|
||||
|
||||
/// 桌面端把标签以 TSPL TEXT(内置中文点阵字体)+ QR BITMAP 混合方式裸发到热敏机。
|
||||
/// TEXT 走 TSS24.BF2/TSS16.BF2(GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。
|
||||
/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。
|
||||
/// 检测不到热敏机 -> 返回 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,
|
||||
@@ -263,34 +296,79 @@ Future<bool> _printFlatLabelThermal({
|
||||
debugPrint('[label] thermal printer = $printer');
|
||||
if (printer == null) return false;
|
||||
|
||||
const w = 320, h = 160;
|
||||
final img = await _renderLabelBitmap(
|
||||
shop: shop, name: name, degSpec: degSpec, date: date, qrBytes: qrBytes);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final rgba = bd!.buffer.asUint8List();
|
||||
// QR BITMAP: 124×124 dots, 右侧 x=192, y=8
|
||||
const qrW = 124, qrH = 124;
|
||||
const qrX = 192, qrY = 8;
|
||||
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
|
||||
|
||||
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
|
||||
const wbytes = (w + 7) >> 3;
|
||||
final out = BytesBuilder();
|
||||
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
|
||||
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n'
|
||||
'BITMAP 0,0,$wbytes,$h,1,'));
|
||||
for (int yy = 0; yy < h; yy++) {
|
||||
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
|
||||
for (int xx = 0; xx < w; xx++) {
|
||||
final i = (yy * w + xx) * 4;
|
||||
final a = rgba[i + 3];
|
||||
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
|
||||
final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
|
||||
final qrImg = (await codec.getNextFrame()).image;
|
||||
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final qrRgba = qrBd!.buffer.asUint8List();
|
||||
|
||||
final buf = BytesBuilder();
|
||||
buf.add(ascii.encode(
|
||||
'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
|
||||
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
|
||||
));
|
||||
|
||||
// QR BITMAP 命令
|
||||
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
|
||||
for (int yy = 0; yy < qrH; yy++) {
|
||||
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白
|
||||
for (int xx = 0; xx < qrW; xx++) {
|
||||
final i = (yy * qrW + xx) * 4;
|
||||
final a = qrRgba[i + 3];
|
||||
final lum = a < 128
|
||||
? 255.0
|
||||
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
|
||||
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
|
||||
: 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
|
||||
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
|
||||
}
|
||||
out.add(row);
|
||||
buf.add(row);
|
||||
}
|
||||
out.add(ascii.encode('\r\nPRINT 1,1\r\n'));
|
||||
final tspl = out.toBytes();
|
||||
buf.add(ascii.encode('\r\n'));
|
||||
|
||||
debugPrint('[label] flat tspl ${tspl.length}B -> $printer');
|
||||
// "扫码溯源" 居中于 QR 正下方
|
||||
// TSS16 CJK 每字 16 dots; 4字=64; QR 中心 x=192+62=254; 文字起始 x=254-32=222
|
||||
await _addTextCmd(buf, 222, qrY + qrH + 2, 'TSS16.BF2', '扫码溯源');
|
||||
|
||||
// 左侧文字(x=4, 宽度至 x=186)
|
||||
// TSS24.BF2: 24×24 点阵; TSS16.BF2: 16×16 点阵
|
||||
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
|
||||
const kFontXL = 'TSS24.BF2'; // 大号(商品名,≤7字时使用)
|
||||
final nameFont = name.runes.length <= 7 ? kFontXL : kFontL;
|
||||
final nameH = nameFont == kFontXL ? 24 : 16;
|
||||
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
||||
|
||||
// (文本, 字体, 字符高度点数)
|
||||
final rows = <(String, String, int)>[];
|
||||
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
|
||||
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
|
||||
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
|
||||
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
|
||||
if (date.isNotEmpty) rows.add((date, kFontL, 16));
|
||||
|
||||
// 在 y=8..150 区间均匀分布各行
|
||||
const kTextTop = 8;
|
||||
const kTextBot = 150;
|
||||
const kTextX = 4;
|
||||
final totalH = rows.fold(0, (s, r) => s + r.$3);
|
||||
final n = rows.length;
|
||||
final gapSize = n > 0
|
||||
? ((kTextBot - kTextTop - totalH) / (n + 1)).round().clamp(2, 20)
|
||||
: 8;
|
||||
|
||||
int yPos = kTextTop + gapSize;
|
||||
for (final r in rows) {
|
||||
await _addTextCmd(buf, kTextX, yPos, r.$2, r.$1);
|
||||
yPos += r.$3 + gapSize;
|
||||
}
|
||||
|
||||
buf.add(ascii.encode('PRINT 1,1\r\n'));
|
||||
final tspl = buf.toBytes();
|
||||
|
||||
debugPrint('[label] TEXT+BITMAP tspl ${tspl.length}B -> $printer');
|
||||
await _sendRaw(printer, tspl);
|
||||
return true;
|
||||
}
|
||||
@@ -323,7 +401,7 @@ Future<void> printProductLabelImpl({
|
||||
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
|
||||
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
|
||||
// 度数(系列) + 规格 同一行
|
||||
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||
final dateVal = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
|
||||
: '—';
|
||||
@@ -332,6 +410,7 @@ Future<void> printProductLabelImpl({
|
||||
if (await _printFlatLabelThermal(
|
||||
shop: shopName,
|
||||
name: name,
|
||||
code: code,
|
||||
degSpec: degSpecVal,
|
||||
date: dateVal == '—' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
|
||||
qrBytes: qrBytes,
|
||||
@@ -398,6 +477,12 @@ Future<void> printProductLabelImpl({
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _ink, letterSpacing: 0.3)),
|
||||
pw.SizedBox(height: 2.5),
|
||||
// 商品编号(度数上方)
|
||||
if (code.isNotEmpty) ...[
|
||||
pw.Text('编号:$code',
|
||||
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
|
||||
pw.SizedBox(height: 1.5),
|
||||
],
|
||||
// 度数(系列) + 规格 同一行, 无字段名标签
|
||||
pw.Text(degSpecVal,
|
||||
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
|
||||
@@ -473,7 +558,7 @@ Future<void> printProductLabelImpl({
|
||||
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 degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||
final dateVal = (label.productionDate ?? '').isNotEmpty
|
||||
? (label.productionDate!.length > 10
|
||||
? label.productionDate!.substring(0, 10)
|
||||
@@ -482,6 +567,7 @@ Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
|
||||
final img = await _renderLabelBitmap(
|
||||
shop: label.shopName,
|
||||
name: label.name,
|
||||
code: label.code,
|
||||
degSpec: degSpecVal,
|
||||
date: dateVal,
|
||||
qrBytes: label.qrBytes!,
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../../core/auth/auth_state.dart';
|
||||
import '../../providers/inventory_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
|
||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
import '../../core/utils/print_util.dart';
|
||||
@@ -36,6 +36,24 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSpec = {};
|
||||
Set<String> _filterSeries = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('code', '商品编码', required: true),
|
||||
ColDef('name', '商品名称', required: true),
|
||||
ColDef('spec', '规格'),
|
||||
ColDef('series', '系列'),
|
||||
ColDef('batch', '批次号'),
|
||||
ColDef('warehouse', '仓库'),
|
||||
ColDef('qty', '库存量'),
|
||||
ColDef('price', '单价'),
|
||||
ColDef('prodDate', '生产日期'),
|
||||
ColDef('inTime', '入库时间'),
|
||||
ColDef('supplier', '供应商'),
|
||||
ColDef('remark', '备注'),
|
||||
ColDef('status', '状态'),
|
||||
ColDef('actions', '操作', required: true),
|
||||
];
|
||||
|
||||
|
||||
@override
|
||||
@@ -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) {
|
||||
return MobileListCard(
|
||||
@@ -392,6 +495,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
? items
|
||||
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
|
||||
|
||||
final visibleCols =
|
||||
_colDefs.where((c) => !_hiddenCols.contains(c.key)).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Summary cards(窄屏横向滚动,宽屏等分)
|
||||
@@ -481,6 +587,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
@@ -503,68 +615,48 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
),
|
||||
mobileCards:
|
||||
filteredItems.map(_inventoryCard).toList(),
|
||||
columns: [
|
||||
const DataColumn(label: Text('商品编码')),
|
||||
const DataColumn(label: Text('商品名称')),
|
||||
DataColumn(
|
||||
label: FilterableColumnHeader(
|
||||
text: '规格',
|
||||
options: specOptions,
|
||||
selected: _filterSpec,
|
||||
onChanged: (v) {
|
||||
setState(() => _filterSpec = v);
|
||||
ref.read(inventoryListProvider.notifier).setSpec(v.toList());
|
||||
},
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: FilterableColumnHeader(
|
||||
text: '系列',
|
||||
options: seriesOptions,
|
||||
selected: _filterSeries,
|
||||
onChanged: (v) {
|
||||
setState(() => _filterSeries = v);
|
||||
ref.read(inventoryListProvider.notifier).setSeries(v.toList());
|
||||
},
|
||||
),
|
||||
),
|
||||
const DataColumn(label: Text('批次号')),
|
||||
DataColumn(
|
||||
label: FilterableColumnHeader(
|
||||
text: '仓库',
|
||||
options: warehouseOptions,
|
||||
selected: _filterWarehouse,
|
||||
onChanged: (v) => setState(() => _filterWarehouse = v),
|
||||
),
|
||||
),
|
||||
const DataColumn(label: Text('库存量'), numeric: true),
|
||||
const DataColumn(label: Text('单价'), numeric: true),
|
||||
const DataColumn(label: Text('生产日期')),
|
||||
const DataColumn(label: Text('入库时间')),
|
||||
const DataColumn(label: Text('供应商')),
|
||||
const DataColumn(label: Text('备注')),
|
||||
const DataColumn(label: Text('状态')),
|
||||
const DataColumn(label: Text('操作')),
|
||||
],
|
||||
columns: visibleCols.map((c) {
|
||||
final label = switch (c.key) {
|
||||
'spec' => FilterableColumnHeader(
|
||||
text: c.label,
|
||||
options: specOptions,
|
||||
selected: _filterSpec,
|
||||
onChanged: (v) {
|
||||
setState(() => _filterSpec = v);
|
||||
ref.read(inventoryListProvider.notifier).setSpec(v.toList());
|
||||
},
|
||||
),
|
||||
'series' => FilterableColumnHeader(
|
||||
text: c.label,
|
||||
options: seriesOptions,
|
||||
selected: _filterSeries,
|
||||
onChanged: (v) {
|
||||
setState(() => _filterSeries = v);
|
||||
ref.read(inventoryListProvider.notifier).setSeries(v.toList());
|
||||
},
|
||||
),
|
||||
'warehouse' => FilterableColumnHeader(
|
||||
text: c.label,
|
||||
options: warehouseOptions,
|
||||
selected: _filterWarehouse,
|
||||
onChanged: (v) => setState(() => _filterWarehouse = v),
|
||||
),
|
||||
_ => Text(c.label),
|
||||
};
|
||||
return DataColumn(
|
||||
label: label,
|
||||
numeric: c.key == 'qty' || c.key == 'price',
|
||||
);
|
||||
}).toList(),
|
||||
rows: items.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无库存数据',
|
||||
DataRow(cells: [
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(Text('暂无库存数据',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
for (int i = 2; i < visibleCols.length; i++)
|
||||
const DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: filteredItems
|
||||
@@ -572,109 +664,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
color: WidgetStateProperty.resolveWith(
|
||||
(states) {
|
||||
if (item.quantity == 0) {
|
||||
return AppTheme.danger.withOpacity(0.04);
|
||||
return AppTheme.danger.withValues(alpha: 0.04);
|
||||
}
|
||||
if (item.minStock != null &&
|
||||
item.quantity < item.minStock!) {
|
||||
return AppTheme.accent.withOpacity(0.04);
|
||||
return AppTheme.accent.withValues(alpha: 0.04);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
cells: [
|
||||
DataCell(Text(
|
||||
item.productCode.isEmpty ? '-' : item.productCode,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(item.productId != null
|
||||
? GestureDetector(
|
||||
onTap: () => context.push('/products/${item.productId}'),
|
||||
child: SizedBox(
|
||||
width: 180,
|
||||
child: Text(
|
||||
item.productName.isEmpty ? '-' : item.productName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(item.productName.isEmpty ? '-' : item.productName,
|
||||
overflow: TextOverflow.ellipsis)),
|
||||
DataCell(Text(item.spec.isEmpty ? '-' : item.spec)),
|
||||
DataCell(Text(item.series.isEmpty ? '-' : item.series)),
|
||||
DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
|
||||
DataCell(Text(
|
||||
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: item.quantity == 0
|
||||
? AppTheme.danger
|
||||
: (item.minStock != null &&
|
||||
item.quantity <
|
||||
item.minStock!)
|
||||
? AppTheme.accent
|
||||
: AppTheme.textPrimary),
|
||||
)),
|
||||
DataCell(Text(
|
||||
item.unitPrice != null
|
||||
? '¥${item.unitPrice!.toStringAsFixed(2)}'
|
||||
: '-',
|
||||
)),
|
||||
DataCell(Text(item.productionDate ?? '-')),
|
||||
DataCell(Text(
|
||||
item.createdAt != null && item.createdAt!.length >= 10
|
||||
? item.createdAt!.substring(0, 10)
|
||||
: '-',
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||
)),
|
||||
DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)),
|
||||
DataCell(
|
||||
Tooltip(
|
||||
message: item.remark.isEmpty ? '' : item.remark,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: GestureDetector(
|
||||
onTap: () => _editRemark(context, item),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.remark.isEmpty
|
||||
? '—'
|
||||
: item.remark.length > 4
|
||||
? '${item.remark.substring(0, 4)}…'
|
||||
: item.remark,
|
||||
style: TextStyle(
|
||||
color: item.remark.isEmpty
|
||||
? AppTheme.textSecondary
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.edit_outlined,
|
||||
size: 12, color: AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(_InventoryStatusBadge(item)),
|
||||
DataCell(
|
||||
item.productId != null
|
||||
? TextButton(
|
||||
onPressed: () => _printLabel(context, item),
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
],
|
||||
cells: visibleCols
|
||||
.map((c) => _buildInventoryCell(c.key, item, context))
|
||||
.toList(),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
@@ -54,6 +55,13 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
||||
);
|
||||
final data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||
setState(() { _data = data; _loading = false; });
|
||||
// 更新浏览器标签标题(Flutter Web 会写 document.title)
|
||||
final name = data['name'] as String? ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
SystemChrome.setApplicationSwitcherDescription(
|
||||
ApplicationSwitcherDescription(label: name, primaryColor: 0xFF8B2331),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() { _error = e.toString(); _loading = false; });
|
||||
}
|
||||
|
||||
@@ -36,10 +36,14 @@ class DataTableCard extends StatefulWidget {
|
||||
class _DataTableCardState extends State<DataTableCard> {
|
||||
// 当前悬停行的索引(-1 表示无悬停)
|
||||
final _hoveredRow = ValueNotifier<int>(-1);
|
||||
final _vCtrl = ScrollController();
|
||||
final _hCtrl = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hoveredRow.dispose();
|
||||
_vCtrl.dispose();
|
||||
_hCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -89,24 +93,35 @@ class _DataTableCardState extends State<DataTableCard> {
|
||||
|
||||
Widget _buildTable() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
builder: (context, constraints) => Scrollbar(
|
||||
controller: _vCtrl,
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
border: TableBorder(
|
||||
horizontalInside: BorderSide(
|
||||
color: Colors.grey.shade200,
|
||||
width: 0.5,
|
||||
controller: _vCtrl,
|
||||
child: Scrollbar(
|
||||
controller: _hCtrl,
|
||||
thumbVisibility: true,
|
||||
scrollbarOrientation: ScrollbarOrientation.bottom,
|
||||
child: SingleChildScrollView(
|
||||
controller: _hCtrl,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
border: TableBorder(
|
||||
horizontalInside: BorderSide(
|
||||
color: Colors.grey.shade200,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
_buildHeaderRow(),
|
||||
for (int i = 0; i < widget.rows.length; i++)
|
||||
_buildDataRow(i, widget.rows[i]),
|
||||
],
|
||||
),
|
||||
),
|
||||
children: [
|
||||
_buildHeaderRow(),
|
||||
for (int i = 0; i < widget.rows.length; i++)
|
||||
_buildDataRow(i, widget.rows[i]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import charset_converter
|
||||
import file_picker
|
||||
import file_selector_macos
|
||||
import package_info_plus
|
||||
@@ -13,6 +14,7 @@ import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
CharsetConverterPlugin.register(with: registry.registrar(forPlugin: "CharsetConverterPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
|
||||
@@ -57,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
charset_converter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: charset_converter
|
||||
sha256: af00b5b73b367101c661b8dfaa52abf3e533de7b3e247e3f65c535ebc4df820a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -29,6 +29,8 @@ dependencies:
|
||||
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
|
||||
win32: ^5.5.0
|
||||
ffi: ^2.1.3
|
||||
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
|
||||
charset_converter: ^2.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <charset_converter/charset_converter_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
CharsetConverterPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("CharsetConverterPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
charset_converter
|
||||
file_selector_windows
|
||||
printing
|
||||
url_launcher_windows
|
||||
|
||||
+15
-3
@@ -47,10 +47,22 @@ server {
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理
|
||||
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html
|
||||
location ~ ^/product/ {
|
||||
root /opt/jiu/web;
|
||||
try_files $uri /app/index.html;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Flutter 路由会把 /product/:id 重写为 /app/product/:id(base-href=/app/)
|
||||
# 分享此 URL 时微信爬虫也需要 OG 标签 → 去掉 /app 前缀后转发后端
|
||||
location ~ ^/app/product/ {
|
||||
rewrite ^/app(/product/.+)$ $1 break;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# 桌面客户端安装包下载(Windows .exe / macOS .zip)
|
||||
|
||||
@@ -9,4 +9,5 @@ LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
|
||||
STORAGE_UPLOAD_DIR=/opt/jiu/images
|
||||
STORAGE_BASE_URL=https://jiu.51yanmei.com/images
|
||||
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
|
||||
STORAGE_WEB_DIR=/opt/jiu/web
|
||||
DB_PASSWORD=CHANGE_ME_DB_PASS
|
||||
|
||||
Reference in New Issue
Block a user