diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index 5e579b9..f960841 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -13,7 +13,9 @@ 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/mobile_list_card.dart'; +import '../../core/theme/app_dims.g.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../core/storage/column_prefs.dart'; import '../../widgets/page_scaffold.dart'; @@ -513,43 +515,73 @@ class _InventoryListScreenState extends ConsumerState { return Column( children: [ - // Summary cards(窄屏横向滚动,宽屏等分) + // KPI 卡(还原原型 .kpi):宽屏 4 等分,窄屏横向滚动 Builder(builder: (context) { - final w = context.isMobile ? 150.0 : null; - final cards = [ - _SummaryCard( - title: '商品总数', - value: '${result.total}', - unit: '种', - icon: Icons.inventory_2, - color: context.tokens.primary, - width: w), - const SizedBox(width: 12), - _SummaryCard( - title: '库存预警', - value: '$warningCount', - unit: '种', - icon: Icons.warning_amber, - color: context.tokens.accent, - width: w), - const SizedBox(width: 12), - _SummaryCard( - title: '缺货商品', - value: '$emptyCount', - unit: '种', - icon: Icons.remove_shopping_cart, - color: context.tokens.danger, - width: w), + final inStockQty = + items.fold(0, (s, i) => s + i.quantity); + final cards = [ + KpiCard( + title: '商品总数', + value: '${result.total}', + icon: Icons.inventory_2, + tone: KpiTone.info, + delta: '种 · 点击清筛选', + onTap: () { + setState(() { + _filterWarehouse = {}; + _filterSpec = {}; + _filterSeries = {}; + _searchCtrl.clear(); + }); + final n = ref.read(inventoryListProvider.notifier); + n.setKeyword(''); + n.setSpec([]); + n.setSeries([]); + }, + ), + KpiCard( + title: '在库数量', + value: inStockQty.toStringAsFixed(0), + icon: Icons.warehouse_outlined, + tone: KpiTone.blue, + delta: '件', + ), + KpiCard( + title: '库存预警', + value: '$warningCount', + icon: Icons.warning_amber, + tone: KpiTone.warn, + delta: warningCount > 0 ? '需补货' : '正常', + ), + KpiCard( + title: '缺货商品', + value: '$emptyCount', + icon: Icons.remove_shopping_cart, + tone: KpiTone.alert, + ), ]; + final mobile = context.isMobile; + final row = []; + for (var i = 0; i < cards.length; i++) { + if (i > 0) { + row.add(const SizedBox(width: AppDims.sp3)); + } + row.add(mobile + ? SizedBox(width: 160, child: cards[i]) + : Expanded(child: cards[i])); + } return Container( color: context.tokens.bg, - padding: const EdgeInsets.all(12), - child: context.isMobile + padding: const EdgeInsets.all(AppDims.sp3), + child: mobile ? SingleChildScrollView( scrollDirection: Axis.horizontal, - child: Row(children: cards), + child: IntrinsicHeight(child: Row(children: row)), ) - : Row(children: cards), + : IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: row)), ); }), const Divider(height: 1), @@ -1047,114 +1079,21 @@ class _InventoryListScreenState extends ConsumerState { } } -class _SummaryCard extends StatelessWidget { - final String title; - final String value; - final String unit; - final IconData icon; - final Color color; - - /// 固定宽度(窄屏横向滚动用);为 null 时占据等分宽度(Expanded,宽屏用)。 - final double? width; - - const _SummaryCard({ - required this.title, - required this.value, - required this.unit, - required this.icon, - required this.color, - this.width, - }); - - @override - Widget build(BuildContext context) { - final card = Container( - height: 72, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: context.tokens.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: context.tokens.border, width: 0.5), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: color, size: 22), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(title, - style: TextStyle( - fontSize: 12, color: context.tokens.muted)), - const SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text(value, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: color)), - if (unit.isNotEmpty) ...[ - const SizedBox(width: 2), - Text(unit, - style: TextStyle( - fontSize: 12, - color: context.tokens.muted)), - ], - ], - ), - ], - ), - ], - ), - ); - return width != null - ? SizedBox(width: width, child: card) - : Expanded(child: card); - } -} - class _InventoryStatusBadge extends StatelessWidget { final Inventory item; const _InventoryStatusBadge(this.item); @override Widget build(BuildContext context) { - final String status; - final Color bg; - final Color fg; + final t = context.tokens; + // 状态派生 qty vs min_stock(在售 / 预警 / 缺货),软底+圆点全走 token。 if (item.quantity == 0) { - status = '缺货'; - bg = const Color(0xFFFFEBEE); - fg = context.tokens.danger; - } else if (item.minStock != null && item.quantity < item.minStock!) { - status = '库存不足'; - bg = const Color(0xFFFFF3E0); - fg = context.tokens.accent; - } else { - status = '正常'; - bg = const Color(0xFFE8F5E9); - fg = context.tokens.success; + return StatusPill(label: '缺货', color: t.danger, background: t.dangerBg); } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: - BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)), - child: Text(status, - style: TextStyle( - color: fg, fontSize: 12, fontWeight: FontWeight.w500)), - ); + if (item.minStock != null && item.quantity < item.minStock!) { + return StatusPill(label: '预警', color: t.warn, background: t.warnBg); + } + return StatusPill(label: '在售', color: t.success, background: t.okSoft); } } @@ -1164,22 +1103,12 @@ class _DirectionBadge extends StatelessWidget { @override Widget build(BuildContext context) { + final t = context.tokens; final isIn = direction == 'in'; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isIn - ? const Color(0xFFE8F5E9) - : const Color(0xFFFFEBEE), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - isIn ? '入库' : '出库', - style: TextStyle( - color: isIn ? context.tokens.success : context.tokens.danger, - fontSize: 12, - fontWeight: FontWeight.w500), - ), + return StatusPill( + label: isIn ? '入库' : '出库', + color: isIn ? t.success : t.danger, + background: isIn ? t.okSoft : t.dangerBg, ); } } diff --git a/client/lib/widgets/kpi_card.dart b/client/lib/widgets/kpi_card.dart new file mode 100644 index 0000000..72e8140 --- /dev/null +++ b/client/lib/widgets/kpi_card.dart @@ -0,0 +1,170 @@ +import 'dart:ui' show FontFeature; +import 'package:flutter/material.dart'; +import '../core/theme/context_tokens.dart'; +import '../core/theme/app_dims.g.dart'; + +/// KPI 卡(还原原型 `.kpi`):左上标题、右上图标芯片、下方大数值、可选同比/说明。 +/// 色调走 token(禁硬编码 hex),尺寸走 AppDims(禁裸 px)。 +/// 原型规格:surface 面 / 1px border / r-lg 圆角 / padding 15·16; +/// `.ic` 30×30 r-md;`.v` 24/700 mono;`.d` fs-xs;hover(click) 描边 brand400。 +enum KpiTone { info, ok, blue, alert, warn, danger } + +enum KpiTrend { none, up, down } + +class KpiCard extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final KpiTone tone; + + /// 同比/说明文案(如「▲ 2.3% 较上月」「需补货 6 项」),null 不显示。 + final String? delta; + final KpiTrend trend; + + /// 点击(如清筛选 / 跳转);null 时为静态卡,不显示 hover。 + final VoidCallback? onTap; + + const KpiCard({ + super.key, + required this.title, + required this.value, + required this.icon, + this.tone = KpiTone.info, + this.delta, + this.trend = KpiTrend.none, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final (Color icBg, Color icFg) = switch (tone) { + KpiTone.info => (t.infoSoft, t.primary), + KpiTone.ok => (t.okSoft, t.success), + KpiTone.blue => (t.infoSoft, t.brand400), + KpiTone.alert => (t.accentSoft, t.accent), + KpiTone.warn => (t.warnBg, t.warn), + KpiTone.danger => (t.dangerBg, t.danger), + }; + final valueColor = switch (tone) { + KpiTone.alert => t.accent, + KpiTone.danger => t.danger, + _ => t.heading, + }; + final deltaColor = switch (trend) { + KpiTrend.up => t.success, + KpiTrend.down => t.danger, + KpiTrend.none => t.muted, + }; + + final card = Container( + padding: const EdgeInsets.symmetric(horizontal: AppDims.sp4, vertical: 15), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text(title, + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + ), + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: icBg, + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Icon(icon, size: 17, color: icFg), + ), + ], + ), + const SizedBox(height: 7), + Text( + value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: valueColor, + letterSpacing: .3, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + if (delta != null) ...[ + const SizedBox(height: 5), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (trend != KpiTrend.none) + Icon( + trend == KpiTrend.up + ? Icons.arrow_drop_up + : Icons.arrow_drop_down, + size: 16, + color: deltaColor, + ), + Text(delta!, + style: TextStyle(fontSize: AppDims.fsXs, color: deltaColor)), + ], + ), + ], + ], + ), + ); + + if (onTap == null) return card; + return InkWell( + borderRadius: BorderRadius.circular(AppDims.rLg), + onTap: onTap, + child: card, + ); + } +} + +/// 状态徽章(还原原型 `.badge`:圆点 + 文字同色 + 软底 pill)。 +/// 用于库存「在售/预警/缺货」等派生状态;色与软底全走 token。 +class StatusPill extends StatelessWidget { + final String label; + final Color color; + final Color background; + const StatusPill( + {super.key, + required this.label, + required this.color, + required this.background}); + + @override + Widget build(BuildContext context) { + return Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(AppDims.rPill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 5), + Text(label, + style: TextStyle( + color: color, + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w600)), + ], + ), + ); + } +}