11 · 组件与主题
目标:知道 88 个 He 组件里哪个能直接用(不再重复造轮子),以及所有样式取值都走
AppTokens。⚠️ 重要:项目里有两份主题文档(
.agents/skills/theme-usage与docs/theme-guide.md)token 命名已过时。 本文的 token 表全部从app_tokens.dart当前代码核实,以本文为准。
开篇:前端概念对照
| 前端概念 | 本项目 |
|---|---|
| UI 组件库(antd / vant) | he_components(自研,88 个)+ tdesign_flutter |
import { Button } from 'antd' | import 'package:haierenergy/he_components/he_components.dart';(单一 barrel) |
| CSS 变量 / design token | AppTokens |
var(--color-primary) | tokens.brandPrimaryDefault |
var(--spacing-md) | AppTokens.spacingMd |
font-size: 14px | AppTokens.bodyMdRegular |
<img src="..."> | Assets.images.xxx.image() |
| 图标字体 / SVG | AppIcon(name: 'icon_xxx') |
一、he_components 组件库
唯一导入入口
import 'package:haierenergy/he_components/he_components.dart';⚠️ 禁止按具体路径导入(import '.../src/widgets/he_button.dart')——一律走 barrel。
组件总览(88 个,barrel 导出)
lib/he_components/he_components.dart 共 105 行、91 条 export(2 个主题文件 + 88 个组件 + 1 个外部类型再导出)。
按钮类
| 组件 | 用途 | 前端类比 |
|---|---|---|
he_button | 通用按钮 | <Button> |
he_loading_button | 带 loading 态 | <Button loading> |
he_fab | 悬浮按钮 | Fab |
he_back_top | 回到顶部 | BackTop |
输入 / 表单类
| 组件 | 用途 |
|---|---|
he_input | 输入框 |
he_textarea | 多行输入 |
he_form | 表单容器 |
he_search_bar | 搜索框 |
he_select_item | 选择项 |
he_dropdown_select | 下拉选择 |
he_dropdown_menu | 下拉菜单 |
he_picker | 选择器 |
he_date_picker / he_date_picker_sheet | 日期选择 |
he_calendar | 日历 |
he_cascader | 级联选择 |
he_tree_select | 树形选择 |
he_multi_select_picker | 多选 |
he_checkbox / he_radio | 复选 / 单选 |
he_switch | 开关 |
he_slider | 滑块 |
he_rate | 评分 |
he_stepper | 步进器 |
he_upload | 上传 |
he_time_counter | 倒计时(验证码) |
导航类
| 组件 | 用途 |
|---|---|
he_nav_bar | 导航栏 |
he_tab_bar | Tab 栏 |
he_bottom_tab_bar | 底部导航 |
he_side_bar | 侧边栏 |
he_indexes | 索引栏(城市列表) |
he_link | 链接 |
he_keep_alive_tab | Tab 保活 |
展示类
| 组件 | 用途 |
|---|---|
he_text | 文本 |
he_icon / he_image / he_image_group | 图标 / 图片 / 图片组 |
he_image_preview | 图片预览 |
he_network_image | 网络图片(带缓存/占位) |
he_avatar | 头像 |
he_tag / he_badge | 标签 / 徽标 |
he_cell / he_cell_group | 单元格 / 分组 |
he_kv_row | 键值对行 |
he_table | 表格 |
he_detail_card | 详情卡片 |
he_menu_list | 菜单列表 |
he_steps | 步骤条 |
he_progress | 进度条 |
he_notice_bar | 通知栏 |
he_result | 结果页 |
he_inline_leading_text | 行内前置文本 |
容器 / 布局类
| 组件 | 用途 |
|---|---|
he_list_scaffold | 列表页脚手架(导航+搜索+Tab) |
he_tabbed_detail_scaffold | Tab 详情页脚手架 |
he_collapse | 折叠面板 |
he_swipe_cell | 滑动单元格(侧滑操作) |
he_divider | 分割线 |
he_footer | 页脚 |
he_glass_effect | 毛玻璃效果 |
反馈 / 弹层类
| 组件 | 用途 |
|---|---|
he_dialog / he_dialog_extra | 对话框 |
he_action_sheet | 动作面板 |
he_bottom_sheet_scaffold | 底部弹层脚手架 |
he_list_bottom_sheet | 列表型底部弹层 |
he_option_select_sheet | 选项选择弹层 |
he_search_select_sheet | 搜索选择弹层 |
he_popover | 气泡卡片 |
he_message | 消息提示 |
he_loading | Toast / Loading 遮罩 |
he_loading_widget / he_loading_indicator | 加载指示 |
he_empty / he_empty_state | 空态 |
he_drawer | 抽屉 |
he_easy_refresh_indicators | 下拉刷新/上拉加载指示器 |
he_reusable_list | 复用列表 |
he_filter_scroll_bar | 筛选滚动条 |
he_locale_dialog / he_locale_switcher | 语言切换 |
he_theme_switcher | 主题切换 |
骨架屏类
he_loading_skeleton / he_skeleton_box / he_skeleton_list_view / he_sheet_skeleton / he_list_skeleton
图表类
he_bar_chart(柱状)/ he_line_chart(折线)/ he_donut_chart(环形)
高频组件用法
// 按钮
HeButton(text: l10n.t('submit'), onPressed: _onSubmit)
HeLoadingButton(isLoading: state.isLoading, text: '...', onPressed: _onSubmit)
// 提示
HeLoading.showToast(l10n.t('tip'))
HeLoading.showSuccess(l10n.t('success'))
HeLoading.showError(l10n.t('failed'))
// 确认弹窗
final ok = await HeDialog.confirm(context, title: '...', content: '...');
// 单元格组
HeCellGroup(cells: [
HeCell(title: '设备名', value: '逆变器01'),
HeCell(title: '状态', value: '在线'),
])
// 图片
Assets.images.home.banner.image() // 本地
HeNetworkImage(url: url) // 网络⚠️ 造轮子前先查这里
新增任何 UI 控件前,先确认 he_components 里有没有。重复造轮子是 code review 高频打回项。
二、AppTokens 体系
两种等价取法
final tokens = AppTokens.of(context); // 定义于 app_tokens.dart:22
final tokens = context.appTokens; // extension,app_tokens.dart:545(推荐)架构四层
Figma → L1 (app_colors.gen.dart) → L2 (app_theme_data.dart) → L3 (app_tokens.dart) → 业务| 层 | 文件 | 说明 |
|---|---|---|
| L1 | src/themes/app_colors.gen.dart | Figma 镜像,自动生成,勿手改 |
| L2 | src/themes/app_theme_data.dart | light/dark 快照 |
| L3 | src/themes/app_tokens.dart | 业务唯一入口 |
业务代码只碰 L3。
三、Token 速查表(以代码为准)
⚠️ 命名变更提示
项目早期用 spacerMd、radiusM、bodyMedium 这类命名,现已改为 spacingMd、radiusLarge、bodyMdRegular。
部分旧文档(含 .agents/skills/theme-usage)仍在写旧名,以本文为准。
间距(9 档,静态 getter)
AppTokens.spacingXxs // 4
AppTokens.spacingXs // 8
AppTokens.spacingSm // 12
AppTokens.spacingMd // 16
AppTokens.spacingLg // 20
AppTokens.spacingXl // 24
AppTokens.spacingXxl // 32
AppTokens.spacingXxxl // 40
AppTokens.spacingXxxxl // 48用法:
Padding(padding: EdgeInsets.all(AppTokens.spacingMd))
SizedBox(height: AppTokens.spacingSm)圆角(静态 getter)
AppTokens.radiusSmall // 4
AppTokens.radiusDefault // 8
AppTokens.radiusLarge // 10
AppTokens.radiusExtraLarge // 20
AppTokens.radiusExtraExtraLarge // 更大
AppTokens.radiusExtraExtraExtraLarge // 更大
AppTokens.radiusFull // 9999(胶囊/圆形)用法:
BorderRadius.circular(AppTokens.radiusLarge)项目实例 —— paged_list_body.dart:173-180:
decoration: BoxDecoration(
color: tokens.white,
borderRadius: BorderRadius.circular(AppTokens.radiusLarge),
),字体(静态 getter,×3 字重)
命名规则:{title|body}{Xl|Lg|Md|Sm|Xs}{Regular|Medium|Bold}
// Title 系列
AppTokens.titleXlRegular / titleXlMedium / titleXlBold
AppTokens.titleLgRegular / titleLgMedium / titleLgBold
AppTokens.titleMdRegular / titleMdMedium / titleMdBold
AppTokens.titleSmRegular / titleSmMedium / titleSmBold
AppTokens.titleXsRegular / titleXsMedium / titleXsBold
// Body 系列
AppTokens.bodyXlRegular / bodyXlMedium / bodyXlBold
AppTokens.bodyLgRegular / bodyLgMedium / bodyLgBold
AppTokens.bodyMdRegular / bodyMdMedium / bodyMdBold用法:
Text('标题', style: AppTokens.titleMdMedium)
Text('正文', style: AppTokens.bodyMdRegular.copyWith(color: tokens.textSecondary))项目实例 —— paged_list_body.dart:193-197:
Text(
l10n.t('bTerminal.nomore'),
style: AppTokens.bodyMdRegular.copyWith(color: tokens.textOnSurfaceHeading),
)颜色(实例 getter,跟随亮/暗切换)
⚠️ 颜色必须先取 tokens 实例(context.appTokens),不能像间距那样静态访问。
品牌色
tokens.brandPrimaryDefault // 主品牌色
tokens.brandSecondaryDefault // 次品牌色
tokens.brandPrimaryDisable // 禁用态状态色
tokens.statusDangerDefault / statusDangerActive / statusDangerSubtle
tokens.statusWarningDefault / statusWarningSubtle
tokens.statusSuccessDefault / statusSuccessSubtle
tokens.statusNeutralDefault / statusNeutralSubtle文字色(语义化)
tokens.textPrimary
tokens.textSecondary
tokens.textPlaceholder
tokens.textDisabled
tokens.textInverse
tokens.textOnSurfaceDefault
tokens.textOnSurfaceEmphasis
tokens.textOnSurfaceMutedAlt
tokens.textOnSurfaceHeading // ⭐ 最常用
tokens.textOnSurfaceTertiary
tokens.textOnSurfaceInstruction
tokens.textOnSurfaceRemark背景 / 表面色
tokens.surfaceBackground
tokens.surfaceColor
tokens.surfacePageWarm
tokens.surfacePageNeutral
tokens.surfaceInputDefault
tokens.surfaceStatusCard
tokens.surfaceCardOverlay
tokens.surfaceScrim
tokens.white边框色
tokens.borderDefault
tokens.borderTertiary标签色(成对使用)
tokens.tagSuccessText / tagSuccessBackground
tokens.tagWarningText / tagWarningBackground
tokens.tagErrorText / tagErrorBackground
tokens.tagBrandText / tagBrandBackground项目自定义色
tokens.suggestion
tokens.barrier // 遮罩背景
tokens.navBarGradientEnd // 导航栏渐变(配合 Start)
tokens.buttonPrimaryText
tokens.chartLabelBackground / chartLabelCard / chartLabelVariables图标 / 组件尺寸(const,可用于 const 表达式)
AppTokens.iconS // 16
AppTokens.iconM // 24
AppTokens.iconL // 32
AppTokens.iconXL // 48
AppTokens.buttonHeight // 48
AppTokens.loadingIndicatorSize // 32
AppTokens.dividerThickness // 1
AppTokens.dialogMaxWidth // 320图表尺寸
chartLineWidth / chartDotRadius / chartLegendBarHeight / chartDefaultHeight 等。
四、const 兼容性(高频错误)
| 类别 | 能否 const | 示例 |
|---|---|---|
| 图标/组件/图表尺寸 | ✅ 可以 | AppTokens.iconM、AppTokens.buttonHeight |
| 间距 / 圆角 | ❌ 不行 | AppTokens.spacingMd、AppTokens.radiusLarge |
| 字体 TextStyle | ❌ 不行 | AppTokens.bodyMdRegular |
| 颜色(实例 getter) | ❌ 不行 | tokens.textPrimary |
// ❌ 编译错误
const EdgeInsets.all(AppTokens.spacingMd)
const TextStyle(fontSize: AppTokens.bodyMdRegular.fontSize)
const SizedBox(height: AppTokens.spacingSm)
// ✅ 正确
EdgeInsets.all(AppTokens.spacingMd)
AppTokens.bodyMdRegular.copyWith(color: tokens.textSecondary)
SizedBox(height: AppTokens.spacingSm)
// ✅ const 字段可以用在 const 表达式
const SizedBox(height: AppTokens.buttonHeight)原因:间距/圆角/字体都是 getter(内部走 ScreenUtil 缩放),不是编译期常量。
构造参数默认值场景走初始化列表:
class MyWidget extends StatelessWidget {
MyWidget({EdgeInsets? padding})
: padding = padding ?? EdgeInsets.all(AppTokens.spacingMd);
final EdgeInsets padding;
}五、屏幕适配
配置
lib/main.dart:370-371:
ScreenUtilInit(
designSize: const Size(750, 1624), // 设计稿基准
minTextAdapt: true,
...
)文字缩放固定
lib/main.dart:391-395:
MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),
child: content,
)App 字体不跟随系统字体大小。
⚠️ 禁止在页面层用 .w / .h / .sp / .r
SizedBox(height: 32.h) // ❌ 禁止
SizedBox(height: AppTokens.spacingXxl) // ✅ 正确ScreenUtil 双轨制
| 对象 | 处理 |
|---|---|
| 业务 UI | 走 AppTokens.*(已含缩放) |
| TDesign 组件内部 | 固定 dp,不要外套 .w/.h |
TDesign 内部是固定 dp,外层再缩放会造成双重缩放——这是「TDesign 假溢出」的根因(见 02 章)。
六、Dark 模式
实现机制
lib/main.dart:217-231 注入两套主题:
final tdLight = TDSemanticThemeData.light;
final tdDark = TDSemanticThemeData.dark;
const appTokensLight = AppTokens(appThemeData: AppThemeData.light);
final appTokensDark = AppTokens(appThemeData: AppThemeData.dark);
setState(() {
_lightTheme = AppTheme.light(AppThemeData.light).copyWith(
extensions: [tdLight, AppThemeData.light, appTokensLight],
);
_darkTheme = AppTheme.dark(AppThemeData.dark).copyWith(
extensions: [tdDark, AppThemeData.dark, appTokensDark],
);
_themeLoaded = true;
});颜色通过 ThemeExtension 机制注入——这就是 context.appTokens 能取到值的原因。
Dark 值从哪来
L2 的 _generateDark 按色阶语义从 light 推导:
| 语义 | 算法 |
|---|---|
| 浅色档 / 页面背景 | 明度反转 |
正常档(*Default 等) | 提亮 +15% |
| 黑白半透明(文字/分割线) | 黑→白保留 alpha |
| 白文字 | 保持 light 值 |
也可通过 _kFigmaDarkOverrides 逐字段覆盖。
业务代码要注意什么
什么都不用做——只要用 tokens.xxx 取色,亮暗自动切换。
⚠️ 唯一禁忌:硬编码颜色。
Container(color: Color(0xFF0073E5)) // ❌ Dark 模式下不会变
Container(color: tokens.brandPrimaryDefault) // ✅切换
ref.read(themeModeNotifierProvider.notifier).setMode(ThemeMode.dark);ThemeModeNotifier(lib/config/providers/theme_mode_provider.dart:35)是 keepAlive,持久化到 SharedPreferences。
七、图片资源
强类型引用(唯一正确写法)
import 'package:haierenergy/generated/assets.gen.dart';
Assets.images.home.banner.image()
Assets.images.auth.logo.image(width: 100)⚠️ 禁止字面字符串:
Image.asset('assets/images/home/banner.png') // ❌ 禁止
AssetImage('assets/images/home/banner.png') // ❌ 禁止原因:flutter_gen 生成强类型访问层,改名/删除时编译期就能发现,且 IDE 可补全。
新增图片的四步闭环
- 放文件 → 放到
assets/images/<feature>/下 - 注册目录 → 确认
pubspec.yaml的assets:包含该目录 - 跑生成 →
fvm dart run build_runner build --delete-conflicting-outputs - 引用 →
Assets.images.<feature>.<name>.image()
生成的 lib/generated/assets.gen.dart 要提交到 git。
网络图片
HeNetworkImage(url: url) // 带缓存与占位,优先用它八、图标
AppIcon(name: 'icon_search', size: AppTokens.iconM, color: tokens.textSecondary)⚠️ 不用 Icon(Icons.search)——项目用远程加载的 iconfont 字体,统一走 AppIcon。
AppIcon 定义于 lib/core/iconfont/app_icon_widget.dart:11,是个 ConsumerWidget(内部监听字体加载状态)。
九、禁止事项总表
| 禁止 | 正确做法 |
|---|---|
Color(0xFF0073E5) | tokens.brandPrimaryDefault |
fontSize: 14 | AppTokens.bodyMdRegular |
padding: EdgeInsets.all(16) | EdgeInsets.all(AppTokens.spacingMd) |
Image.asset('assets/...') | Assets.images.xxx.image() |
Icon(Icons.home) | AppIcon(name: 'icon_home') |
SizedBox(height: 32.h) | AppTokens.spacingXxl |
AppTokens.spacerMd(旧名) | AppTokens.spacingMd |
AppTokens.radiusM(旧名) | AppTokens.radiusLarge |
AppTokens.bodyMedium(旧名) | AppTokens.bodyMdRegular |
业务直接 import 'package:tdesign_flutter/...' | 走 he_components 封装 |
Text('中文') | Text(l10n.t('key')) |
print('...') | AppLogger.I.d('...') |
Navigator.push(...) | context.push(...) |
十、自检清单
- [ ] 知道组件库唯一导入入口是
he_components.dart - [ ] 造 UI 前先在 88 个组件里找,不重复造轮子
- [ ] 所有间距用
AppTokens.spacingXxx(不是spacerXxx) - [ ] 所有圆角用
AppTokens.radiusXxx - [ ] 所有字体用
AppTokens.{title,body}Xx{Regular,Medium,Bold} - [ ] 所有颜色用
tokens.xxx,且先取实例 - [ ] 知道间距/圆角/字体/颜色都不能放进
const - [ ] 图片用
Assets.images.xxx,不用字面串 - [ ] 图标用
AppIcon,不用Icon(Icons.xxx) - [ ] 不用
.w/.h/.sp/.r