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:
@@ -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 |
Reference in New Issue
Block a user