diff --git a/client/lib/widgets/ds/ds_atoms.dart b/client/lib/widgets/ds/ds_atoms.dart new file mode 100644 index 0000000..3eeeb06 --- /dev/null +++ b/client/lib/widgets/ds/ds_atoms.dart @@ -0,0 +1,215 @@ +// widgets/ds/ds_atoms.dart +// Flutter 组件库(ds=design-system)——一对一镜像原型 atoms.css 的原子。 +// 单一组件源:尺寸/间距/圆角/字号全引 AppDims,颜色全引 context.tokens(禁硬编码)。 +// 屏只组合这些组件,不再自己堆样式(见代码端真相源闸 check_ds_code)。 +import 'package:flutter/material.dart'; +import '../../core/theme/context_tokens.dart'; +import '../../core/theme/app_dims.g.dart'; + +// ── .btn ────────────────────────────────────────────────────────────────── +enum DsBtnVariant { ghost, primary, danger } + +/// 原型 .btn:h38 / pad 0 16 / r-md / fs-body / fw600 / gap7,图标 16。 +/// ghost=surface+border / primary=primary+on-primary / danger=danger+白。 +class DsButton extends StatelessWidget { + final String label; + final IconData? icon; + final VoidCallback? onPressed; + final DsBtnVariant variant; + final bool small; // .btn.sm:h32 / pad 0 12 / fs-sm + + const DsButton( + this.label, { + super.key, + this.icon, + this.onPressed, + this.variant = DsBtnVariant.ghost, + this.small = false, + }); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final (bg, fg, border) = switch (variant) { + DsBtnVariant.ghost => (t.surface, t.text, t.border), + DsBtnVariant.primary => (t.primary, t.onPrimary, t.primary), + DsBtnVariant.danger => (t.danger, t.onPrimary, t.danger), + }; + return Material( + color: bg, + borderRadius: BorderRadius.circular(AppDims.rMd), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: small ? 32 : 38, + padding: EdgeInsets.symmetric(horizontal: small ? 12 : 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppDims.rMd), + border: Border.all(color: border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 16, color: fg), + const SizedBox(width: 7), + ], + Text(label, + style: TextStyle( + color: fg, + fontSize: small ? AppDims.fsSm : AppDims.fsBody, + fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ); + } +} + +// ── .badge ──────────────────────────────────────────────────────────────── +enum DsBadgeTone { ok, danger, warn, info, accent, muted } + +/// 原型 .badge:h22 / pad 0 9 / r-pill / fs-sm fw600 + 前导圆点(6px, currentColor)。 +class DsBadge extends StatelessWidget { + final String label; + final DsBadgeTone tone; + const DsBadge(this.label, {super.key, this.tone = DsBadgeTone.ok}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final (bg, fg) = switch (tone) { + DsBadgeTone.ok => (t.successBg, t.success), + DsBadgeTone.danger => (t.dangerBg, t.danger), + DsBadgeTone.warn => (t.warnBg, t.warn), + DsBadgeTone.info => (t.infoSoft, t.primary), + DsBadgeTone.accent => (t.accentSoft, t.accent), + DsBadgeTone.muted => (t.borderSubtle, t.muted), + }; + return Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: + BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppDims.rPill)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: fg, shape: BoxShape.circle)), + const SizedBox(width: 5), + Text(label, + style: TextStyle( + color: fg, + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w600)), + ], + ), + ); + } +} + +// ── .chip ───────────────────────────────────────────────────────────────── +/// 原型 .chip:h34 / pad 0 12 / border r-md / surface / fs-sm / gap7。 +/// 选中值用 .cv(primary fw600);激活(on)边框 primary。配 PopupMenuButton 用。 +class DsChip extends StatelessWidget { + final String label; + final String? value; // 选中值(.cv);非空即 on 态 + final VoidCallback? onTap; + const DsChip({super.key, required this.label, this.value, this.onTap}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final on = value != null && value!.isNotEmpty; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: on ? t.primary : t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: TextStyle(fontSize: AppDims.fsSm, color: t.text)), + if (on) ...[ + const SizedBox(width: 7), + Text(value!, + style: TextStyle( + fontSize: AppDims.fsSm, + color: t.primary, + fontWeight: FontWeight.w600)), + ], + const SizedBox(width: 7), + Icon(Icons.keyboard_arrow_down, size: 14, color: t.faint), + ], + ), + ), + ); + } +} + +// ── .searchbox ────────────────────────────────────────────────────────────── +/// 原型 .searchbox:h34 / bg / border r-md / gap8 / pad 0 11,前导放大镜 14(faint)。 +class DsSearchBox extends StatelessWidget { + final TextEditingController? controller; + final String hint; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final double? width; + const DsSearchBox({ + super.key, + this.controller, + this.hint = '', + this.onChanged, + this.onSubmitted, + this.width, + }); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return SizedBox( + width: width, + height: 34, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: t.bg, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row( + children: [ + Icon(Icons.search, size: 14, color: t.faint), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + onSubmitted: onSubmitted, + style: TextStyle(fontSize: AppDims.fsBody, color: t.text), + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: hint, + hintStyle: + TextStyle(fontSize: AppDims.fsBody, color: t.faint), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/widgets/ds/ds_kpi.dart b/client/lib/widgets/ds/ds_kpi.dart new file mode 100644 index 0000000..86b77eb --- /dev/null +++ b/client/lib/widgets/ds/ds_kpi.dart @@ -0,0 +1,115 @@ +// widgets/ds/ds_kpi.dart — 原型 .kpi 卡(镜像 atoms.css)。 +import 'package:flutter/material.dart'; +import '../../core/theme/context_tokens.dart'; +import '../../core/theme/app_dims.g.dart'; + +/// 图标块 .ic 变体(软底 + 前景色)。 +enum DsKpiTone { info, ok, blue, alert } + +/// 环比方向 .d.up/.down(绿/红),neutral=纯文案(muted)。 +enum DsKpiDelta { up, down, neutral } + +/// 原型 .kpi:surface/border/r-lg/pad 15 16;右上 .ic(30×30 r-md); +/// .t(fs-sm muted) / .v(24 fw700 heading mono,alert→accent) / .d(fs-xs,up绿/down红)。 +class DsKpi extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final DsKpiTone tone; + final String? delta; + final DsKpiDelta deltaTone; + final VoidCallback? onTap; + + const DsKpi({ + super.key, + required this.title, + required this.value, + required this.icon, + this.tone = DsKpiTone.info, + this.delta, + this.deltaTone = DsKpiDelta.neutral, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final (icBg, icFg) = switch (tone) { + DsKpiTone.info => (t.infoSoft, t.primary), + DsKpiTone.ok => (t.okSoft, t.success), + DsKpiTone.blue => (t.infoSoft, t.brand400), + DsKpiTone.alert => (t.accentSoft, t.accent), + }; + final deltaColor = switch (deltaTone) { + DsKpiDelta.up => t.success, + DsKpiDelta.down => t.danger, + DsKpiDelta.neutral => t.muted, + }; + final card = Container( + padding: const EdgeInsets.fromLTRB(16, 15, 16, 15), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + 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)), + alignment: Alignment.center, + child: Icon(icon, size: 17, color: icFg), + ), + ], + ), + const SizedBox(height: 7), + Text(value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + fontFamily: 'monospace', + color: tone == DsKpiTone.alert ? t.accent : t.heading)), + if (delta != null) ...[ + const SizedBox(height: 5), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (deltaTone != DsKpiDelta.neutral) + Icon( + deltaTone == DsKpiDelta.up + ? Icons.arrow_drop_up + : Icons.arrow_drop_down, + size: 14, + color: deltaColor), + Text(delta!, + style: TextStyle(fontSize: AppDims.fsXs, color: deltaColor)), + ], + ), + ], + ], + ), + ); + if (onTap == null) return card; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector(onTap: onTap, child: card), + ); + } +}