refactor(client): 库存屏改用 ds 组件库重写(屏内零内联样式)

按「屏只组合组件、不写样式」用 widgets/ds/ 重写库存屏:
- KPI: KpiCard → DsKpi(4 卡,ic 右上 + mono 数字 + 缺货 accent)
- 表格: DataTableCard → DsTable(列头漏斗 header + 状态 DsBadge 圆点 + 库存 accent 染色 + 分页)
- 工具栏: TextField → DsSearchBox;状态内联 chip → DsChip;重置/导出 → DsButton
- _buildInventoryCell 改返回 Widget(供 DsRow.cells),状态走 _dsStatusBadge(DsBadge)
- 颜色全走 token(闸 0 违规),analyze 0 error,flutter test 242 通过

旧 DataTableCard/KpiCard/StatusPill 依赖仅剩未用的预警/流水 tab,下一步清理。

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 21:58:00 +08:00
parent 76f1b13eb4
commit 348d118bb9
4 changed files with 100 additions and 127 deletions
@@ -14,7 +14,10 @@ import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart'; import '../../widgets/write_guard.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/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 '../../widgets/mobile_list_card.dart';
import '../../core/theme/app_dims.g.dart'; import '../../core/theme/app_dims.g.dart';
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
@@ -362,53 +365,50 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
); );
} }
/// 库存查询:宽屏表格单元格(按列 key 返回对应 DataCell /// 库存单元格(按列 key 返回 Widget,供 DsTable 的 DsRow.cells)。
DataCell _buildInventoryCell(String key, Inventory item, BuildContext context) { Widget _buildInventoryCell(String key, Inventory item, BuildContext context) {
final t = context.tokens;
return switch (key) { return switch (key) {
'product' => DataCell(_productCell(item, context)), 'product' => _productCell(item, context),
'spec' => DataCell(Text(item.spec.isEmpty ? '-' : item.spec)), 'spec' => Text(item.spec.isEmpty ? '-' : item.spec),
'series' => DataCell(Text(item.series.isEmpty ? '-' : item.series)), 'series' => Text(item.series.isEmpty ? '-' : item.series),
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo, 'batch' => 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' =>
'qty' => DataCell(Text( Text(item.warehouseName.isEmpty ? '-' : item.warehouseName),
item.quantity.toStringAsFixed(0), 'qty' => Text(item.quantity.toStringAsFixed(0),
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontFamily: 'monospace', fontFamily: 'monospace',
// 低库存染色(qty ≤ 安全库存):原型 .qty.low → accent // 低库存染色(qty ≤ 安全库存):原型 .qty.low → accent
color: (item.minStock != null && item.quantity <= item.minStock!) color: (item.minStock != null && item.quantity <= item.minStock!)
? context.tokens.accent ? t.accent
: context.tokens.text))), : t.text)),
'cost' => DataCell(Text( 'cost' => Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-', item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-',
style: const TextStyle(fontFamily: 'monospace'))), style: const TextStyle(fontFamily: 'monospace')),
'price' => DataCell(Text( 'price' => Text(
item.salePrice != null ? '¥${item.salePrice!.toStringAsFixed(2)}' : '待定价', item.salePrice != null
? '¥${item.salePrice!.toStringAsFixed(2)}'
: '待定价',
style: item.salePrice == null style: item.salePrice == null
? TextStyle(color: context.tokens.warn) ? TextStyle(color: t.warn, fontWeight: FontWeight.w600)
: const TextStyle(fontFamily: 'monospace'))), : const TextStyle(fontFamily: 'monospace')),
'prodDate' => DataCell(Text(item.productionDate ?? '-')), 'prodDate' => Text(item.productionDate ?? '-'),
'inTime' => DataCell(Text( 'inTime' => Text(
item.createdAt != null && item.createdAt!.length >= 10 item.createdAt != null && item.createdAt!.length >= 10
? item.createdAt!.substring(0, 10) ? item.createdAt!.substring(0, 10)
: '-', : '-',
style: TextStyle(fontSize: 12, color: context.tokens.muted))), style: TextStyle(fontSize: 12, color: t.muted)),
'supplier' => DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)), 'supplier' => Text(item.supplierName.isEmpty ? '-' : item.supplierName),
'remark' => DataCell(Tooltip( 'remark' => WriteGuard(
message: item.remark.isEmpty ? '' : item.remark, placeholder: _remarkDisplay(item, editable: false),
waitDuration: const Duration(milliseconds: 300), child: GestureDetector(
// 内联编辑入口统一交给 WriteGuard:只读角色显示纯文本(无编辑图标), onTap: () => _editRemark(context, item),
// 授权过期则由 WriteGuard 自动置灰并在点击时弹提示——不再手搓三元 + toast。 child: _remarkDisplay(item, editable: true),
child: WriteGuard( )),
placeholder: _remarkDisplay(item, editable: false), 'status' => _dsStatusBadge(item),
child: GestureDetector( 'actions' => item.productId != null
onTap: () => _editRemark(context, item),
child: _remarkDisplay(item, editable: true),
),
))),
'status' => DataCell(_InventoryStatusBadge(item)),
'actions' => DataCell(item.productId != null
? IconButton( ? IconButton(
icon: const Icon(Icons.visibility_outlined, size: 18), icon: const Icon(Icons.visibility_outlined, size: 18),
tooltip: '查看详情', tooltip: '查看详情',
@@ -416,11 +416,22 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
) )
: const SizedBox()), : const SizedBox(),
_ => const DataCell(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) { Widget _inventoryCard(Inventory item) {
return MobileListCard( return MobileListCard(
@@ -614,10 +625,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
}, },
), ),
const SizedBox(width: AppDims.sp2), const SizedBox(width: AppDims.sp2),
OutlinedButton.icon( DsButton(
'导出',
icon: Icons.download,
onPressed: () => _exportInventory(filteredItems), onPressed: () => _exportInventory(filteredItems),
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
), ),
], ],
], ],
@@ -631,12 +642,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}' ? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}'
: '¥${v.toStringAsFixed(0)}'; : '¥${v.toStringAsFixed(0)}';
final cards = <Widget>[ final cards = <Widget>[
KpiCard( DsKpi(
title: 'SKU 总数', title: 'SKU 总数',
value: NumberFormat.decimalPattern() value: NumberFormat.decimalPattern()
.format(summary?.skuCount ?? result.total), .format(summary?.skuCount ?? result.total),
icon: Icons.inventory_2, icon: Icons.inventory_2,
tone: KpiTone.info, tone: DsKpiTone.info,
delta: '点击清筛选', delta: '点击清筛选',
onTap: () { onTap: () {
setState(() { setState(() {
@@ -652,26 +663,26 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
n.setSeries([]); n.setSeries([]);
}, },
), ),
KpiCard( DsKpi(
title: '库存货值', title: '库存货值',
value: summary != null ? yuanWan(summary.stockValue) : '', value: summary != null ? yuanWan(summary.stockValue) : '',
icon: Icons.savings_outlined, icon: Icons.savings_outlined,
tone: KpiTone.ok, tone: DsKpiTone.ok,
delta: '按进价', delta: '按进价',
), ),
KpiCard( DsKpi(
title: '在库数量', title: '在库数量',
value: NumberFormat.decimalPattern() value: NumberFormat.decimalPattern()
.format((summary?.inStockQty ?? 0).round()), .format((summary?.inStockQty ?? 0).round()),
icon: Icons.warehouse_outlined, icon: Icons.warehouse_outlined,
tone: KpiTone.blue, tone: DsKpiTone.blue,
delta: '', delta: '',
), ),
KpiCard( DsKpi(
title: '缺货预警', title: '缺货预警',
value: '${summary?.shortageCount ?? 0}', value: '${summary?.shortageCount ?? 0}',
icon: Icons.error_outline, icon: Icons.error_outline,
tone: KpiTone.alert, tone: DsKpiTone.alert,
delta: (summary?.warningCount ?? 0) > 0 delta: (summary?.warningCount ?? 0) > 0
? '需补货 ${summary!.warningCount}' ? '需补货 ${summary!.warningCount}'
: '点击筛选', : '点击筛选',
@@ -704,8 +715,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
}), }),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: DataTableCard( child: DsTable(
totalCount: result.total, total: result.total,
page: result.page, page: result.page,
pageSize: result.pageSize, pageSize: result.pageSize,
onPageChanged: (p) => onPageChanged: (p) =>
@@ -716,18 +727,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final isMobile = ctx.isMobile; final isMobile = ctx.isMobile;
void doExport() => _exportInventory(filteredItems); void doExport() => _exportInventory(filteredItems);
final searchField = TextField( final searchField = DsSearchBox(
controller: _searchCtrl, controller: _searchCtrl,
decoration: InputDecoration( hint: '名称/编码/拼音,回车搜索',
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,
),
),
onSubmitted: (_) => _triggerSearch(), onSubmitted: (_) => _triggerSearch(),
); );
@@ -808,36 +810,15 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
.map((s) => .map((s) =>
PopupMenuItem(value: s, child: Text(s))) PopupMenuItem(value: s, child: Text(s)))
.toList(), .toList(),
child: Container( child: DsChip(
height: 38, label: '状态',
padding: const EdgeInsets.symmetric(horizontal: 12), value: _statusFilter == '全部' ? null : _statusFilter,
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 Spacer(), const Spacer(),
TextButton( DsButton(
'重置',
small: true,
onPressed: () { onPressed: () {
setState(() { setState(() {
_filterWarehouse = {}; _filterWarehouse = {};
@@ -851,7 +832,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
n.setSpec([]); n.setSpec([]);
n.setSeries([]); n.setSeries([]);
}, },
child: const Text('重置'),
), ),
], ],
); );
@@ -859,15 +839,18 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
mobileCards: _mobileTableView mobileCards: _mobileTableView
? null ? null
: filteredItems.map(_inventoryCard).toList(), : filteredItems.map(_inventoryCard).toList(),
emptyText: '暂无库存数据',
columns: visibleCols.map((c) { columns: visibleCols.map((c) {
final label = switch (c.key) { final Widget? header = switch (c.key) {
'spec' => FilterableColumnHeader( 'spec' => FilterableColumnHeader(
text: c.label, text: c.label,
options: specOptions, options: specOptions,
selected: _filterSpec, selected: _filterSpec,
onChanged: (v) { onChanged: (v) {
setState(() => _filterSpec = v); setState(() => _filterSpec = v);
ref.read(inventoryListProvider.notifier).setSpec(v.toList()); ref
.read(inventoryListProvider.notifier)
.setSpec(v.toList());
}, },
), ),
'series' => FilterableColumnHeader( 'series' => FilterableColumnHeader(
@@ -876,7 +859,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
selected: _filterSeries, selected: _filterSeries,
onChanged: (v) { onChanged: (v) {
setState(() => _filterSeries = v); setState(() => _filterSeries = v);
ref.read(inventoryListProvider.notifier).setSeries(v.toList()); ref
.read(inventoryListProvider.notifier)
.setSeries(v.toList());
}, },
), ),
'warehouse' => FilterableColumnHeader( 'warehouse' => FilterableColumnHeader(
@@ -885,42 +870,30 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
selected: _filterWarehouse, selected: _filterWarehouse,
onChanged: (v) => setState(() => _filterWarehouse = v), onChanged: (v) => setState(() => _filterWarehouse = v),
), ),
_ => Text(c.label), _ => null,
}; };
return DataColumn( return DsColumn(c.key, c.label,
label: label, numeric: const {'qty', 'cost', 'price'}.contains(c.key),
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(), }).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(),
), ),
), ),
], ],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 213 KiB