feat(client): 库存三态 UI——抽屉挂售 switch + 三态徽章/筛选/已售 KPI 卡

- 新组件 DsSwitch(原型 atoms .switch 对照,L1 已登记:38×22 pill,on=primary)
- 商品抽屉状态行:三态徽章后跟挂售 switch(在售⇄库存 即时生效,已售只读,
  readonly 禁用),桌面 drawer / 移动 sheet 同组件
- 列表状态列/卡片徽章三态化(在售绿/库存蓝/已售灰,icons BADGE_ICON 补两词);
  状态筛选改「全部/在售/库存/已售」走服务端;预警缺货退出状态维度由数量染色承担
- KPI 第四卡「缺货预警」→「已售」(sold_count,点击筛选已售)
- 原型同步:atoms/index 登记 switch 与 b-库存/b-已售、inventory 两屏三态数据、
  抽屉状态行 switch;12 道 ds 闸 + L1 同源闸全过
- golden 重录 ×9(三态徽章 + 已售卡形态)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-07 13:35:19 +08:00
parent 73955a139c
commit 65c672b4ad
26 changed files with 264 additions and 56 deletions
@@ -38,12 +38,19 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {};
Set<String> _filterSeries = {};
// 库存状态前端筛选(派生自 qty vs 安全库存):全部/在售/预警/缺货
// 库存三态筛选(2026-07-07 status 落库,服务端过滤):全部/在售/库存/已售;
// 默认「全部」= 后端排除已售(已售仅点已售卡/筛选时显示)。预警缺货退出状态维度。
String _statusFilter = '全部';
static const _statusParam = {
'全部': '',
'在售': 'on_sale',
'库存': 'stock',
'已售': 'sold',
};
Set<String>? _hiddenCols; // null = 尚未载入本地存档
static const _screenId = 'inventory_list';
static const _statusOptions = ['全部', '在售', '预警', '缺货'];
static const _statusOptions = ['全部', '在售', '库存', '已售'];
// 列序照原型 inventory.html COLS:商品/规格/系列/批次/库存/成本价/单价/生产/入库/供应商/状态/操作。
// 成本价=入库进价(unitPrice),总价=库存×成本价(2026-07-03 用户拍板:原「单价/参考售价」列换掉)。
@@ -95,13 +102,21 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
n.setKeyword('');
n.setSpec([]);
n.setSeries([]);
n.setStatus('');
}
String _statusOf(Inventory i) => i.quantity == 0
? '缺货'
: (i.minStock != null && i.quantity < i.minStock!)
? '预警'
: '在售';
// 三态落库后的显示标签(status 列真相源)
String _statusOf(Inventory i) => switch (i.status) {
'on_sale' => '在售',
'sold' => '已售',
_ => '库存',
};
// 低库存提示(预警/缺货退出状态列后由数量染色承担)
bool _isLowStock(Inventory i) =>
i.status != 'sold' &&
(i.quantity == 0 ||
(i.minStock != null && i.quantity < i.minStock!));
void _openEditor(Inventory item) {
if (item.productId == null) return;
@@ -112,9 +127,20 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
unit: item.unit,
cost: item.unitPrice,
status: _statusOf(item),
inventoryId: item.id,
inventoryStatus: item.status,
onStatusChanged: () =>
ref.read(inventoryListProvider.notifier).reload(),
);
}
void _applyStatusFilter(String label) {
setState(() => _statusFilter = label);
ref
.read(inventoryListProvider.notifier)
.setStatus(_statusParam[label] ?? '');
}
// 成本仅管理员可见(与出库同口径):非管理员隐藏成本价/总价列、卡片单价、货值 KPI。
bool get _isAdmin => ref.watch(isAdminProvider);
bool _colAllowed(ColDef c) =>
@@ -149,7 +175,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
DsMenuItem(value: s, label: s, selected: s == _statusFilter),
],
);
if (v != null && mounted) setState(() => _statusFilter = v);
if (v != null && mounted) _applyStatusFilter(v);
}
// ── 多选漏斗(规格/系列/仓库) ──
@@ -223,7 +249,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
selected: _statusFilter == s,
caret: false,
onTap: () {
setState(() => _statusFilter = s);
_applyStatusFilter(s);
setSheet(() {});
}),
]),
@@ -512,12 +538,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
return '¥$buf${parts.length > 1 ? '.${parts[1]}' : ''}';
}
/// 状态徽章(DsBadge):派生 qty vs 安全库存 → 在售/预警/缺货
/// 状态徽章(DsBadge):三态落库 status 真相源
Widget _dsStatusBadge(Inventory item) {
return switch (_statusOf(item)) {
'缺货' => const DsBadge('缺货', tone: DsBadgeTone.danger),
'预警' => const DsBadge('预警', tone: DsBadgeTone.warn),
_ => const DsBadge('在售', tone: DsBadgeTone.ok),
return switch (item.status) {
'on_sale' => const DsBadge('在售', tone: DsBadgeTone.ok),
'sold' => const DsBadge('已售', tone: DsBadgeTone.muted),
_ => const DsBadge('库存', tone: DsBadgeTone.info),
};
}
@@ -526,11 +552,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Widget _inventoryCard(Inventory item) {
final t = context.tokens;
final status = _statusOf(item);
final qtyColor = switch (status) {
'缺货' => t.danger,
'预警' => t.warn,
_ => t.heading,
};
// 低库存数量染色(预警/缺货退出状态维度后由数量承担)
final qtyColor = !_isLowStock(item)
? t.heading
: item.quantity == 0
? t.danger
: t.warn;
final sub = [
if (item.productCode.isNotEmpty) item.productCode,
if (item.spec.isNotEmpty) item.spec,
@@ -626,11 +653,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
?.map((s) => s.name)
.toList() ??
[];
// 状态筛选已走服务端(setStatus);仓库仍为当前页客户端筛选
final filteredItems = items
.where((i) =>
(_filterWarehouse.isEmpty ||
_filterWarehouse.contains(i.warehouseName)) &&
(_statusFilter == '全部' || _statusOf(i) == _statusFilter))
_filterWarehouse.isEmpty ||
_filterWarehouse.contains(i.warehouseName))
.toList();
final hidden = _hiddenCols ?? <String>{};
@@ -739,12 +766,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
deltaTone: _mTone(qtyTone),
),
MKpiItem(
label: '缺货预警',
value: '${summary?.shortageCount ?? 0}',
label: '已售',
value: '${summary?.soldCount ?? 0}',
delta: '需补货 ${summary?.warningCount ?? 0}',
deltaTone: MKpiDeltaTone.warn,
selected: _statusFilter == '缺货',
onTap: () => setState(() => _statusFilter = '缺货'),
selected: _statusFilter == '已售',
onTap: () => _applyStatusFilter('已售'),
),
]),
);
@@ -780,15 +807,15 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
deltaTone: qtyTone,
),
DsKpi(
title: '缺货预警 · 点击筛选',
value: '${summary?.shortageCount ?? 0}',
icon: LucideIcons.triangleAlert,
title: '已售 · 点击筛选',
value: '${summary?.soldCount ?? 0}',
icon: LucideIcons.check,
tone: DsKpiTone.alert,
delta: '需补货 ${summary?.warningCount ?? 0}',
deltaTone: (summary?.warningCount ?? 0) > 0
? DsKpiDelta.down
: DsKpiDelta.neutral,
onTap: () => setState(() => _statusFilter = '缺货'),
onTap: () => _applyStatusFilter('已售'),
),
];
final row = <Widget>[];