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
+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)),
],
),
);
}
}