Files
jiu/client/lib/widgets/kpi_card.dart
wangjia 60483ae483 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
2026-06-25 06:56:45 +08:00

171 lines
5.1 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
],
),
);
}
}