feat(client): 库存列表 KPI 卡 + 状态徽章按原型重建(阶段3 首切)

新增可复用 widgets/kpi_card.dart:KpiCard(还原原型 .kpi/.ic,6 档色调
info/ok/blue/alert/warn/danger,token+AppDims 驱动)+ StatusPill(.badge
圆点软底)。库存查询页 3 张旧 _SummaryCard → 4 张 KPI 卡(商品总数/在库数量/
库存预警/缺货,商品总数可点清筛选)。状态徽章 正常/库存不足→在售/预警,
硬编码 FFEBEE/FFF3E0/E8F5E9 → dangerBg/warnBg/okSoft;出入库方向徽章同迁。
该屏 0 硬编码色残留,三主题随 token 换色。

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 06:56:45 +08:00
parent 80e63ca7f7
commit 5685a79c81
2 changed files with 244 additions and 145 deletions
@@ -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<InventoryListScreen> {
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<num>(0, (s, i) => s + i.quantity);
final cards = <Widget>[
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 = <Widget>[];
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<InventoryListScreen> {
}
}
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,
);
}
}
+170
View File
@@ -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-xshover(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)),
],
),
);
}
}