feat: 进行功能分区方便后期维护
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user