feat: 进行功能分区方便后期维护

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-13 22:49:02 +08:00
parent 4075519075
commit e2cc3ac30f
40 changed files with 3379 additions and 515 deletions
@@ -0,0 +1,24 @@
/// 网格列数计算。
///
/// `index3.html` 的 `.grid` 用的是 CSS Grid 的
/// `repeat(auto-fill, minmax(220px, 1fr))`;Flutter 没有等价写法,
/// 这里把它的规则换算成「给定可用宽度能放几列」,写成纯函数便于单测。
///
/// 原型没有媒体查询,所以这里也不做断点分支——宽度由调用方传入。
abstract final class AppBreakpoints {
/// 给定可用宽度与最小单元宽度,算出网格能放几列。
///
/// 等价于 CSS:`列数 = floor((可用宽度 + 间距) / (最小单元 + 间距))`。
static int gridColumnsFor(
double availableWidth, {
double minItemWidth = 220,
double gap = 16,
}) {
if (availableWidth <= minItemWidth) {
return 1;
}
final int columns =
((availableWidth + gap) / (minItemWidth + gap)).floor();
return columns < 1 ? 1 : columns;
}
}
+162
View File
@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
/// 尺寸与动效常量。
///
/// 数值逐条对应 `index3.html` 的 CSS 变量与规则,组件里不再出现魔法数字。
abstract final class AppSizes {
// ---- 侧栏 ----
/// 侧栏展开宽度(`--sidebar-width`,230px)。
static const double sidebarWidth = 230;
/// 侧栏折叠宽度(`--sidebar-collapsed-width`,64px)。
static const double sidebarCollapsedWidth = 64;
/// 侧栏内边距(`padding: 20px 12px 12px 12px`)。
static const double sidebarPaddingTop = 20;
static const double sidebarPaddingHorizontal = 12;
static const double sidebarPaddingBottom = 12;
/// 折叠后的左右内边距(`padding: 20px 8px 12px 8px`)。
static const double sidebarCollapsedPaddingHorizontal = 8;
/// logo 区域高度与下方间距(`.sidebar-header`,`height:40px; margin-bottom:24px`)。
static const double sidebarHeaderHeight = 40;
static const double sidebarHeaderGap = 24;
/// 侧栏展开 / 折叠的动画(`width .3s cubic-bezier(.4, 0, .2, 1)`)。
///
/// 该贝塞尔曲线就是 Material 的 standard easing,Flutter 里对应
/// [Curves.fastOutSlowIn]。
static const Duration sidebarTransition = Duration(milliseconds: 300);
// ---- 导航项 ----
/// 导航项圆角(`border-radius: 8px`)。
static const double navItemRadius = 8;
/// 导航项内边距(`.nav-link`,`padding: 11px 16px`)。
static const double navItemPaddingVertical = 11;
static const double navItemPaddingHorizontal = 16;
/// 导航项之间的间距(`.nav-item-wrapper`,`margin-bottom: 4px`)。
static const double navItemGap = 4;
/// 折叠状态下导航项的内边距(`padding: 12px 0`)。
static const double navItemCollapsedPaddingVertical = 12;
/// 子菜单项内边距与字号(`.sub-menu .nav-link`,`padding: 8px 16px`)。
static const double subNavItemPaddingVertical = 8;
static const double subNavItemPaddingHorizontal = 16;
/// 子菜单相对父级的缩进(`.sub-menu`,`padding-left: 16px`)。
static const double subMenuIndent = 16;
/// 子菜单展开 / 收起的动画时长(`transition: .3s`)。
static const Duration subMenuTransition = Duration(milliseconds: 300);
/// 折叠箭头的点击热区宽度。
static const double navArrowBoxWidth = 18;
// ---- 侧栏底部 ----
/// 底部分隔线与内边距(`.sidebar-footer`)。
static const double sidebarFooterPaddingTop = 12;
static const double sidebarFooterMarginTop = 8;
/// 提交按钮内边距(`.submit-btn`,`padding: 10px 16px`)。
static const double submitButtonPaddingVertical = 10;
static const double submitButtonPaddingHorizontal = 16;
// ---- 主内容区 ----
/// 主内容区内边距(`padding: 24px 40px 40px 40px`)。
static const double contentPaddingTop = 24;
static const double contentPaddingHorizontal = 40;
static const double contentPaddingBottom = 40;
// ---- 顶部控制栏 ----
/// 顶栏最大宽度与垂直间距(`.top-bar`,`max-width: 750px; margin-bottom: 36px`)。
static const double topBarMaxWidth = 750;
static const double topBarGap = 16;
static const double topBarMarginBottom = 36;
/// 折叠按钮尺寸与圆角(`.toggle-btn`,44×44,圆角 12px)。
static const double toggleButtonSize = 44;
static const double toggleButtonRadius = 12;
// ---- 搜索框 ----
/// 搜索框高度与内边距(`.search-box`,`height:44px; padding: 0 20px`)。
static const double searchHeight = 44;
static const double searchPaddingHorizontal = 20;
/// 搜索框圆角(`border-radius: 12px`)。
static const double searchRadius = 12;
/// 聚焦光晕宽度(`0 0 0 3px`)。
static const double focusRingWidth = 3;
// ---- 分组与卡片 ----
/// 分组标题与网格之间的间距(`margin-bottom: 16px`)。
static const double sectionTitleGap = 16;
/// 标题前的竖条尺寸(`width: 4px; height: 16px`)。
static const double sectionAccentWidth = 4;
static const double sectionAccentHeight = 16;
/// 竖条与标题文字的间距(`margin-right: 8px`)。
static const double sectionAccentGap = 8;
/// 竖条圆角(`border-radius: 2px`)。
static const double sectionAccentRadius = 2;
/// 分组之间的间距(`.grid` 的 `margin-bottom: 36px`)。
static const double sectionGap = 36;
/// 网格间距(`gap: 16px`)。
static const double gridGap = 16;
/// 网格单元最小宽度(`minmax(220px, 1fr)`)。
static const double gridMinItemWidth = 220;
/// 卡片圆角(`border-radius: 10px`)。
static const double cardRadius = 10;
/// 卡片内边距(`padding: 14px 16px`)。
static const double cardPaddingVertical = 14;
static const double cardPaddingHorizontal = 16;
/// 卡片悬停时上移的距离(`translateY(-2px)`)。
static const double cardHoverLift = 2;
/// 卡片图标尺寸(`.card-icon`,36px)。
static const double cardIconSize = 36;
/// 卡片图标圆角(`border-radius: 8px`)。
static const double cardIconRadius = 8;
/// 图标与文字之间的间距(`margin-right: 12px`)。
static const double cardIconGap = 12;
/// 卡片标题与描述的间距(`margin-top: 2px`)。
static const double cardTitleGap = 2;
// ---- 回到顶部 ----
/// 按钮尺寸(`.back-to-top`,44×44,圆角 50%)。
static const double backToTopSize = 44;
/// 距视口右下角的距离(`bottom: 30px; right: 30px`)。
static const double backToTopOffset = 30;
/// 悬停时上移距离(`translateY(-3px)`)。
static const double backToTopHoverLift = 3;
// ---- 通用 ----
/// 通用过渡时长(CSS 里的 `transition: 0.2s`)。
static const Duration transition = Duration(milliseconds: 200);
}
@@ -0,0 +1,8 @@
/// 打开外部链接的能力抽象。
///
/// 界面层只依赖这个接口:一来把「打开链接」这种平台相关行为挡在 UI 之外,
/// 二来测试里可以注入假实现,避免真的弹出浏览器。
abstract interface class UrlOpener {
/// 打开 [url]。
void open(String url);
}
@@ -0,0 +1,16 @@
import 'package:web/web.dart' as web;
import 'url_opener.dart';
/// [UrlOpener] 的 Web 实现:在新标签页打开链接。
///
/// 当前项目只面向 Web,因此这是唯一的实现;
/// 以后要支持其它平台,只需新增实现类,界面代码无需改动。
class WebUrlOpener implements UrlOpener {
const WebUrlOpener();
@override
void open(String url) {
web.window.open(url, '_blank');
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
/// 全局配色。
///
/// 取值逐条对应 `index.html` 里 `:root` 的 CSS 变量,
/// 组件只引用这里的语义名,不再出现十六进制字面量。
/// 将来调色只需要改这个文件。
abstract final class AppColors {
// ---- 基础色 ----
/// 页面底色(`--bg-color`)。
static const Color background = Color(0xFFF7F9FB);
/// 卡片 / 侧栏底色(`--card-bg`)。
static const Color surface = Color(0xFFFFFFFF);
/// 主色(`--primary-color`):logo、导航选中态、图标文字、焦点边框。
static const Color primary = Color(0xFF3B82F6);
/// 主要文字(`--text-main`):标题与卡片标题。
static const Color textPrimary = Color(0xFF1E293B);
/// 次要文字(`--text-sub`):描述、未选中的导航项。
static const Color textSecondary = Color(0xFF64748B);
/// 描边(`--border-color`):侧栏右边界、卡片边框、搜索框边框。
static const Color border = Color(0xFFE2E8F0);
/// 导航项悬停 / 选中底色(`.nav-item:hover`)。
static const Color primaryFaint = Color(0xFFEFF6FF);
/// 导航项悬停底色(`.nav-link:hover`,比选中态更浅)。
static const Color navItemHover = Color(0xFFF8FAFC);
/// 提交按钮悬停底色(`.submit-btn:hover`),与卡片图标底色同值。
static const Color navItemHoverDeep = Color(0xFFF1F5F9);
/// 折叠箭头颜色(`.arrow-icon`)。
static const Color navArrow = Color(0xFF94A3B8);
/// 卡片图标底色(`.card-icon`)。
static const Color iconSurface = Color(0xFFF1F5F9);
/// 搜索框聚焦时的外发光(`0 0 0 3px rgba(59,130,246,.15)`)。
static const Color focusRing = Color(0x263B82F6);
// ---- 阴影 ----
/// 卡片默认阴影(`--shadow`)。
static const List<BoxShadow> cardShadow = <BoxShadow>[
BoxShadow(
color: Color(0x0D000000),
blurRadius: 6,
offset: Offset(0, 4),
),
BoxShadow(
color: Color(0x08000000),
blurRadius: 4,
offset: Offset(0, 2),
),
];
/// 卡片悬停阴影(`--shadow-hover`)。
static const List<BoxShadow> cardShadowHover = <BoxShadow>[
BoxShadow(
color: Color(0x14000000),
blurRadius: 15,
offset: Offset(0, 10),
),
BoxShadow(
color: Color(0x0A000000),
blurRadius: 6,
offset: Offset(0, 4),
),
];
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// 语义化文字样式。
///
/// 字号与字重对应 `index.html` 的 CSS;字体族不显式指定,
/// 沿用 HTML 里的系统字体栈(`-apple-system, Segoe UI, ...`)。
/// 颜色留空的样式由调用方按状态着色(例如导航项的选中 / 未选中)。
abstract final class AppTextStyles {
/// 侧栏 logo 文字(`.logo`,18px / 700)。
static const TextStyle logo = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primary,
);
/// 侧栏折叠后的 logo 图标(`.logo-icon`,20px / 800)。
static const TextStyle logoIcon = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: AppColors.primary,
);
/// 侧栏一级导航项(`.nav-link`,14px / 500)。
static const TextStyle navItem = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
);
/// 侧栏二级导航项(`.sub-menu .nav-link`,13px / 500)。
static const TextStyle subNavItem = TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
);
/// 折叠箭头(`.arrow-icon`,14px)。
static const TextStyle navArrow = TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.navArrow,
fontFamily: 'monospace',
height: 1,
);
/// 提交按钮文字(`.submit-btn`,14px / 500)。
static const TextStyle submitButton = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
);
/// 分组标题(`.section-title`,16px / 600)。
static const TextStyle sectionTitle = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
/// 搜索框正文(`.search-box`,15px)。
static const TextStyle searchInput = TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
);
/// 搜索框占位文字。
static const TextStyle searchHint = TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
);
/// 卡片标题(`.card-title`,14px / 600)。
static const TextStyle cardTitle = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
/// 卡片描述(`.card-desc`,12px)。
static const TextStyle cardDesc = TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
);
/// 卡片图标里的首字母(`.card-icon`)。
///
/// 原型没有给 .card-icon 设 font-size,它继承 body 的浏览器默认字号 16px。
static const TextStyle cardIcon = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
);
/// 空状态主文案。
static const TextStyle emptyTitle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
/// 空状态补充说明。
static const TextStyle emptyMessage = TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
);
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// 全局主题。
abstract final class AppTheme {
/// 浅色主题(当前唯一主题,取值来自 index.html 的设计变量)。
static ThemeData get light {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primary,
primary: AppColors.primary,
surface: AppColors.surface,
),
scaffoldBackgroundColor: AppColors.background,
dividerColor: AppColors.border,
iconTheme: const IconThemeData(color: AppColors.textSecondary, size: 20),
// 搜索框自己绘制边框与聚焦光晕,这里只负责光标颜色。
textSelectionTheme: const TextSelectionThemeData(
cursorColor: AppColors.primary,
selectionColor: AppColors.focusRing,
selectionHandleColor: AppColors.primary,
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.textPrimary,
contentTextStyle: TextStyle(color: AppColors.surface, fontSize: 14),
),
);
}
}
+11
View File
@@ -0,0 +1,11 @@
/// 取字符串的第一个字符。
///
/// 用 `runes` 而不是 `substring(0, 1)`:中文与 emoji 不会被截成半个字符。
/// 侧栏折叠后的短标签、卡片左上角的字母图标都依赖这条规则,
/// 放在这里保证三处(logo、导航、卡片)表现一致。
String firstChar(String text, {String fallback = '·'}) {
if (text.isEmpty) {
return fallback;
}
return String.fromCharCode(text.runes.first);
}