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
+8
View File
@@ -9,6 +9,9 @@ class Inventory {
final int? productId;
final int? stockInItemId;
final double quantity;
/// 库存三态(2026-07-07):stock=库存 / on_sale=在售(公开酒单) / sold=已售(卖光留痕)
final String status;
final String productCode;
final String productName;
final String series;
@@ -31,6 +34,7 @@ class Inventory {
this.productId,
this.stockInItemId,
required this.quantity,
this.status = 'stock',
this.productCode = '',
this.productName = '',
this.series = '',
@@ -54,6 +58,7 @@ class Inventory {
productId: (json['product_id'] as num?)?.toInt(),
stockInItemId: (json['stock_in_item_id'] as num?)?.toInt(),
quantity: (json['quantity'] as num).toDouble(),
status: json['status'] as String? ?? 'stock',
productCode: json['product_code'] as String? ?? '',
productName: json['product_name'] as String? ?? '',
series: json['series'] as String? ?? '',
@@ -77,6 +82,7 @@ class InventorySummary {
final int skuCount;
final double stockValue;
final double inStockQty;
final int soldCount; // 已售留痕行数(status=sold
final int shortageCount;
final int warningCount;
// 月初快照(KPI 环比):由 inventory_logs 反推
@@ -87,6 +93,7 @@ class InventorySummary {
this.skuCount = 0,
this.stockValue = 0,
this.inStockQty = 0,
this.soldCount = 0,
this.shortageCount = 0,
this.warningCount = 0,
this.lastMonthSku = 0,
@@ -97,6 +104,7 @@ class InventorySummary {
skuCount: (j['sku_count'] as num?)?.toInt() ?? 0,
stockValue: (j['stock_value'] as num?)?.toDouble() ?? 0,
inStockQty: (j['in_stock_qty'] as num?)?.toDouble() ?? 0,
soldCount: (j['sold_count'] as num?)?.toInt() ?? 0,
shortageCount: (j['shortage_count'] as num?)?.toInt() ?? 0,
warningCount: (j['warning_count'] as num?)?.toInt() ?? 0,
lastMonthSku: (j['last_month_sku'] as num?)?.toInt() ?? 0,
@@ -30,6 +30,7 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
int? _warehouseId;
String _keyword = '';
String _code = ''; // 商品编码独立筛选(原型 codeInput)
String _status = ''; // 三态筛选(空=后端默认排除已售)
List<String> _series = [];
List<String> _spec = [];
PageResult<Inventory>? _cache;
@@ -53,6 +54,7 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
warehouseId: _warehouseId,
keyword: _keyword.isEmpty ? null : _keyword,
code: _code.isEmpty ? null : _code,
status: _status.isEmpty ? null : _status,
series: _series.isEmpty ? null : _series,
spec: _spec.isEmpty ? null : _spec,
page: _page,
@@ -89,6 +91,12 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
reload();
}
void setStatus(String status) {
_status = status;
_page = 1;
reload();
}
void setSeries(List<String> series) {
_series = series;
_page = 1;
@@ -16,6 +16,7 @@ class InventoryRepository {
String? code,
List<String>? series,
List<String>? spec,
String? status, // 三态筛选:stock/on_sale/sold(逗号可多值);空=后端默认(排除已售)
int page = 1,
int pageSize = 50,
}) async {
@@ -29,6 +30,7 @@ class InventoryRepository {
if (code != null && code.isNotEmpty) 'code': code,
if (series != null && series.isNotEmpty) 'series': series.join(','),
if (spec != null && spec.isNotEmpty) 'spec': spec.join(','),
if (status != null && status.isNotEmpty) 'status': status,
};
final resp = await _client.get('/inventory', params: params);
return PageResult.fromJson(
@@ -67,6 +69,18 @@ class InventoryRepository {
}
}
/// 库存⇄在售 挂售切换(status 仅收 stock/on_salesold 由出库流程专属)
Future<void> updateStatus(int id, String status) async {
try {
await _client.put('/inventory/$id/status', data: {'status': status});
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '状态更新失败',
statusCode: e.response?.statusCode,
);
}
}
Future<void> updateRemark(int id, String remark) async {
try {
await _client.put('/inventory/$id/remark', data: {'remark': remark});
@@ -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>[];
+55
View File
@@ -0,0 +1,55 @@
// widgets/ds/ds_switch.dart — 原型 atoms.css .switch 的 Flutter 对照(2026-07-07 登记)。
// 38×22 pill 轨道 + 18px 圆点滑块:on=primary / off=border / disabled 45% 透明。
// 用于布尔即时生效场景(库存⇄在售挂售切换);不同于 Material Switch 的尺寸与配色。
import 'package:flutter/material.dart';
import '../../core/theme/context_tokens.dart';
class DsSwitch extends StatelessWidget {
final bool value;
final ValueChanged<bool>? onChanged; // null = disabled
const DsSwitch({super.key, required this.value, this.onChanged});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final enabled = onChanged != null;
return Semantics(
toggled: value,
button: true,
child: GestureDetector(
onTap: enabled ? () => onChanged!(!value) : null,
child: Opacity(
opacity: enabled ? 1 : .45,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 38,
height: 22,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: value ? t.primary : t.border,
borderRadius: BorderRadius.circular(999),
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: t.surface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: t.shadow, blurRadius: 3, offset: const Offset(0, 1)),
],
),
),
),
),
),
),
);
}
}
+3 -1
View File
@@ -5,8 +5,10 @@ import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
const Map<String, IconData> kStatusIcons = {
// 库存状态
// 库存状态(三态落库 2026-07-07:在售/库存/已售;预警/缺货为数量派生提示)
'在售': LucideIcons.shoppingCart, // i-cart
'库存': LucideIcons.box, // i-box
'已售': LucideIcons.check, // i-check
'预警': LucideIcons.triangleAlert, // i-alert
'缺货': LucideIcons.x, // i-close
// 单据状态
+78 -10
View File
@@ -20,10 +20,12 @@ import '../core/theme/app_dims.g.dart';
import '../core/theme/context_tokens.dart';
import '../models/product.dart';
import '../models/product_image.dart';
import '../providers/inventory_provider.dart' show inventoryRepositoryProvider;
import '../providers/product_option_provider.dart';
import '../providers/product_provider.dart';
import '../providers/shop_provider.dart' show shopInfoProvider;
import 'ds/ds_atoms.dart';
import 'ds/ds_switch.dart';
import 'ds/grid_combo_cell.dart';
import 'ds/m_sheet.dart';
import 'searchable_option_field.dart';
@@ -41,7 +43,10 @@ Future<void> showProductEditorDrawer(
double? qty,
String? unit,
double? cost,
String? status, // 在售 / 预警 / 缺货
String? status, // 在售 / 库存 / 已售(三态显示标签)
int? inventoryId, // 库存行 id:非空时状态行显示挂售 switch
String? inventoryStatus, // stock / on_sale / soldswitch 初始态)
VoidCallback? onStatusChanged, // switch 切换成功后回调(列表刷新)
}) {
// 窄屏:底部 sheet 形态(原型 m-inventory openItem 内嵌公开页编辑)
if (context.isMobile) {
@@ -56,7 +61,10 @@ Future<void> showProductEditorDrawer(
qty: qty,
unit: unit,
cost: cost,
status: status),
status: status,
inventoryId: inventoryId,
inventoryStatus: inventoryStatus,
onStatusChanged: onStatusChanged),
),
);
}
@@ -83,7 +91,10 @@ Future<void> showProductEditorDrawer(
qty: qty,
unit: unit,
cost: cost,
status: status),
status: status,
inventoryId: inventoryId,
inventoryStatus: inventoryStatus,
onStatusChanged: onStatusChanged),
),
),
),
@@ -106,12 +117,18 @@ class _ProductEditorDrawer extends ConsumerStatefulWidget {
final String? unit;
final double? cost;
final String? status;
final int? inventoryId;
final String? inventoryStatus;
final VoidCallback? onStatusChanged;
const _ProductEditorDrawer({
required this.productId,
this.qty,
this.unit,
this.cost,
this.status,
this.inventoryId,
this.inventoryStatus,
this.onStatusChanged,
});
@override
@@ -125,6 +142,32 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
bool _uploading = false;
bool _saving = false;
// 库存三态挂售 switchstock⇄on_salesold 只读无 switch
late String? _invStatus = widget.inventoryStatus;
bool _statusSaving = false;
Future<void> _toggleOnSale(bool on) async {
final id = widget.inventoryId;
if (id == null || _statusSaving) return;
final next = on ? 'on_sale' : 'stock';
final prev = _invStatus;
setState(() {
_invStatus = next;
_statusSaving = true;
});
try {
await ref.read(inventoryRepositoryProvider).updateStatus(id, next);
widget.onStatusChanged?.call();
} catch (e) {
if (mounted) {
setState(() => _invStatus = prev);
showDsToast(context, '状态更新失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _statusSaving = false);
}
}
// 介绍选择态(.pe-pick / .pe-text
String? _introTitle; // 已选介绍库标题;null → 占位「从介绍库选择…」
int? _introDocId;
@@ -366,15 +409,40 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
}
}
if ((widget.status ?? '').isNotEmpty) {
// 状态行:三态徽章 + 挂售 switch(在售⇄库存 即时生效;已售只读;readonly 禁用)
final invStatus = _invStatus;
final statusLabel = switch (invStatus) {
'on_sale' => '在售',
'sold' => '已售',
'stock' => '库存',
_ => widget.status ?? '',
};
if (statusLabel.isNotEmpty) {
final badge = DsBadge(statusLabel,
tone: switch (statusLabel) {
'在售' => DsBadgeTone.ok,
'已售' => DsBadgeTone.muted,
'库存' => DsBadgeTone.info,
'缺货' => DsBadgeTone.danger,
'预警' => DsBadgeTone.warn,
_ => DsBadgeTone.ok,
});
final canToggle = widget.inventoryId != null &&
invStatus != null &&
invStatus != 'sold' &&
!ref.watch(isReadonlyProvider);
rows.add((
'状态',
DsBadge(widget.status!,
tone: switch (widget.status) {
'缺货' => DsBadgeTone.danger,
'预警' => DsBadgeTone.warn,
_ => DsBadgeTone.ok,
})
Row(mainAxisSize: MainAxisSize.min, children: [
badge,
if (widget.inventoryId != null && invStatus != null && invStatus != 'sold') ...[
const SizedBox(width: 10),
DsSwitch(
value: invStatus == 'on_sale',
onChanged: canToggle && !_statusSaving ? _toggleOnSale : null,
),
],
])
));
}
return Column(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 142 KiB

@@ -27,6 +27,7 @@ const _items = [
id: 1,
productId: 1,
quantity: 128,
status: 'on_sale',
productCode: 'MT-FT-500',
productName: '茅台 飞天 53°',
series: '飞天',
@@ -153,6 +154,7 @@ const _items = [
id: 8,
productId: 8,
quantity: 0,
status: 'sold',
productCode: 'FJ-QH20-500',
productName: '汾酒 青花 20',
series: '青花20',
@@ -249,6 +251,7 @@ List<Override> _overrides() => [
stockValue: 2640000,
inStockQty: 18920,
shortageCount: 17,
soldCount: 17,
warningCount: 6,
// 月初快照 → 环比 ▲2.3% / ▲1.1% / ▼0.6%(对齐原型示例文案)
lastMonthSku: 1255,
@@ -22,6 +22,7 @@ const _items = [
id: 1,
productId: 1,
quantity: 128,
status: 'on_sale',
productCode: 'MT-FT-500',
productName: '茅台 飞天 53°',
series: '飞天',
@@ -76,6 +77,7 @@ const _items = [
id: 8,
productId: 8,
quantity: 0,
status: 'sold',
productCode: 'FJ-QH20-500',
productName: '汾酒 青花 20',
series: '青花20',
@@ -172,6 +174,7 @@ List<Override> _overrides() => [
stockValue: 2640000,
inStockQty: 18920,
shortageCount: 17,
soldCount: 17,
warningCount: 6,
lastMonthSku: 1255,
lastMonthValue: 2611276,
@@ -51,6 +51,7 @@ class _FakeInvRepo implements InventoryRepository {
String? code,
List<String>? series,
List<String>? spec,
String? status,
int page = 1,
int pageSize = 50,
}) async =>
@@ -99,6 +99,7 @@ class _FakeInventoryRepository extends InventoryRepository {
String? code,
List<String>? series,
List<String>? spec,
String? status,
int page = 1,
int pageSize = 50,
}) async {