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 '../../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<InventoryListScreen> {
);
}
/// 库存查询:宽屏表格单元格(按列 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<InventoryListScreen> {
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<InventoryListScreen> {
},
),
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<InventoryListScreen> {
? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}'
: '¥${v.toStringAsFixed(0)}';
final cards = <Widget>[
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<InventoryListScreen> {
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<InventoryListScreen> {
}),
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<InventoryListScreen> {
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<InventoryListScreen> {
.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<InventoryListScreen> {
n.setSpec([]);
n.setSeries([]);
},
child: const Text('重置'),
),
],
);
@@ -859,15 +839,18 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
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<InventoryListScreen> {
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<InventoryListScreen> {
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(),
),
),
],
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