From 9ce8934412eb9b429fb9520bcd99b71c2381d4b8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 25 Jun 2026 21:30:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20ds=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=BA=93=E5=9C=B0=E5=9F=BA=EF=BC=88=E9=95=9C=E5=83=8F=20atoms.?= =?UTF-8?q?css=20=E5=8E=9F=E5=AD=90=EF=BC=8C=E5=B1=8F=E9=87=8D=E5=86=99?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按「先建单一组件源、屏只组合不重写样式」重立组件层: - 新建 widgets/ds/:DsButton(.btn ghost/primary/danger/sm) · DsBadge(.badge+圆点) · DsChip(.chip 状态筹码) · DsSearchBox(.searchbox) · DsKpi(.kpi 含 .ic 右上块+环比 .d) - 每个组件一对一对照 atoms.css 尺寸/间距/圆角/字号,颜色/尺寸全引 token(0 硬编码,颜色闸过) - 现有 KpiCard/StatusPill 等逐步迁入 ds/ 废弃 下一步:建 DsTable(表格+列头漏斗+分页+状态徽章用 DsBadge)→ 用 ds 组件重写库存屏。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX --- client/lib/widgets/ds/ds_atoms.dart | 215 ++++++++++++++++++++++++++++ client/lib/widgets/ds/ds_kpi.dart | 115 +++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 client/lib/widgets/ds/ds_atoms.dart create mode 100644 client/lib/widgets/ds/ds_kpi.dart 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), + ); + } +}