diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index a9bced4..7c40307 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -14,7 +14,10 @@ import '../../core/auth/auth_state.dart'; import '../../widgets/write_guard.dart'; import '../../providers/inventory_provider.dart'; import '../../widgets/data_table_card.dart'; -import '../../widgets/kpi_card.dart'; +import '../../widgets/kpi_card.dart' show StatusPill; // 旧 tab/移动卡片用,待统一 DsBadge +import '../../widgets/ds/ds_atoms.dart'; +import '../../widgets/ds/ds_kpi.dart'; +import '../../widgets/ds/ds_table.dart'; import '../../widgets/mobile_list_card.dart'; import '../../core/theme/app_dims.g.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; @@ -362,53 +365,50 @@ class _InventoryListScreenState extends ConsumerState { ); } - /// 库存查询:宽屏表格单元格(按列 key 返回对应 DataCell) - DataCell _buildInventoryCell(String key, Inventory item, BuildContext context) { + /// 库存表单元格(按列 key 返回 Widget,供 DsTable 的 DsRow.cells)。 + Widget _buildInventoryCell(String key, Inventory item, BuildContext context) { + final t = context.tokens; return switch (key) { - 'product' => DataCell(_productCell(item, context)), - '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), + 'product' => _productCell(item, context), + 'spec' => Text(item.spec.isEmpty ? '-' : item.spec), + 'series' => Text(item.series.isEmpty ? '-' : item.series), + 'batch' => Text(item.batchNo.isEmpty ? '-' : item.batchNo, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), + 'warehouse' => + Text(item.warehouseName.isEmpty ? '-' : item.warehouseName), + 'qty' => 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( + ? t.accent + : t.text)), + 'cost' => Text( item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-', - style: const TextStyle(fontFamily: 'monospace'))), - 'price' => DataCell(Text( - item.salePrice != null ? '¥${item.salePrice!.toStringAsFixed(2)}' : '待定价', + style: const TextStyle(fontFamily: 'monospace')), + 'price' => Text( + item.salePrice != null + ? '¥${item.salePrice!.toStringAsFixed(2)}' + : '待定价', style: item.salePrice == null - ? TextStyle(color: context.tokens.warn) - : const TextStyle(fontFamily: 'monospace'))), - 'prodDate' => DataCell(Text(item.productionDate ?? '-')), - 'inTime' => DataCell(Text( + ? TextStyle(color: t.warn, fontWeight: FontWeight.w600) + : const TextStyle(fontFamily: 'monospace')), + 'prodDate' => Text(item.productionDate ?? '-'), + 'inTime' => Text( item.createdAt != null && item.createdAt!.length >= 10 ? item.createdAt!.substring(0, 10) : '-', - style: TextStyle(fontSize: 12, color: context.tokens.muted))), - 'supplier' => DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)), - 'remark' => DataCell(Tooltip( - message: item.remark.isEmpty ? '' : item.remark, - waitDuration: const Duration(milliseconds: 300), - // 内联编辑入口统一交给 WriteGuard:只读角色显示纯文本(无编辑图标), - // 授权过期则由 WriteGuard 自动置灰并在点击时弹提示——不再手搓三元 + toast。 - child: WriteGuard( - placeholder: _remarkDisplay(item, editable: false), - child: GestureDetector( - onTap: () => _editRemark(context, item), - child: _remarkDisplay(item, editable: true), - ), - ))), - 'status' => DataCell(_InventoryStatusBadge(item)), - 'actions' => DataCell(item.productId != null + style: TextStyle(fontSize: 12, color: t.muted)), + 'supplier' => Text(item.supplierName.isEmpty ? '-' : item.supplierName), + 'remark' => WriteGuard( + placeholder: _remarkDisplay(item, editable: false), + child: GestureDetector( + onTap: () => _editRemark(context, item), + child: _remarkDisplay(item, editable: true), + )), + 'status' => _dsStatusBadge(item), + 'actions' => item.productId != null ? IconButton( icon: const Icon(Icons.visibility_outlined, size: 18), tooltip: '查看详情', @@ -416,11 +416,22 @@ class _InventoryListScreenState extends ConsumerState { padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ) - : const SizedBox()), - _ => const DataCell(SizedBox()), + : const SizedBox(), + _ => const SizedBox(), }; } + /// 状态徽章(DsBadge):派生 qty vs 安全库存 → 在售/预警/缺货。 + Widget _dsStatusBadge(Inventory item) { + if (item.quantity == 0) { + return const DsBadge('缺货', tone: DsBadgeTone.danger); + } + if (item.minStock != null && item.quantity < item.minStock!) { + return const DsBadge('预警', tone: DsBadgeTone.warn); + } + return const DsBadge('在售', tone: DsBadgeTone.ok); + } + /// 库存查询:窄屏卡片 Widget _inventoryCard(Inventory item) { return MobileListCard( @@ -614,10 +625,10 @@ class _InventoryListScreenState extends ConsumerState { }, ), const SizedBox(width: AppDims.sp2), - OutlinedButton.icon( + DsButton( + '导出', + icon: Icons.download, onPressed: () => _exportInventory(filteredItems), - icon: const Icon(Icons.download, size: 16), - label: const Text('导出'), ), ], ], @@ -631,12 +642,12 @@ class _InventoryListScreenState extends ConsumerState { ? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}万' : '¥${v.toStringAsFixed(0)}'; final cards = [ - KpiCard( + DsKpi( title: 'SKU 总数', value: NumberFormat.decimalPattern() .format(summary?.skuCount ?? result.total), icon: Icons.inventory_2, - tone: KpiTone.info, + tone: DsKpiTone.info, delta: '点击清筛选', onTap: () { setState(() { @@ -652,26 +663,26 @@ class _InventoryListScreenState extends ConsumerState { n.setSeries([]); }, ), - KpiCard( + DsKpi( title: '库存货值', value: summary != null ? yuanWan(summary.stockValue) : '—', icon: Icons.savings_outlined, - tone: KpiTone.ok, + tone: DsKpiTone.ok, delta: '按进价', ), - KpiCard( + DsKpi( title: '在库数量', value: NumberFormat.decimalPattern() .format((summary?.inStockQty ?? 0).round()), icon: Icons.warehouse_outlined, - tone: KpiTone.blue, + tone: DsKpiTone.blue, delta: '件', ), - KpiCard( + DsKpi( title: '缺货预警', value: '${summary?.shortageCount ?? 0}', icon: Icons.error_outline, - tone: KpiTone.alert, + tone: DsKpiTone.alert, delta: (summary?.warningCount ?? 0) > 0 ? '需补货 ${summary!.warningCount} 项' : '点击筛选', @@ -704,8 +715,8 @@ class _InventoryListScreenState extends ConsumerState { }), const Divider(height: 1), Expanded( - child: DataTableCard( - totalCount: result.total, + child: DsTable( + total: result.total, page: result.page, pageSize: result.pageSize, onPageChanged: (p) => @@ -716,18 +727,9 @@ class _InventoryListScreenState extends ConsumerState { final isMobile = ctx.isMobile; void doExport() => _exportInventory(filteredItems); - final searchField = TextField( + final searchField = DsSearchBox( controller: _searchCtrl, - decoration: InputDecoration( - hintText: '名称/编码/拼音,回车搜索', - prefixIcon: const Icon(Icons.search, size: 16), - hintStyle: const TextStyle(fontSize: 12), - suffixIcon: IconButton( - icon: const Icon(Icons.search, size: 16), - tooltip: '搜索', - onPressed: _triggerSearch, - ), - ), + hint: '名称/编码/拼音,回车搜索', onSubmitted: (_) => _triggerSearch(), ); @@ -808,36 +810,15 @@ class _InventoryListScreenState extends ConsumerState { .map((s) => 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), - ], - ), + child: DsChip( + label: '状态', + value: _statusFilter == '全部' ? null : _statusFilter, ), ), const Spacer(), - TextButton( + DsButton( + '重置', + small: true, onPressed: () { setState(() { _filterWarehouse = {}; @@ -851,7 +832,6 @@ class _InventoryListScreenState extends ConsumerState { n.setSpec([]); n.setSeries([]); }, - child: const Text('重置'), ), ], ); @@ -859,15 +839,18 @@ class _InventoryListScreenState extends ConsumerState { mobileCards: _mobileTableView ? null : filteredItems.map(_inventoryCard).toList(), + emptyText: '暂无库存数据', columns: visibleCols.map((c) { - final label = switch (c.key) { + final Widget? header = switch (c.key) { 'spec' => FilterableColumnHeader( text: c.label, options: specOptions, selected: _filterSpec, onChanged: (v) { setState(() => _filterSpec = v); - ref.read(inventoryListProvider.notifier).setSpec(v.toList()); + ref + .read(inventoryListProvider.notifier) + .setSpec(v.toList()); }, ), 'series' => FilterableColumnHeader( @@ -876,7 +859,9 @@ class _InventoryListScreenState extends ConsumerState { selected: _filterSeries, onChanged: (v) { setState(() => _filterSeries = v); - ref.read(inventoryListProvider.notifier).setSeries(v.toList()); + ref + .read(inventoryListProvider.notifier) + .setSeries(v.toList()); }, ), 'warehouse' => FilterableColumnHeader( @@ -885,42 +870,30 @@ class _InventoryListScreenState extends ConsumerState { selected: _filterWarehouse, onChanged: (v) => setState(() => _filterWarehouse = v), ), - _ => Text(c.label), + _ => null, }; - return DataColumn( - label: label, - numeric: const {'qty', 'cost', 'price'}.contains(c.key), + return DsColumn(c.key, c.label, + numeric: const {'qty', 'cost', 'price'}.contains(c.key), + action: c.key == 'actions', + header: header); + }).toList(), + rows: filteredItems.map((item) { + final Color? hl = item.quantity == 0 + ? context.tokens.danger.withValues(alpha: 0.04) + : (item.minStock != null && + item.quantity < item.minStock!) + ? context.tokens.accent.withValues(alpha: 0.04) + : null; + return DsRow( + highlight: hl, + onTap: item.productId != null + ? () => context.push('/products/${item.productId}') + : null, + cells: visibleCols + .map((c) => _buildInventoryCell(c.key, item, context)) + .toList(), ); }).toList(), - rows: items.isEmpty - ? [ - DataRow(cells: [ - const DataCell(SizedBox()), - DataCell(Text('暂无库存数据', - style: TextStyle( - color: context.tokens.muted))), - for (int i = 2; i < visibleCols.length; i++) - const DataCell(SizedBox()), - ]) - ] - : filteredItems - .map((item) => DataRow( - color: WidgetStateProperty.resolveWith( - (states) { - if (item.quantity == 0) { - return context.tokens.danger.withValues(alpha: 0.04); - } - if (item.minStock != null && - item.quantity < item.minStock!) { - return context.tokens.accent.withValues(alpha: 0.04); - } - return null; - }), - cells: visibleCols - .map((c) => _buildInventoryCell(c.key, item, context)) - .toList(), - )) - .toList(), ), ), ], diff --git a/client/test/golden/goldens/inventory_list_a.png b/client/test/golden/goldens/inventory_list_a.png index a871823..500703f 100644 Binary files a/client/test/golden/goldens/inventory_list_a.png and b/client/test/golden/goldens/inventory_list_a.png differ diff --git a/client/test/golden/goldens/inventory_list_b.png b/client/test/golden/goldens/inventory_list_b.png index c8928f3..8cc460e 100644 Binary files a/client/test/golden/goldens/inventory_list_b.png and b/client/test/golden/goldens/inventory_list_b.png differ diff --git a/client/test/golden/goldens/inventory_list_c.png b/client/test/golden/goldens/inventory_list_c.png index 96c8bc6..0e61dd7 100644 Binary files a/client/test/golden/goldens/inventory_list_c.png and b/client/test/golden/goldens/inventory_list_c.png differ