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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user