Compare commits

...

2 Commits

Author SHA1 Message Date
wangjia a5bdb79f81 chore: release v1.0.40
Deploy / build-linux-web (push) Successful in 48s
Deploy / build-windows (push) Successful in 1m41s
Deploy / build-macos (push) Successful in 1m22s
Deploy / build-android (push) Successful in 1m9s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m49s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:23:03 +08:00
wangjia c4b4259a3d feat(client): 表格加横向滚动条 + 库存页列显隐
- DataTableCard 横向/纵向 ScrollView 各加 Scrollbar(thumbVisibility:true),
  Windows 鼠标用户可拖动滚动条查看右侧截断的列(6 个表格全受益)
- 库存查询页新增「显示字段」按钮,可按需隐藏生产日期/供应商/备注等列,
  列变少后横向溢出大幅缓解;商品编码/名称/操作三列锁定不可隐藏

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 12:35:57 +08:00
9 changed files with 311 additions and 175 deletions
+9
View File
@@ -5,6 +5,15 @@ 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.40] - 2026-06-15
### 新功能
- 商品标签打印新增编号行(显示「编号:xxxx」),位于度数上方,方便核对入库单
- 扫码分享商品链接时,微信/企业微信/飞书/钉钉等平台的分享卡片现可显示商品名、规格描述及商品图片,不再只显示「岩美」
### 改进
- 库存页表格支持横向滚动,列过多时不再截断;支持显示/隐藏列,按需定制显示内容
## [1.0.39] - 2026-06-15 ## [1.0.39] - 2026-06-15
### 修复 ### 修复
+3
View File
@@ -43,6 +43,7 @@ type StorageConfig struct {
UploadDir string `mapstructure:"upload_dir"` UploadDir string `mapstructure:"upload_dir"`
BaseURL string `mapstructure:"base_url"` BaseURL string `mapstructure:"base_url"`
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码 PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入
} }
var C Config var C Config
@@ -66,6 +67,7 @@ func Load() {
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR") _ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL") _ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL") _ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
// 默认值 // 默认值
viper.SetDefault("server.port", "8080") viper.SetDefault("server.port", "8080")
@@ -78,6 +80,7 @@ func Load() {
viper.SetDefault("storage.upload_dir", "./uploads/images") viper.SetDefault("storage.upload_dir", "./uploads/images")
viper.SetDefault("storage.base_url", "http://localhost:8080/images") viper.SetDefault("storage.base_url", "http://localhost:8080/images")
viper.SetDefault("storage.public_url", "http://localhost:8081") viper.SetDefault("storage.public_url", "http://localhost:8081")
viper.SetDefault("storage.web_dir", "./client/build/web")
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file found, using defaults and env vars") log.Println("[config] no config file found, using defaults and env vars")
+91
View File
@@ -3,13 +3,16 @@ package handler
import ( import (
"errors" "errors"
"fmt" "fmt"
"html"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/model"
) )
@@ -274,3 +277,91 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
"page_size": pageSize, "page_size": pageSize,
}) })
} }
// ProductPage GET /product/:public_id
// 返回注入了基础 Open Graph 标签的 Flutter index.html,供微信/飞书等社交平台爬虫生成分享卡片。
// 找不到商品时原样返回 index.html,让 Flutter 自行展示"商品不存在";爬虫拿不到 OG 标签但页面不报错。
func (h *PublicHandler) ProductPage(c *gin.Context) {
publicID := c.Param("public_id")
// 读取 Flutter 构建产物 index.html
idxPath := config.C.Storage.WebDir + "/index.html"
idxBytes, err := os.ReadFile(idxPath)
if err != nil {
c.String(http.StatusInternalServerError, "index.html not found: %s", idxPath)
return
}
idxHTML := string(idxBytes)
// 查商品(只取 OG 所需字段,轻量查询)
var product model.Product
if err := h.db.Select("id, public_id, name, brand, series, spec, shop_id").
Where("public_id = ? AND deleted_at IS NULL", publicID).
Preload("Images").
First(&product).Error; err != nil {
// 查不到商品:原样返回 index.html,让 Flutter 展示"商品不存在"
c.Data(http.StatusOK, "text/html; charset=utf-8", idxBytes)
return
}
// 查门店名
var shop model.Shop
shopName := ""
if err := h.db.Select("name").Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
shopName = shop.Name
}
// 构造 OG 标签并注入 </head> 前
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
out := strings.Replace(idxHTML, "</head>", ogTags+"</head>", 1)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out))
}
// buildProductOG 构造基础 Open Graph meta 标签字符串。
// 所有值经 html.EscapeString 转义,防止 XSS 或破坏 HTML 结构。
// 无图片时不输出 og:image 行。
func buildProductOG(product model.Product, shopName, publicURL string) string {
// og:title:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列
title := product.Name
if product.Brand != "" && !strings.Contains(product.Name, product.Brand) {
title = product.Brand + title
}
if product.Series != "" {
title = title + "" + product.Series + ""
}
// og:description:规格(度数/香型/容量)| 门店名正品 · 扫码验真
desc := product.Spec
suffix := "正品 · 扫码验真"
if shopName != "" {
suffix = shopName + suffix
}
if desc != "" {
desc = desc + " | " + suffix
} else {
desc = suffix
}
// og:site_name
siteName := "岩美酒库"
if shopName != "" {
siteName = shopName + " · " + siteName
}
// og:url
pageURL := publicURL + "/product/" + product.PublicID
var sb strings.Builder
sb.WriteString("\n")
sb.WriteString(` <meta property="og:type" content="website">` + "\n")
sb.WriteString(` <meta property="og:site_name" content="` + html.EscapeString(siteName) + `">` + "\n")
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(pageURL) + `">` + "\n")
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(title) + `">` + "\n")
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(desc) + `">` + "\n")
// og:image:仅有图片时输出
if len(product.Images) > 0 && product.Images[0].URL != "" {
imgURL := publicURL + product.Images[0].URL
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(imgURL) + `">` + "\n")
}
return sb.String()
}
+3
View File
@@ -45,6 +45,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
// 版本信息(无需认证,用于客户端更新检查) // 版本信息(无需认证,用于客户端更新检查)
r.GET("/version", handler.GetVersion) r.GET("/version", handler.GetVersion)
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
r.GET("/product/:public_id", publicH.ProductPage)
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
// 公开路由(无需登录) // 公开路由(无需登录)
+13 -1
View File
@@ -194,6 +194,7 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
Future<ui.Image> _renderLabelBitmap({ Future<ui.Image> _renderLabelBitmap({
required String shop, required String shop,
required String name, required String name,
required String code,
required String degSpec, required String degSpec,
required String date, required String date,
required Uint8List qrBytes, required Uint8List qrBytes,
@@ -226,9 +227,11 @@ Future<ui.Image> _renderLabelBitmap({
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充 // 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充
const lx = 22.0; const lx = 22.0;
const textMaxW = qrLeft - lx - 6; // 164 const textMaxW = qrLeft - lx - 6; // 164
final codeText = code.isNotEmpty ? '编号:$code' : '';
final rows = <(String, double, bool)>[ final rows = <(String, double, bool)>[
(shop, 0.9, true), (shop, 0.9, true),
(name, 1.5, true), (name, 1.5, true),
if (codeText.isNotEmpty) (codeText, 1.0, false),
if (degSpec.isNotEmpty) (degSpec, 1.05, false), if (degSpec.isNotEmpty) (degSpec, 1.05, false),
if (date.isNotEmpty) (date, 1.0, false), if (date.isNotEmpty) (date, 1.0, false),
]; ];
@@ -253,6 +256,7 @@ Future<ui.Image> _renderLabelBitmap({
Future<bool> _printFlatLabelThermal({ Future<bool> _printFlatLabelThermal({
required String shop, required String shop,
required String name, required String name,
required String code,
required String degSpec, required String degSpec,
required String date, required String date,
required Uint8List qrBytes, required Uint8List qrBytes,
@@ -265,7 +269,7 @@ Future<bool> _printFlatLabelThermal({
const w = 320, h = 160; const w = 320, h = 160;
final img = await _renderLabelBitmap( final img = await _renderLabelBitmap(
shop: shop, name: name, degSpec: degSpec, date: date, qrBytes: qrBytes); shop: shop, name: name, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes);
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba); final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List(); final rgba = bd!.buffer.asUint8List();
@@ -332,6 +336,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 +403,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)),
@@ -482,6 +493,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(),
), ),
+30 -15
View File
@@ -36,10 +36,14 @@ class DataTableCard extends StatefulWidget {
class _DataTableCardState extends State<DataTableCard> { class _DataTableCardState extends State<DataTableCard> {
// 当前悬停行的索引(-1 表示无悬停) // 当前悬停行的索引(-1 表示无悬停)
final _hoveredRow = ValueNotifier<int>(-1); final _hoveredRow = ValueNotifier<int>(-1);
final _vCtrl = ScrollController();
final _hCtrl = ScrollController();
@override @override
void dispose() { void dispose() {
_hoveredRow.dispose(); _hoveredRow.dispose();
_vCtrl.dispose();
_hCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -89,24 +93,35 @@ class _DataTableCardState extends State<DataTableCard> {
Widget _buildTable() { Widget _buildTable() {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView( builder: (context, constraints) => Scrollbar(
controller: _vCtrl,
thumbVisibility: true,
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, controller: _vCtrl,
child: ConstrainedBox( child: Scrollbar(
constraints: BoxConstraints(minWidth: constraints.maxWidth), controller: _hCtrl,
child: Table( thumbVisibility: true,
defaultColumnWidth: const IntrinsicColumnWidth(), scrollbarOrientation: ScrollbarOrientation.bottom,
border: TableBorder( child: SingleChildScrollView(
horizontalInside: BorderSide( controller: _hCtrl,
color: Colors.grey.shade200, scrollDirection: Axis.horizontal,
width: 0.5, child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
border: TableBorder(
horizontalInside: BorderSide(
color: Colors.grey.shade200,
width: 0.5,
),
),
children: [
_buildHeaderRow(),
for (int i = 0; i < widget.rows.length; i++)
_buildDataRow(i, widget.rows[i]),
],
), ),
), ),
children: [
_buildHeaderRow(),
for (int i = 0; i < widget.rows.length; i++)
_buildDataRow(i, widget.rows[i]),
],
), ),
), ),
), ),
+5 -3
View File
@@ -47,10 +47,12 @@ server {
expires 0; expires 0;
} }
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理 # 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html
location ~ ^/product/ { location ~ ^/product/ {
root /opt/jiu/web; proxy_pass http://127.0.0.1:8080;
try_files $uri /app/index.html; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
} }
# 桌面客户端安装包下载(Windows .exe / macOS .zip # 桌面客户端安装包下载(Windows .exe / macOS .zip
+1
View File
@@ -9,4 +9,5 @@ LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
STORAGE_UPLOAD_DIR=/opt/jiu/images STORAGE_UPLOAD_DIR=/opt/jiu/images
STORAGE_BASE_URL=https://jiu.51yanmei.com/images STORAGE_BASE_URL=https://jiu.51yanmei.com/images
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
STORAGE_WEB_DIR=/opt/jiu/web
DB_PASSWORD=CHANGE_ME_DB_PASS DB_PASSWORD=CHANGE_ME_DB_PASS