Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 146444ccc3 | |||
| 2ca65e8077 | |||
| bf36f7aa3f | |||
| 097e466372 | |||
| c1ba6c6d97 | |||
| 2b327cd365 | |||
| 07f8973477 | |||
| 3b02a848c0 | |||
| bde2d35756 | |||
| 4ac5adac3e | |||
| 8c2dcf49a8 | |||
| f400459497 | |||
| 64ac17d6b5 | |||
| a5bdb79f81 | |||
| c4b4259a3d |
@@ -5,6 +5,84 @@ 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.53] - 2026-06-15
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)
|
||||||
|
|
||||||
|
## [1.0.52] - 2026-06-15
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题
|
||||||
|
|
||||||
|
## [1.0.51] - 2026-06-15
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 标签打印字体细化:阈值调回 128,店名改细体,商品名保持粗体
|
||||||
|
|
||||||
|
## [1.0.50] - 2026-06-15
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 标签打印字体更清晰:采用 2× 超采样渲染,消除热敏打印时反锯齿导致的模糊笔划
|
||||||
|
|
||||||
|
## [1.0.49] - 2026-06-15
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 二维码链接统一使用 `/app/product/` 路径,扫码后 URL 不再跳变,分享与打开同一个链接
|
||||||
|
|
||||||
|
## [1.0.48] - 2026-06-15
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 修复分享 `/app/product/` 路径时微信爬虫拿不到 OG 标签的问题(nginx 补充该路径的代理规则)
|
||||||
|
- 分享卡片描述规格/系列与门店名分两行显示
|
||||||
|
|
||||||
|
## [1.0.47] - 2026-06-15
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 微信分享卡片描述补充度数/系列信息,不再只显示规格
|
||||||
|
|
||||||
|
## [1.0.46] - 2026-06-15
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 微信内打开商品页时顶部标题栏显示商品名称,不再显示"岩美"
|
||||||
|
|
||||||
|
## [1.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
|
## [1.0.39] - 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", "./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")
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
|
|||||||
h.db.Model(&product).Update("public_id", product.PublicID)
|
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 != "" {
|
if product.Code != "" {
|
||||||
url += "?code=" + product.Code
|
url += "?code=" + product.Code
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
"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 +278,110 @@ 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
|
||||||
|
// 用正则替换避免缩进空格导致字符串不匹配
|
||||||
|
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)
|
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")
|
||||||
|
|
||||||
// 公开路由(无需登录)
|
// 公开路由(无需登录)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'dart:ffi';
|
import 'dart:ffi';
|
||||||
|
import 'package:charset_converter/charset_converter.dart';
|
||||||
import 'package:ffi/ffi.dart';
|
import 'package:ffi/ffi.dart';
|
||||||
import 'package:win32/win32.dart'
|
import 'package:win32/win32.dart'
|
||||||
show
|
show
|
||||||
@@ -13,7 +14,8 @@ import 'package:win32/win32.dart'
|
|||||||
EndPagePrinter,
|
EndPagePrinter,
|
||||||
EndDocPrinter,
|
EndDocPrinter,
|
||||||
ClosePrinter,
|
ClosePrinter,
|
||||||
DOC_INFO_1;
|
DOC_INFO_1,
|
||||||
|
WideCharToMultiByte;
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
|
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../errors/error_reporter.dart';
|
import '../errors/error_reporter.dart';
|
||||||
@@ -189,19 +191,24 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
|||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||||
|
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
|
||||||
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
||||||
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,
|
||||||
}) async {
|
}) 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 recorder = ui.PictureRecorder();
|
||||||
final canvas = ui.Canvas(
|
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()),
|
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
|
||||||
ui.Paint()..color = const ui.Color(0xFFFFFFFF));
|
ui.Paint()..color = const ui.Color(0xFFFFFFFF));
|
||||||
|
|
||||||
@@ -223,36 +230,95 @@ 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, false), // 店名细体,突出商品名
|
||||||
(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(sw, sh); // 返回 2× 图(640×320)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
/// UTF-8 → GBK 字节:
|
||||||
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
|
/// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件;
|
||||||
|
/// macOS 走 charset_converter(CoreFoundation 原生支持 GBK)。
|
||||||
|
Uint8List _encodeGbk(String text) {
|
||||||
|
if (text.isEmpty) return Uint8List(0);
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
final wideStr = text.toNativeUtf16();
|
||||||
|
try {
|
||||||
|
final needed = WideCharToMultiByte(936, 0, wideStr, -1, nullptr, 0, nullptr, nullptr);
|
||||||
|
if (needed <= 1) return Uint8List(0);
|
||||||
|
final buf = calloc<Uint8>(needed);
|
||||||
|
try {
|
||||||
|
WideCharToMultiByte(936, 0, wideStr, -1, buf.cast(), needed, nullptr, nullptr);
|
||||||
|
return Uint8List.fromList(buf.asTypedList(needed - 1)); // 不含 null 终止符
|
||||||
|
} finally {
|
||||||
|
calloc.free(buf);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
calloc.free(wideStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// macOS: charset_converter(同步 CoreFoundation)
|
||||||
|
// encode() 在 macOS 上是同步 channel 调用,此处以 Future.value 包装保持接口一致
|
||||||
|
throw UnsupportedError('call _encodeGbkAsync on macOS');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _encodeGbkAsync(String text) async {
|
||||||
|
if (text.isEmpty) return Uint8List(0);
|
||||||
|
if (Platform.isWindows) return _encodeGbk(text);
|
||||||
|
return CharsetConverter.encode('GBK', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。
|
||||||
|
/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。
|
||||||
|
Future<void> _addTextCmd(
|
||||||
|
BytesBuilder buf, int x, int y, String font, String text) async {
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
final gbk = await _encodeGbkAsync(text);
|
||||||
|
buf.add(ascii.encode('TEXT $x,$y,"$font",0,1,1,"'));
|
||||||
|
buf.add(gbk);
|
||||||
|
buf.add(ascii.encode('"\r\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 桌面端把标签以 TSPL TEXT(内置中文点阵字体)+ QR BITMAP 混合方式裸发到热敏机。
|
||||||
|
/// TEXT 走 TSS24.BF2/TSS16.BF2(GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。
|
||||||
|
/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。
|
||||||
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
||||||
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
|
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
|
||||||
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,
|
||||||
@@ -263,34 +329,79 @@ Future<bool> _printFlatLabelThermal({
|
|||||||
debugPrint('[label] thermal printer = $printer');
|
debugPrint('[label] thermal printer = $printer');
|
||||||
if (printer == null) return false;
|
if (printer == null) return false;
|
||||||
|
|
||||||
const w = 320, h = 160;
|
// QR BITMAP: 124×124 dots, 右侧 x=192, y=8
|
||||||
final img = await _renderLabelBitmap(
|
const qrW = 124, qrH = 124;
|
||||||
shop: shop, name: name, degSpec: degSpec, date: date, qrBytes: qrBytes);
|
const qrX = 192, qrY = 8;
|
||||||
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
|
||||||
final rgba = bd!.buffer.asUint8List();
|
|
||||||
|
|
||||||
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
|
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
|
||||||
const wbytes = (w + 7) >> 3;
|
final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
|
||||||
final out = BytesBuilder();
|
final qrImg = (await codec.getNextFrame()).image;
|
||||||
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
|
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||||
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n'
|
final qrRgba = qrBd!.buffer.asUint8List();
|
||||||
'BITMAP 0,0,$wbytes,$h,1,'));
|
|
||||||
for (int yy = 0; yy < h; yy++) {
|
final buf = BytesBuilder();
|
||||||
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
|
buf.add(ascii.encode(
|
||||||
for (int xx = 0; xx < w; xx++) {
|
'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
|
||||||
final i = (yy * w + xx) * 4;
|
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
|
||||||
final a = rgba[i + 3];
|
));
|
||||||
|
|
||||||
|
// 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
|
final lum = a < 128
|
||||||
? 255.0
|
? 255.0
|
||||||
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
|
: 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
|
||||||
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
|
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'));
|
buf.add(ascii.encode('\r\n'));
|
||||||
final tspl = out.toBytes();
|
|
||||||
|
|
||||||
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);
|
await _sendRaw(printer, tspl);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -323,7 +434,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 +443,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 +510,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 +591,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 +600,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!,
|
||||||
|
|||||||
@@ -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,109 +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: () => _printLabel(context, item),
|
|
||||||
child: const Text('打标签',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
|
||||||
)
|
|
||||||
: const SizedBox(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../core/config/app_config.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>;
|
final data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||||
setState(() { _data = data; _loading = false; });
|
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) {
|
} catch (e) {
|
||||||
setState(() { _error = e.toString(); _loading = false; });
|
setState(() { _error = e.toString(); _loading = false; });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import charset_converter
|
||||||
import file_picker
|
import file_picker
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
@@ -13,6 +14,7 @@ import shared_preferences_foundation
|
|||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
CharsetConverterPlugin.register(with: registry.registrar(forPlugin: "CharsetConverterPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
|||||||
+12
-4
@@ -57,6 +57,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -513,10 +521,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "2.1.6"
|
||||||
path_provider_android:
|
path_provider_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -545,10 +553,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
path_provider_windows:
|
path_provider_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
name: jiu_client
|
name: jiu_client
|
||||||
description: 酒库管理系统 - 酒店仓库管理
|
description: 酒库管理系统 - 酒店仓库管理
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.3+1
|
version: 1.0.52+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -29,6 +29,8 @@ dependencies:
|
|||||||
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
|
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
|
||||||
win32: ^5.5.0
|
win32: ^5.5.0
|
||||||
ffi: ^2.1.3
|
ffi: ^2.1.3
|
||||||
|
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
|
||||||
|
charset_converter: ^2.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -6,11 +6,14 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <charset_converter/charset_converter_plugin.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <printing/printing_plugin.h>
|
#include <printing/printing_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
CharsetConverterPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("CharsetConverterPlugin"));
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
PrintingPluginRegisterWithRegistrar(
|
PrintingPluginRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
charset_converter
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
printing
|
printing
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
|
|||||||
+15
-3
@@ -47,10 +47,22 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
# 桌面客户端安装包下载(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