feat(client): Phase 2 库存屏照原型重建(去tab/KPI货值/库存·成本价·单价列)

照原型 inventory.html 把库存屏从「3 tab」重排为单视图:
- 去 3 tab(库存查询/预警/流水)→ 单视图;预警并入 KPI 缺货卡点击筛选;流水移商品详情
- 头部:库存管理 + 共 N 个 SKU·数据实时 + 列设置/导出(盘点/新增入库去掉,走专门菜单)
- KPI 4 卡走全店汇总接口:SKU总数/库存货值(Σqty×进价)/在库数量/缺货预警(可点筛选)
- 表格 12 列:加 库存(低库存染色)/成本价(进价)/单价(售价);操作列只「查看详情」;状态圆点
- 仓库/备注默认隐藏;工具栏 搜索+状态筛选+重置
- 后端 GET /inventory/summary 全店汇总(守 shop_id);前端 model/repository/provider 配套
- golden_harness:monospace 指向 Noto(消等宽数字黑块,全 golden 受益);库存屏存量 grey 清零

后端编译/库存测试通过;前端 analyze 0 error,flutter test 242 通过;代码端颜色闸 0 违规。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
wangjia
2026-06-25 20:40:54 +08:00
parent ff45100eed
commit 8d3f902f3b
42 changed files with 286 additions and 113 deletions
+31
View File
@@ -160,6 +160,37 @@ func (h *InventoryHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize}) c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
} }
// inventorySummary 全店库存 KPI 汇总(不随分页,供库存屏 KPI 卡)
type inventorySummary struct {
SkuCount int64 `json:"sku_count"` // SKU 总数(库存行数)
StockValue float64 `json:"stock_value"` // 库存货值 Σ(qty×进价)
InStockQty float64 `json:"in_stock_qty"` // 在库总数量
ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0
WarningCount int64 `json:"warning_count"` // 预警数(0<qty<安全库存)
}
// Summary GET /api/v1/inventory/summary —— 全店汇总(守多租户)
func (h *InventoryHandler) Summary(c *gin.Context) {
shopID := middleware.GetShopID(c)
const sql = `
SELECT
COUNT(*) AS sku_count,
COALESCE(SUM(inv.quantity * COALESCE(sii.unit_price, inv.unit_price, p.purchase_price)), 0) AS stock_value,
COALESCE(SUM(inv.quantity), 0) AS in_stock_qty,
COALESCE(SUM(CASE WHEN inv.quantity <= 0 THEN 1 ELSE 0 END), 0) AS shortage_count,
COALESCE(SUM(CASE WHEN inv.quantity > 0 AND p.min_stock IS NOT NULL AND inv.quantity < p.min_stock THEN 1 ELSE 0 END), 0) AS warning_count
FROM inventories inv
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL
WHERE inv.shop_id = ? AND inv.deleted_at IS NULL`
var s inventorySummary
if err := h.db.Raw(sql, shopID).Scan(&s).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
c.JSON(http.StatusOK, s)
}
// Logs GET /api/v1/inventory/logs // Logs GET /api/v1/inventory/logs
func (h *InventoryHandler) Logs(c *gin.Context) { func (h *InventoryHandler) Logs(c *gin.Context) {
shopID := middleware.GetShopID(c) shopID := middleware.GetShopID(c)
+1
View File
@@ -183,6 +183,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
inventory := api.Group("/inventory") inventory := api.Group("/inventory")
{ {
inventory.GET("", inventoryH.List) inventory.GET("", inventoryH.List)
inventory.GET("/summary", inventoryH.Summary)
inventory.GET("/logs", inventoryH.Logs) inventory.GET("/logs", inventoryH.Logs)
inventory.PUT("/:id/remark", inventoryH.UpdateRemark) inventory.PUT("/:id/remark", inventoryH.UpdateRemark)
inventory.POST("/checks", inventoryH.CreateCheck) inventory.POST("/checks", inventoryH.CreateCheck)
+23
View File
@@ -72,6 +72,29 @@ class Inventory {
); );
} }
/// 全店库存 KPI 汇总(GET /inventory/summary,不随分页)。
class InventorySummary {
final int skuCount;
final double stockValue;
final double inStockQty;
final int shortageCount;
final int warningCount;
const InventorySummary({
this.skuCount = 0,
this.stockValue = 0,
this.inStockQty = 0,
this.shortageCount = 0,
this.warningCount = 0,
});
factory InventorySummary.fromJson(Map<String, dynamic> j) => InventorySummary(
skuCount: (j['sku_count'] as num?)?.toInt() ?? 0,
stockValue: (j['stock_value'] as num?)?.toDouble() ?? 0,
inStockQty: (j['in_stock_qty'] as num?)?.toDouble() ?? 0,
shortageCount: (j['shortage_count'] as num?)?.toInt() ?? 0,
warningCount: (j['warning_count'] as num?)?.toInt() ?? 0,
);
}
class InventoryLog { class InventoryLog {
final int warehouseId; final int warehouseId;
final String? warehouseName; final String? warehouseName;
@@ -16,6 +16,14 @@ final inventoryListProvider =
InventoryListNotifier.new, InventoryListNotifier.new,
); );
/// 全店库存 KPI 汇总(不随分页;换店/网络恢复自动刷新)。
final inventorySummaryProvider =
FutureProvider.autoDispose<InventorySummary>((ref) {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider);
return ref.read(inventoryRepositoryProvider).getSummary();
});
class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> { class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
int _page = 1; int _page = 1;
int _pageSize = AppConstants.defaultPageSize; int _pageSize = AppConstants.defaultPageSize;
@@ -41,6 +41,18 @@ class InventoryRepository {
} }
} }
Future<InventorySummary> getSummary() async {
try {
final resp = await _client.get('/inventory/summary');
return InventorySummary.fromJson(resp.data as Map<String, dynamic>);
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '获取库存汇总失败',
statusCode: e.response?.statusCode,
);
}
}
Future<Map<String, dynamic>> createCheck(Map<String, dynamic> data) async { Future<Map<String, dynamic>> createCheck(Map<String, dynamic> data) async {
try { try {
final resp = await _client.post('/inventory/checks', data: data); final resp = await _client.post('/inventory/checks', data: data);
@@ -42,18 +42,23 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Set<String> _filterWarehouse = {}; Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {}; Set<String> _filterSpec = {};
Set<String> _filterSeries = {}; Set<String> _filterSeries = {};
// 库存状态前端筛选(派生自 qty vs 安全库存):全部/在售/预警/缺货
String _statusFilter = '全部';
Set<String>? _hiddenCols; // null = 尚未载入本地存档 Set<String>? _hiddenCols; // null = 尚未载入本地存档
bool _mobileTableView = false; // 移动端:false=卡片列表,true=表格(横向滚动) bool _mobileTableView = false; // 移动端:false=卡片列表,true=表格(横向滚动)
static const _screenId = 'inventory_list'; static const _screenId = 'inventory_list';
// 列序照原型重排;商品 = name+code 合并两行 // 列序照原型 inventory.html:商品/规格/系列/批次/库存/成本价/单价/生产/入库/供应商/状态/操作
// 库存量/成本价列按项目铁律不展示(装箱数脏数据 + 进价敏感);仓库/备注作可隐藏列 // 成本价=入库进价(unitPrice),单价=售价(salePrice);库存列照原型显示(脏数据另清)
// 仓库/备注原型没有 → 默认隐藏的可选列(列设置可开)。
static const _colDefs = [ static const _colDefs = [
ColDef('product', '商品', required: true), ColDef('product', '商品', required: true),
ColDef('spec', '规格'), ColDef('spec', '规格'),
ColDef('series', '系列'), ColDef('series', '系列'),
ColDef('batch', '批次号'), ColDef('batch', '批次号'),
ColDef('qty', '库存'),
ColDef('cost', '成本价'),
ColDef('price', '单价'), ColDef('price', '单价'),
ColDef('prodDate', '生产日期'), ColDef('prodDate', '生产日期'),
ColDef('inTime', '入库时间'), ColDef('inTime', '入库时间'),
@@ -64,10 +69,15 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ColDef('actions', '操作', required: true), ColDef('actions', '操作', required: true),
]; ];
// 仓库/备注默认隐藏(原型无此两列);用户在列设置里可开。
static const _defaultHidden = {'warehouse', 'remark'};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 默认隐藏仓库/备注(原型无此两列);本地有存档则覆盖。
_hiddenCols = Set.of(_defaultHidden);
ColumnPrefs.load(_screenId).then((saved) { ColumnPrefs.load(_screenId).then((saved) {
if (saved != null && mounted) setState(() => _hiddenCols = saved); if (saved != null && mounted) setState(() => _hiddenCols = saved);
}); });
@@ -285,6 +295,40 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
} }
/// 商品列:name(可点进详情)+ code 两行(还原原型 .pname/.pcode)。 /// 商品列:name(可点进详情)+ code 两行(还原原型 .pname/.pcode)。
void _exportInventory(List<Inventory> items) {
exportExcel(
filename: '库存查询',
headers: const [
'商品编码', '商品名称', '规格', '系列', '批次号', '库存量',
'成本价', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'
],
rows: items.map((item) {
final status = item.quantity == 0
? '缺货'
: (item.minStock != null && item.quantity < item.minStock!)
? '库存不足'
: '正常';
return [
item.productCode,
item.productName,
item.spec,
item.series,
item.batchNo,
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
item.unitPrice != null ? item.unitPrice!.toStringAsFixed(2) : '',
item.salePrice != null ? item.salePrice!.toStringAsFixed(2) : '',
item.productionDate ?? '',
item.createdAt != null && item.createdAt!.length >= 10
? item.createdAt!.substring(0, 10)
: '',
item.supplierName,
item.minStock ?? '',
status,
];
}).toList(),
);
}
Widget _productCell(Inventory item, BuildContext context) { Widget _productCell(Inventory item, BuildContext context) {
final linked = item.productId != null; final linked = item.productId != null;
final body = Column( final body = Column(
@@ -327,11 +371,23 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo, 'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)), 'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
'qty' => DataCell(Text(
item.quantity.toStringAsFixed(0),
style: TextStyle(
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
// 低库存染色(qty ≤ 安全库存):原型 .qty.low → accent
color: (item.minStock != null && item.quantity <= item.minStock!)
? context.tokens.accent
: context.tokens.text))),
'cost' => DataCell(Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-',
style: const TextStyle(fontFamily: 'monospace'))),
'price' => DataCell(Text( 'price' => DataCell(Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '待定价', item.salePrice != null ? '¥${item.salePrice!.toStringAsFixed(2)}' : '待定价',
style: item.unitPrice == null style: item.salePrice == null
? TextStyle(color: context.tokens.warn) ? TextStyle(color: context.tokens.warn)
: null)), : const TextStyle(fontFamily: 'monospace'))),
'prodDate' => DataCell(Text(item.productionDate ?? '-')), 'prodDate' => DataCell(Text(item.productionDate ?? '-')),
'inTime' => DataCell(Text( 'inTime' => DataCell(Text(
item.createdAt != null && item.createdAt!.length >= 10 item.createdAt != null && item.createdAt!.length >= 10
@@ -353,10 +409,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
))), ))),
'status' => DataCell(_InventoryStatusBadge(item)), 'status' => DataCell(_InventoryStatusBadge(item)),
'actions' => DataCell(item.productId != null 'actions' => DataCell(item.productId != null
? TextButton( ? IconButton(
onPressed: () => _printLabel(context, item), icon: const Icon(Icons.visibility_outlined, size: 18),
child: Text('打标签', tooltip: '查看详情',
style: TextStyle(fontSize: 12, color: context.tokens.primary)), onPressed: () => context.push('/products/${item.productId}'),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
) )
: const SizedBox()), : const SizedBox()),
_ => const DataCell(SizedBox()), _ => const DataCell(SizedBox()),
@@ -458,24 +516,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PageScaffold( // 照原型 inventory.html:单视图(无 tab)。库存预警并入 KPI 缺货卡点击筛选,
title: '库存管理', // 流水记录移到商品详情页。
initialTab: ref.read(inventoryTabProvider), return _buildInventoryView();
onTabChanged: (i) => ref.read(inventoryTabProvider.notifier).state = i,
tabs: const [
Tab(text: '库存查询'),
Tab(text: '库存预警'),
Tab(text: '流水记录'),
],
tabViews: [
_buildInventoryTab(),
_buildWarningTab(),
_buildLogTab(),
],
);
} }
Widget _buildInventoryTab() { Widget _buildInventoryView() {
final asyncInventory = ref.watch(inventoryListProvider); final asyncInventory = ref.watch(inventoryListProvider);
return asyncInventory.when( return asyncInventory.when(
@@ -500,9 +546,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
), ),
data: (result) { data: (result) {
final items = result.data; final items = result.data;
final warningCount =
items.where((i) => i.minStock != null && i.quantity < i.minStock!).length;
final emptyCount = items.where((i) => i.quantity == 0).length;
// 仓库选项从当前页派生(客户端筛选),系列/规格从 product-options API 加载(服务端筛选) // 仓库选项从当前页派生(客户端筛选),系列/规格从 product-options API 加载(服务端筛选)
final warehouseOptions = items final warehouseOptions = items
@@ -519,9 +562,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
?.map((s) => s.name) ?.map((s) => s.name)
.toList() ?? .toList() ??
[]; [];
final filteredItems = _filterWarehouse.isEmpty String statusOf(Inventory i) => i.quantity == 0
? items ? '缺货'
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList(); : (i.minStock != null && i.quantity < i.minStock!)
? '预警'
: '在售';
final filteredItems = items
.where((i) =>
(_filterWarehouse.isEmpty ||
_filterWarehouse.contains(i.warehouseName)) &&
(_statusFilter == '全部' || statusOf(i) == _statusFilter))
.toList();
final hidden = _hiddenCols ?? <String>{}; final hidden = _hiddenCols ?? <String>{};
final visibleCols = final visibleCols =
@@ -529,34 +580,70 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
return Column( return Column(
children: [ children: [
// 副标(还原原型 .head .sub):商品总数 + 实时提示 // 头部(原型 .head):标题 + SKU 数 + 列设置/导出
Container( Container(
width: double.infinity, width: double.infinity,
color: context.tokens.bg, color: context.tokens.bg,
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
AppDims.sp3, AppDims.sp3, AppDims.sp3, 0), AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2),
child: Text( child: Row(
'${NumberFormat.decimalPattern().format(result.total)} 个商品 · 数据实时', crossAxisAlignment: CrossAxisAlignment.end,
style: children: [
TextStyle(fontSize: AppDims.fsSm, color: context.tokens.muted), Text('库存管理',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: context.tokens.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${NumberFormat.decimalPattern().format(result.total)} 个 SKU · 数据实时',
style: TextStyle(
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
const Spacer(),
if (!context.isMobile) ...[
ColumnToggleButton(
columns: _colDefs,
hidden: hidden,
onChanged: (v) {
setState(() => _hiddenCols = v);
ColumnPrefs.save(_screenId, v);
},
),
const SizedBox(width: AppDims.sp2),
OutlinedButton.icon(
onPressed: () => _exportInventory(filteredItems),
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
],
],
), ),
), ),
// KPI 卡(还原原型 .kpi):宽屏 4 等分,窄屏横向滚动 // KPI 卡(还原原型 .kpi):宽屏 4 等分,窄屏横向滚动
Builder(builder: (context) { Builder(builder: (context) {
final inStockQty = // KPI 走全店汇总接口(不随分页)。
items.fold<num>(0, (s, i) => s + i.quantity); final summary = ref.watch(inventorySummaryProvider).valueOrNull;
String yuanWan(double v) => v >= 10000
? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}'
: '¥${v.toStringAsFixed(0)}';
final cards = <Widget>[ final cards = <Widget>[
KpiCard( KpiCard(
title: '商品总数', title: 'SKU 总数',
value: '${result.total}', value: NumberFormat.decimalPattern()
.format(summary?.skuCount ?? result.total),
icon: Icons.inventory_2, icon: Icons.inventory_2,
tone: KpiTone.info, tone: KpiTone.info,
delta: '种 · 点击清筛选', delta: '点击清筛选',
onTap: () { onTap: () {
setState(() { setState(() {
_filterWarehouse = {}; _filterWarehouse = {};
_filterSpec = {}; _filterSpec = {};
_filterSeries = {}; _filterSeries = {};
_statusFilter = '全部';
_searchCtrl.clear(); _searchCtrl.clear();
}); });
final n = ref.read(inventoryListProvider.notifier); final n = ref.read(inventoryListProvider.notifier);
@@ -565,25 +652,30 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
n.setSeries([]); n.setSeries([]);
}, },
), ),
KpiCard(
title: '库存货值',
value: summary != null ? yuanWan(summary.stockValue) : '',
icon: Icons.savings_outlined,
tone: KpiTone.ok,
delta: '按进价',
),
KpiCard( KpiCard(
title: '在库数量', title: '在库数量',
value: inStockQty.toStringAsFixed(0), value: NumberFormat.decimalPattern()
.format((summary?.inStockQty ?? 0).round()),
icon: Icons.warehouse_outlined, icon: Icons.warehouse_outlined,
tone: KpiTone.blue, tone: KpiTone.blue,
delta: '', delta: '',
), ),
KpiCard( KpiCard(
title: '库存预警', title: '缺货预警',
value: '$warningCount', value: '${summary?.shortageCount ?? 0}',
icon: Icons.warning_amber, icon: Icons.error_outline,
tone: KpiTone.warn,
delta: warningCount > 0 ? '需补货' : '正常',
),
KpiCard(
title: '缺货商品',
value: '$emptyCount',
icon: Icons.remove_shopping_cart,
tone: KpiTone.alert, tone: KpiTone.alert,
delta: (summary?.warningCount ?? 0) > 0
? '需补货 ${summary!.warningCount}'
: '点击筛选',
onTap: () => setState(() => _statusFilter = '缺货'),
), ),
]; ];
final mobile = context.isMobile; final mobile = context.isMobile;
@@ -622,32 +714,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ref.read(inventoryListProvider.notifier).setPageSize(s), ref.read(inventoryListProvider.notifier).setPageSize(s),
toolbar: Builder(builder: (ctx) { toolbar: Builder(builder: (ctx) {
final isMobile = ctx.isMobile; final isMobile = ctx.isMobile;
void doExport() => exportExcel( void doExport() => _exportInventory(filteredItems);
filename: '库存查询',
headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'],
rows: filteredItems.map((item) {
final status = item.quantity == 0
? '缺货'
: (item.minStock != null && item.quantity < item.minStock!)
? '库存不足'
: '正常';
return [
item.productCode.isEmpty ? '' : item.productCode,
item.productName.isEmpty ? '' : item.productName,
item.spec.isEmpty ? '' : item.spec,
item.series.isEmpty ? '' : item.series,
item.batchNo.isEmpty ? '' : item.batchNo,
item.warehouseName.isEmpty ? '' : item.warehouseName,
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
item.unitPrice != null ? item.unitPrice!.toStringAsFixed(2) : '',
item.productionDate ?? '',
item.createdAt != null && item.createdAt!.length >= 10 ? item.createdAt!.substring(0, 10) : '',
item.supplierName.isEmpty ? '' : item.supplierName,
item.minStock ?? '',
status,
];
}).toList(),
);
final searchField = TextField( final searchField = TextField(
controller: _searchCtrl, controller: _searchCtrl,
@@ -729,43 +796,63 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
); );
} }
// 桌面:搜索置于最前(原型 searchbox 240),按钮成组靠左 // 桌面(原型 .toolbar):搜索 + 状态筛选 + 重置(导出/列设置在头部)
return Row( return Row(
children: [ children: [
SizedBox(width: 240, child: searchField), SizedBox(width: 240, child: searchField),
const SizedBox(width: AppDims.sp3), const SizedBox(width: AppDims.sp3),
if (canCheck) ...[ PopupMenuButton<String>(
WriteGuard( initialValue: _statusFilter,
child: OutlinedButton.icon( onSelected: (v) => setState(() => _statusFilter = v),
onPressed: () => context.go('/inventory/check'), itemBuilder: (_) => const ['全部', '在售', '预警', '缺货']
icon: const Icon(Icons.fact_check, size: 16), .map((s) =>
label: const Text('发起盘点'), PopupMenuItem(value: s, child: Text(s)))
.toList(),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: ctx.tokens.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('状态',
style: TextStyle(
fontSize: 13, color: ctx.tokens.text)),
if (_statusFilter != '全部') ...[
const SizedBox(width: 6),
Text(_statusFilter,
style: TextStyle(
fontSize: 13,
color: ctx.tokens.primary,
fontWeight: FontWeight.w600)),
],
const SizedBox(width: 4),
Icon(Icons.keyboard_arrow_down,
size: 16, color: ctx.tokens.muted),
],
), ),
), ),
const SizedBox(width: AppDims.sp2),
],
OutlinedButton.icon(
onPressed: doExport,
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const SizedBox(width: AppDims.sp2),
ColumnToggleButton(
columns: _colDefs,
hidden: hidden,
onChanged: (v) {
setState(() => _hiddenCols = v);
ColumnPrefs.save(_screenId, v);
},
),
const SizedBox(width: AppDims.sp2),
OutlinedButton.icon(
onPressed: () =>
ref.read(inventoryListProvider.notifier).reload(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('刷新'),
), ),
const Spacer(), const Spacer(),
TextButton(
onPressed: () {
setState(() {
_filterWarehouse = {};
_filterSpec = {};
_filterSeries = {};
_statusFilter = '全部';
_searchCtrl.clear();
});
final n = ref.read(inventoryListProvider.notifier);
n.setKeyword('');
n.setSpec([]);
n.setSeries([]);
},
child: const Text('重置'),
),
], ],
); );
}), }),
@@ -802,7 +889,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
}; };
return DataColumn( return DataColumn(
label: label, label: label,
numeric: c.key == 'price', numeric: const {'qty', 'cost', 'price'}.contains(c.key),
); );
}).toList(), }).toList(),
rows: items.isEmpty rows: items.isEmpty
@@ -1208,12 +1295,12 @@ class _ImportProgressDialog extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
LinearProgressIndicator( LinearProgressIndicator(
value: state.uploadPercent / 100, value: state.uploadPercent / 100,
backgroundColor: Colors.grey[200], backgroundColor: context.tokens.borderSubtle,
valueColor: AlwaysStoppedAnimation<Color>(context.tokens.primary), valueColor: AlwaysStoppedAnimation<Color>(context.tokens.primary),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text('${state.uploadPercent}%', Text('${state.uploadPercent}%',
style: TextStyle(fontSize: 12, color: Colors.grey[600])), style: TextStyle(fontSize: 12, color: context.tokens.muted)),
], ],
); );
@@ -1238,7 +1325,7 @@ class _ImportProgressDialog extends StatelessWidget {
]), ]),
const SizedBox(height: 8), const SizedBox(height: 8),
LinearProgressIndicator( LinearProgressIndicator(
backgroundColor: Colors.grey[200], backgroundColor: context.tokens.borderSubtle,
valueColor: AlwaysStoppedAnimation<Color>(context.tokens.primary), valueColor: AlwaysStoppedAnimation<Color>(context.tokens.primary),
), ),
], ],
@@ -1266,7 +1353,7 @@ class _ImportProgressDialog extends StatelessWidget {
Text('${state.total}', style: const TextStyle(fontSize: 13)), Text('${state.total}', style: const TextStyle(fontSize: 13)),
Text('成功插入:${state.imported}', style: const TextStyle(fontSize: 13)), Text('成功插入:${state.imported}', style: const TextStyle(fontSize: 13)),
Text('重复更新:${state.updated}', Text('重复更新:${state.updated}',
style: TextStyle(fontSize: 13, color: Colors.grey[600])), style: TextStyle(fontSize: 13, color: context.tokens.muted)),
if (state.errors.isNotEmpty) if (state.errors.isNotEmpty)
Text('失败:${state.errors.length}', Text('失败:${state.errors.length}',
style: TextStyle(fontSize: 13, color: context.tokens.danger)), style: TextStyle(fontSize: 13, color: context.tokens.danger)),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 175 KiB

@@ -76,6 +76,13 @@ List<Override> _overrides() => [
productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()), productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()),
productSpecListProvider.overrideWith(() => _FakeSpecNotifier()), productSpecListProvider.overrideWith(() => _FakeSpecNotifier()),
isReadonlyProvider.overrideWithValue(false), isReadonlyProvider.overrideWithValue(false),
inventorySummaryProvider.overrideWith((ref) => const InventorySummary(
skuCount: 1284,
stockValue: 2640000,
inStockQty: 18920,
shortageCount: 17,
warningCount: 6,
)),
]; ];
void main() { void main() {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 431 KiB

+4
View File
@@ -12,6 +12,10 @@ Future<void> ensureGoldenFonts() async {
final loader = FontLoader('NotoSansSC') final loader = FontLoader('NotoSansSC')
..addFont(rootBundle.load('assets/fonts/NotoSansSC.ttf')); ..addFont(rootBundle.load('assets/fonts/NotoSansSC.ttf'));
await loader.load(); await loader.load();
// 把内置 'monospace' family 也指向 Noto(否则等宽数字/批次号在 golden 渲染成黑块)。
final mono = FontLoader('monospace')
..addFont(rootBundle.load('assets/fonts/NotoSansSC.ttf'));
await mono.load();
// 真实 Material 图标字体(否则 golden 里图标渲染成豆腐块 □)。 // 真实 Material 图标字体(否则 golden 里图标渲染成豆腐块 □)。
for (final key in const [ for (final key in const [
'fonts/MaterialIcons-Regular.otf', 'fonts/MaterialIcons-Regular.otf',