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
+32 -11
View File
@@ -1,17 +1,38 @@
# frontend
# Navius 前端(Flutter Web)
A new Flutter project.
导航站前端,使用 Flutter 构建。当前目标平台只有 Web,数据全部来自本地 mock,
不发起网络请求;后端(Rust)尚未接入。
## Getting Started
界面基准是仓库根目录的 `index3.html`:可折叠的层级侧栏 + 居中的顶部控制栏
(折叠按钮 + 搜索框)+ 分组卡片网格 + 右下角圆形回到顶部。
This project is a starting point for a Flutter application.
## 常用命令
A few resources to get you started if this is your first Flutter project:
```bash
flutter pub get # 拉取依赖
flutter run -d chrome # 本地运行
flutter analyze # 静态分析
flutter test # 单元测试与组件测试
flutter build web # 构建产物输出到 build/web
```
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
## 目录约定
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
- `lib/core/`:与业务无关的基础设施——主题、尺寸常量、断点计算、打开链接的能力。
- `lib/features/<功能>/`:按功能划分,内部再分 `data`(数据来源与仓库)、`domain`(模型与规则)、`presentation`(页面与组件)。
- `test/`:目录结构与 `lib/` 保持一致。
依赖方向固定为 `features/ → core/`:`core` 不引用任何功能代码,
功能之间也不互相引用。
## 接后端的位置
页面只依赖仓库接口,不关心数据来自 mock 还是 Rust 后端:
- 首页:`lib/features/home/data/home_repository.dart`
接入时新增一个实现类,并把 `fetchHomeData` 的返回类型改成 `Future<HomeData>` 即可,
`domain/` 与 `presentation/` 基本不需要改动。
完整的目录树、设计令牌映射、响应式规则与实现取舍见
[docs/frontend-architecture.md](../docs/frontend-architecture.md)。
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'core/services/web_url_opener.dart';
import 'core/theme/app_theme.dart';
import 'features/home/data/mock_home_repository.dart';
import 'features/home/presentation/pages/home_page.dart';
/// 应用根组件。
///
/// 只负责三件事:配置全局主题、装配依赖、指定首页。
/// 这里是「具体实现」接起来的地方,所以只有它需要知道
/// [MockHomeRepository] 与 [WebUrlOpener] 的存在。
class NaviusApp extends StatelessWidget {
const NaviusApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// 与 HomeData.siteName 保持一致;接入后端后这一层由接口返回的数据驱动。
title: 'Suxing Nav',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
home: const HomePage(
repository: MockHomeRepository(),
urlOpener: WebUrlOpener(),
),
);
}
}
@@ -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);
}
-94
View File
@@ -1,94 +0,0 @@
import '../models/nav_item.dart';
import '../models/category.dart';
class NavData {
// 获取所有分类数据
static List<Category> getCategories() {
return [
Category(
id: 'default',
name: '常用导航',
isSystem: true,
entries: [
NavItem(
id: 'github',
title: 'GitHub',
url: 'https://github.com',
icon: '💻',
isSystem: true,
),
NavItem(
id: 'google',
title: 'Google',
url: 'https://www.google.com',
icon: '🔍',
isSystem: true,
),
NavItem(
id: 'stackoverflow',
title: 'Stack Overflow',
url: 'https://stackoverflow.com',
icon: '📚',
isSystem: true,
),
NavItem(
id: 'bilibili',
title: 'B站',
url: 'https://www.bilibili.com',
icon: '📺',
isSystem: true,
),
],
),
Category(
id: 'dev',
name: '开发工具',
isSystem: false,
entries: [
NavItem(
id: 'rust_doc',
title: 'Rust 文档',
url: 'https://doc.rust-lang.org',
icon: '🦀',
isSystem: false,
),
NavItem(
id: 'flutter_doc',
title: 'Flutter 文档',
url: 'https://docs.flutter.dev',
icon: '📱',
isSystem: false,
),
NavItem(
id: 'dartpad',
title: 'DartPad',
url: 'https://dartpad.dev',
icon: '🎯',
isSystem: false,
),
],
),
Category(
id: 'ai',
name: 'AI 工具',
isSystem: false,
entries: [
NavItem(
id: 'chatgpt',
title: 'ChatGPT',
url: 'https://chat.openai.com',
icon: '🤖',
isSystem: false,
),
NavItem(
id: 'claude',
title: 'Claude',
url: 'https://claude.ai',
icon: '🧠',
isSystem: false,
),
],
),
];
}
}
@@ -0,0 +1,13 @@
import '../domain/models/home_data.dart';
/// 首页数据仓库契约。
///
/// 界面层只认这个接口,不关心数据来自本地 mock 还是 Rust 后端。
///
/// 目前是同步返回:mock 数据是恒定常量,加一层 Future 只会凭空多出
/// loading / error 分支。等真正走网络请求时,把返回值改成
/// `Future<HomeData>`,改动范围仅限实现类与页面的一次数据加载。
abstract interface class HomeRepository {
/// 读取首页需要的全部数据。
HomeData fetchHomeData();
}
@@ -0,0 +1,83 @@
import '../domain/models/home_data.dart';
import '../domain/models/link_item.dart';
import '../domain/models/link_section.dart';
import '../domain/models/nav_node.dart';
/// 侧栏导航树,与 `index3.html` 的 `<ul class="nav-group">` 一一对应。
const List<NavNode> mockNavNodes = <NavNode>[
NavNode(id: 'common', title: '常用推荐'),
NavNode(
id: 'materials',
title: '素材资源',
children: <NavNode>[
NavNode(id: 'design', title: 'UI 设计'),
NavNode(id: 'icon', title: '图标库'),
NavNode(id: 'stock', title: '高清壁纸'),
],
),
NavNode(
id: 'dev-tools',
title: '开发工具',
children: <NavNode>[
NavNode(id: 'dev', title: '前端框架'),
NavNode(id: 'api', title: '在线 API'),
],
),
];
/// 内容区分组,与 `index3.html` 的两段内容一一对应。
///
/// 原型只写了 `#common` 与 `#design` 两段;导航里的 `#icon`、`#stock`、
/// `#dev`、`#api` 在原型中没有对应元素,点击不会发生任何事——这里保持一致。
const List<LinkSection> mockLinkSections = <LinkSection>[
LinkSection(
id: 'common',
title: '常用推荐',
items: <LinkItem>[
LinkItem(
id: 'github',
title: 'GitHub',
url: 'https://github.com',
description: '代码托管与开源社区',
),
LinkItem(
id: 'zhihu',
title: '知乎',
url: 'https://www.zhihu.com',
description: '深度内容问答社区',
),
LinkItem(
id: 'bilibili',
title: '哔哩哔哩',
url: 'https://www.bilibili.com',
description: '弹幕视频分享网站',
),
],
),
LinkSection(
id: 'design',
title: '素材资源 - UI 设计',
items: <LinkItem>[
LinkItem(
id: 'figma',
title: 'Figma',
url: 'https://www.figma.com',
description: '协同界面设计工具',
),
LinkItem(
id: 'dribbble',
title: 'Dribbble',
url: 'https://dribbble.com',
description: '全球设计作品分享社区',
),
],
),
];
/// 首页聚合数据。
const HomeData mockHomeData = HomeData(
siteName: 'Suxing Nav',
searchHint: '输入关键字搜索全网资源...',
navNodes: mockNavNodes,
sections: mockLinkSections,
);
@@ -0,0 +1,14 @@
import '../domain/models/home_data.dart';
import 'home_repository.dart';
import 'mock_home_data.dart';
/// [HomeRepository] 的本地实现,返回 [mockHomeData]。
///
/// 单独建一个类而不是让页面直接读常量:页面因此只依赖接口,
/// 换成后端实现时不用碰界面代码,测试里也能注入任意数据集。
class MockHomeRepository implements HomeRepository {
const MockHomeRepository();
@override
HomeData fetchHomeData() => mockHomeData;
}
@@ -0,0 +1,59 @@
import 'link_item.dart';
import 'link_section.dart';
import 'nav_node.dart';
/// 首页一次性需要的全部数据。
///
/// 打包成一个聚合对象:页面只调用一次仓库、只持有一份状态,
/// 后续换成后端接口时也只需要在一个响应里组装出它。
class HomeData {
const HomeData({
required this.siteName,
required this.searchHint,
required this.navNodes,
required this.sections,
});
/// 站点名,显示在侧栏顶部与浏览器标题。
final String siteName;
/// 搜索框占位文案。
final String searchHint;
/// 侧栏导航树。
final List<NavNode> navNodes;
/// 内容区分组。
///
/// 数量可能少于导航叶子:原型里 `#icon`、`#stock`、`#dev`、`#api`
/// 没有对应元素,点击时什么也不会发生。
final List<LinkSection> sections;
/// 导航树里的全部叶子,按自上而下的顺序展开。
List<NavNode> get navLeaves => navNodes
.expand((NavNode node) => node.leaves)
.toList(growable: false);
/// 全部站点(扁平化)。
List<LinkItem> get allItems => <LinkItem>[
for (final LinkSection section in sections) ...section.items,
];
/// 站点总数。
int get itemCount =>
sections.fold(0, (int sum, LinkSection s) => sum + s.itemCount);
/// 原型把 `.active` 写死在第一个导航条目上,这里取同一个值的来源。
String? get activeSectionId =>
navLeaves.isEmpty ? null : navLeaves.first.id;
/// 按 id 取分区;原型里没有对应元素时返回 null。
LinkSection? sectionById(String id) {
for (final LinkSection section in sections) {
if (section.id == id) {
return section;
}
}
return null;
}
}
@@ -0,0 +1,63 @@
import '../../../../core/utils/text.dart';
/// 一张站点卡片,对应 `index.html` 里的 `.card`。
class LinkItem {
const LinkItem({
required this.id,
required this.title,
required this.url,
this.description = '',
});
/// 从 JSON 构造(将来接后端接口时使用)。
factory LinkItem.fromJson(Map<String, dynamic> json) {
return LinkItem(
id: json['id'] as String,
title: json['title'] as String,
url: json['url'] as String,
description: json['description'] as String? ?? '',
);
}
/// 唯一标识。
final String id;
/// 站点名称,同时用于生成卡片左上角的首字母图标。
final String title;
/// 跳转地址。
final String url;
/// 一句话描述。
final String description;
/// 卡片图标显示的首字符。
String get initial => firstChar(title).toUpperCase();
/// 展示用域名。
String get host => Uri.tryParse(url)?.host ?? url;
/// 序列化为 JSON。
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'title': title,
'url': url,
'description': description,
};
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is LinkItem &&
other.id == id &&
other.title == title &&
other.url == url &&
other.description == description;
}
@override
int get hashCode => Object.hash(id, title, url, description);
@override
String toString() => 'LinkItem(id: $id, title: $title)';
}
@@ -0,0 +1,75 @@
import 'link_item.dart';
/// 一个分组,对应 `index.html` 里带标题的一组卡片。
class LinkSection {
const LinkSection({
required this.id,
required this.title,
required this.items,
});
/// 从 JSON 构造。
///
/// `items` 做了类型防御:字段缺失或类型不对时退化成空列表,
/// 避免后端返回异常数据时整个页面直接崩掉。
factory LinkSection.fromJson(Map<String, dynamic> json) {
final Object? rawItems = json['items'];
final List<LinkItem> items = rawItems is List
? rawItems
.whereType<Map<String, dynamic>>()
.map(LinkItem.fromJson)
.toList(growable: false)
: const <LinkItem>[];
return LinkSection(
id: json['id'] as String,
title: json['title'] as String,
items: items,
);
}
/// 唯一标识,同时用作锚点。
final String id;
/// 分组标题。
final String title;
/// 分组下的站点。
final List<LinkItem> items;
/// 站点数量。
int get itemCount => items.length;
/// 序列化为 JSON。
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'title': title,
'items': items.map((LinkItem item) => item.toJson()).toList(growable: false),
};
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! LinkSection ||
other.id != id ||
other.title != title ||
other.items.length != items.length) {
return false;
}
for (int index = 0; index < items.length; index++) {
if (items[index] != other.items[index]) {
return false;
}
}
return true;
}
@override
int get hashCode =>
Object.hash(id, title, Object.hashAll(items));
@override
String toString() => 'LinkSection(id: $id, title: $title, items: $itemCount)';
}
@@ -0,0 +1,54 @@
import '../../../../core/utils/text.dart';
/// 侧栏导航节点。
///
/// `index3.html` 的侧栏是两层的:一级条目要么直接指向一个分区(如「常用推荐」),
/// 要么是一个可以展开的分组(如「素材资源」),分组下面是若干二级条目。
/// 这里用同一棵树表达两种形态——[children] 为空即为直接跳转的叶子。
class NavNode {
const NavNode({
required this.id,
required this.title,
this.children = const <NavNode>[],
});
/// 唯一标识;叶子节点同时也是内容区的锚点 id。
final String id;
/// 显示文字。
final String title;
/// 子节点;为空表示这是一个直接跳转的条目。
final List<NavNode> children;
/// 是否是可展开的分组。
bool get isGroup => children.isNotEmpty;
/// 该节点覆盖的所有叶子(自身是叶子时返回自身,否则递归收集后代)。
List<NavNode> get leaves {
if (children.isEmpty) {
return <NavNode>[this];
}
return children
.expand((NavNode child) => child.leaves)
.toList(growable: false);
}
/// 侧栏折叠时显示的短标签。
///
/// 取标题首字,与 logo 折叠成「S」是同一套规则;
/// 用 `runes` 保证中文与 emoji 不会被截成半个字符。
String get shortLabel => firstChar(title);
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is NavNode && other.id == id && other.title == title;
}
@override
int get hashCode => Object.hash(id, title);
@override
String toString() => 'NavNode(id: $id, title: $title, children: ${children.length})';
}
@@ -0,0 +1,184 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/services/url_opener.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/home_repository.dart';
import '../../domain/models/home_data.dart';
import '../../domain/models/link_item.dart';
import '../../domain/models/link_section.dart';
import '../widgets/back_to_top_button.dart';
import '../widgets/link_section_view.dart';
import '../widgets/sidebar_nav.dart';
import '../widgets/top_bar.dart';
/// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。
///
/// 完全按项目根目录的 `index3.html` 还原:结构、样式、交互都以那份文件为准,
/// 不额外增加原型里没有的行为。原型里没有的东西也照样不实现——
/// 搜索框不参与过滤(原型没有对应脚本)、`active` 固定写在第一个导航条目上、
/// 没有对应锚点的导航项点击后不会有任何反应。
class HomePage extends StatefulWidget {
const HomePage({
super.key,
required this.repository,
required this.urlOpener,
});
/// 数据来源(当前是 mock 实现)。
final HomeRepository repository;
/// 打开链接的能力(当前是 Web 实现)。
final UrlOpener urlOpener;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
/// 首页数据只在初始化时取一次。
late final HomeData _data = widget.repository.fetchHomeData();
/// 有对应锚点的分区,用于「点导航滚动过去」。
late final Map<String, GlobalKey> _sectionKeys = <String, GlobalKey>{
for (final LinkSection section in _data.sections) section.id: GlobalKey(),
};
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
/// 已展开的分组。原型初始全部收起。
final Set<String> _openGroupIds = <String>{};
/// 侧栏是否折叠。原型初始是展开的(body 上没有 sidebar-collapsed)。
bool _collapsed = false;
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
// ---- 交互 ----
/// `#toggleBtn`:切换 `sidebar-collapsed`。
void _toggleSidebar() {
setState(() => _collapsed = !_collapsed);
}
/// `.has-sub` 的点击:折叠时先展开侧栏,再切换该分组的 open。
void _toggleGroup(String groupId) {
setState(() {
if (_collapsed) {
_collapsed = false;
_openGroupIds.add(groupId);
return;
}
if (!_openGroupIds.remove(groupId)) {
_openGroupIds.add(groupId);
}
});
}
/// 点导航叶子:滚到对应锚点。
///
/// 原型里 `#icon`、`#stock`、`#dev`、`#api` 没有对应元素,
/// 对应 `<a href="#...">` 点了不会有任何反应,这里保持一致。
void _goToSection(String sectionId) {
final BuildContext? sectionContext = _sectionKeys[sectionId]?.currentContext;
if (sectionContext == null) {
return;
}
unawaited(
Scrollable.ensureVisible(
sectionContext,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
),
);
}
void _openItem(LinkItem item) => widget.urlOpener.open(item.url);
void _backToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
);
}
// ---- 布局 ----
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: Stack(
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SidebarNav(
siteName: _data.siteName,
navNodes: _data.navNodes,
// 原型把 active 写死在第一个条目上,切换页面也不变。
activeSectionId: _data.activeSectionId,
collapsed: _collapsed,
openGroupIds: _openGroupIds,
onToggleGroup: _toggleGroup,
onSectionSelected: _goToSection,
// 原型指向不存在的 #submit,点了没有任何反应。
onSubmitSite: () {},
// 补上这行,将页面的切换折叠方法传进去
onToggleCollapse: _toggleSidebar,
),
Expanded(child: _buildMain()),
],
),
Positioned(
right: AppSizes.backToTopOffset,
bottom: AppSizes.backToTopOffset,
child: BackToTopButton(onTap: _backToTop),
),
],
),
);
}
Widget _buildMain() {
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(
AppSizes.contentPaddingHorizontal,
AppSizes.contentPaddingTop,
AppSizes.contentPaddingHorizontal,
AppSizes.contentPaddingBottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
TopBar(
searchController: _searchController,
searchHint: _data.searchHint,
sidebarCollapsed: _collapsed,
onToggleSidebar: _toggleSidebar,
),
for (final LinkSection section in _data.sections)
Padding(
// 原型里每个 .grid 都带 margin-bottom: 36px。
padding: const EdgeInsets.only(bottom: AppSizes.sectionGap),
child: LinkSectionView(
key: _sectionKeys[section.id],
section: section,
onOpen: _openItem,
),
),
],
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
/// 右下角的圆形「回到顶部」按钮(`.back-to-top`)。
///
/// 原型的按钮是一直显示的,这里保持一致;悬停时上移 3px、
/// 边框与图标变主色、阴影加深。
class BackToTopButton extends StatefulWidget {
const BackToTopButton({super.key, required this.onTap});
/// 点击回调。
final VoidCallback onTap;
@override
State<BackToTopButton> createState() => _BackToTopButtonState();
}
class _BackToTopButtonState extends State<BackToTopButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return Tooltip(
message: '回到顶部',
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
// `.back-to-top { transition: all 0.2s ease }`。
curve: Curves.ease,
transform: Matrix4.translationValues(
0,
_hovered ? -AppSizes.backToTopHoverLift : 0,
0,
),
width: AppSizes.backToTopSize,
height: AppSizes.backToTopSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(
color: _hovered ? AppColors.primary : AppColors.border,
),
shape: BoxShape.circle,
boxShadow: _hovered
? AppColors.cardShadowHover
: AppColors.cardShadow,
),
// 原型这里就是一个 ↑ 字符(font-size: 18px)。
child: Text(
'↑',
style: TextStyle(
fontSize: 18,
color: _hovered
? AppColors.primary
: AppColors.textSecondary,
),
),
),
),
),
);
}
}
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../domain/models/link_item.dart';
/// 站点卡片,对应 `index.html` 里的 `.card`。
///
/// 结构是「36px 的字母方块 + 名称 + 描述」,悬停时上移 2px 并加强阴影。
class LinkCard extends StatefulWidget {
const LinkCard({super.key, required this.item, required this.onOpen});
/// 站点数据。
final LinkItem item;
/// 打开回调。
final VoidCallback onOpen;
@override
State<LinkCard> createState() => _LinkCardState();
}
class _LinkCardState extends State<LinkCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onOpen,
child: AnimatedContainer(
duration: AppSizes.transition,
// `.card { transition: transform .2s, box-shadow .2s }`。
curve: Curves.ease,
transform: Matrix4.translationValues(
0,
_hovered ? -AppSizes.cardHoverLift : 0,
0,
),
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.cardPaddingHorizontal,
vertical: AppSizes.cardPaddingVertical,
),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
boxShadow: _hovered
? AppColors.cardShadowHover
: AppColors.cardShadow,
),
child: Row(
children: <Widget>[
_CardIcon(initial: widget.item.initial),
const SizedBox(width: AppSizes.cardIconGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
widget.item.title,
style: AppTextStyles.cardTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSizes.cardTitleGap),
Text(
widget.item.description,
style: AppTextStyles.cardDesc,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
}
}
/// 卡片左侧的字母方块(`.card-icon`)。
class _CardIcon extends StatelessWidget {
const _CardIcon({required this.initial});
final String initial;
@override
Widget build(BuildContext context) {
return Container(
width: AppSizes.cardIconSize,
height: AppSizes.cardIconSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.iconSurface,
borderRadius: BorderRadius.circular(AppSizes.cardIconRadius),
),
child: Text(initial, style: AppTextStyles.cardIcon),
);
}
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
/// 搜索框。
///
/// 对应 `index3.html` 的 `.search-box`:固定 44px 高、白色底、1px 描边、
/// 12px 圆角,聚焦时边框变主色并外扩 3px 的浅色光晕。
///
/// 用 [FocusNode] 而不是 `InputDecoration` 的 `focusedBorder`:
/// 那条光晕是「向外扩散」的矩形阴影,靠边框宽度画不出来。
class LinkSearchField extends StatefulWidget {
const LinkSearchField({
super.key,
required this.controller,
required this.hintText,
});
/// 文本控制器。
final TextEditingController controller;
/// 占位文案。
final String hintText;
@override
State<LinkSearchField> createState() => _LinkSearchFieldState();
}
class _LinkSearchFieldState extends State<LinkSearchField> {
final FocusNode _focusNode = FocusNode();
bool _focused = false;
@override
void initState() {
super.initState();
_focusNode.addListener(_handleFocusChange);
}
@override
void dispose() {
_focusNode
..removeListener(_handleFocusChange)
..dispose();
super.dispose();
}
void _handleFocusChange() {
if (_focusNode.hasFocus != _focused) {
setState(() => _focused = _focusNode.hasFocus);
}
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: AppSizes.transition,
// `.search-box { transition: border-color .2s, box-shadow .2s }`。
curve: Curves.ease,
height: AppSizes.searchHeight,
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.searchPaddingHorizontal,
),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(
color: _focused ? AppColors.primary : AppColors.border,
),
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
// 原型 `.search-box:focus` 的 box-shadow 会整体覆盖掉默认阴影,
// 聚焦时只剩这一圈光晕,不是「默认阴影 + 光晕」。
boxShadow: <BoxShadow>[
if (_focused)
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
else
...AppColors.cardShadow,
],
),
child: TextField(
controller: widget.controller,
focusNode: _focusNode,
style: AppTextStyles.searchInput,
cursorColor: AppColors.primary,
textAlignVertical: TextAlignVertical.center,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: widget.hintText,
hintStyle: AppTextStyles.searchHint,
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_breakpoints.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../domain/models/link_item.dart';
import '../../domain/models/link_section.dart';
import 'link_card.dart';
/// 一个分组:标题 + 自适应网格。
class LinkSectionView extends StatelessWidget {
const LinkSectionView({
super.key,
required this.section,
required this.onOpen,
});
/// 分组数据。
final LinkSection section;
/// 打开某个站点。
final ValueChanged<LinkItem> onOpen;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_SectionTitle(title: section.title),
const SizedBox(height: AppSizes.sectionTitleGap),
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final int columns = AppBreakpoints.gridColumnsFor(
constraints.maxWidth,
minItemWidth: AppSizes.gridMinItemWidth,
gap: AppSizes.gridGap,
);
final double itemWidth =
(constraints.maxWidth - AppSizes.gridGap * (columns - 1)) /
columns;
return Wrap(
spacing: AppSizes.gridGap,
runSpacing: AppSizes.gridGap,
children: <Widget>[
for (final LinkItem item in section.items)
SizedBox(
width: itemWidth,
child: LinkCard(item: item, onOpen: () => onOpen(item)),
),
],
);
},
),
],
);
}
}
/// 分组标题:左侧一条 4×16 的主色竖条 + 文字(`.section-title::before`)。
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Container(
width: AppSizes.sectionAccentWidth,
height: AppSizes.sectionAccentHeight,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(AppSizes.sectionAccentRadius),
),
),
const SizedBox(width: AppSizes.sectionAccentGap),
// 用 Flexible 包住:窄屏(侧栏展开后主内容只剩一百多像素)时
// 「素材资源 - UI 设计」这类长标题会溢出,这里退化成省略号。
Flexible(
child: Text(
title,
style: AppTextStyles.sectionTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
@@ -0,0 +1,465 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/text.dart';
import '../../domain/models/nav_node.dart';
/// 侧栏:logo + 层级导航 + 底部提交入口[cite: 1]。
class SidebarNav extends StatelessWidget {
const SidebarNav({
super.key,
required this.siteName,
required this.navNodes,
required this.activeSectionId,
required this.collapsed,
required this.openGroupIds,
required this.onToggleGroup,
required this.onSectionSelected,
required this.onSubmitSite,
required this.onToggleCollapse,
});
final String siteName;
final List<NavNode> navNodes;
final String? activeSectionId;
final bool collapsed;
final Set<String> openGroupIds;
final ValueChanged<String> onToggleGroup;
final ValueChanged<String> onSectionSelected;
final VoidCallback onSubmitSite;
final VoidCallback onToggleCollapse;
@override
Widget build(BuildContext context) {
final double horizontalPadding = collapsed
? AppSizes.sidebarCollapsedPaddingHorizontal
: AppSizes.sidebarPaddingHorizontal;
return AnimatedContainer(
duration: AppSizes.sidebarTransition,
curve: Curves.fastOutSlowIn,
width: collapsed
? AppSizes.sidebarCollapsedWidth
: AppSizes.sidebarWidth,
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(right: BorderSide(color: AppColors.border)),
),
child: ClipRect(
child: Padding(
padding: EdgeInsets.fromLTRB(
horizontalPadding,
AppSizes.sidebarPaddingTop,
horizontalPadding,
AppSizes.sidebarPaddingBottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_SidebarHeader(siteName: siteName, collapsed: collapsed),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
for (final NavNode node in navNodes)
_NavEntry(
key: ValueKey(node.id),
node: node,
collapsed: collapsed,
activeSectionId: activeSectionId,
open: openGroupIds.contains(node.id),
onToggleGroup: onToggleGroup,
onSectionSelected: onSectionSelected,
onToggleCollapse: onToggleCollapse,
),
],
),
),
),
_SidebarFooter(
collapsed: collapsed,
onSubmitSite: onSubmitSite,
),
],
),
),
),
);
}
}
class _SidebarHeader extends StatelessWidget {
const _SidebarHeader({required this.siteName, required this.collapsed});
final String siteName;
final bool collapsed;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSizes.sidebarHeaderGap),
child: SizedBox(
height: AppSizes.sidebarHeaderHeight,
child: Center(
child: AnimatedCrossFade(
duration: AppSizes.sidebarTransition,
crossFadeState: collapsed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: Text(
siteName,
style: AppTextStyles.logo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
secondChild: Text(
firstChar(siteName),
style: AppTextStyles.logoIcon,
),
),
),
),
);
}
}
class _NavEntry extends StatelessWidget {
const _NavEntry({
super.key,
required this.node,
required this.collapsed,
required this.activeSectionId,
required this.open,
required this.onToggleGroup,
required this.onSectionSelected,
required this.onToggleCollapse,
});
final NavNode node;
final bool collapsed;
final String? activeSectionId;
final bool open;
final ValueChanged<String> onToggleGroup;
final ValueChanged<String> onSectionSelected;
final VoidCallback onToggleCollapse;
@override
Widget build(BuildContext context) {
if (!node.isGroup) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSizes.navItemGap),
child: _NavRow(
title: node.title,
collapsed: collapsed,
selected: node.id == activeSectionId,
onTap: () => onSectionSelected(node.id),
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: AppSizes.navItemGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_NavRow(
title: node.title,
collapsed: collapsed,
selected: false,
onTap: () {
if (collapsed) {
onToggleCollapse();
}
onToggleGroup(node.id);
},
trailing: _ArrowIcon(open: open),
),
if (!collapsed)
AnimatedSize(
duration: AppSizes.subMenuTransition,
curve: open
? Curves.easeInOut
: const Cubic(0, 1, 0, 1),
alignment: Alignment.topCenter,
child: open
? Padding(
padding: const EdgeInsets.only(
left: AppSizes.subMenuIndent,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
for (final NavNode child in node.children)
_SubNavItem(
key: ValueKey(child.id),
title: child.title,
active: child.id == activeSectionId,
onTap: () => onSectionSelected(child.id),
),
],
),
)
: const SizedBox(height: 0),
),
],
),
);
}
}
class _ArrowIcon extends StatelessWidget {
const _ArrowIcon({required this.open});
final bool open;
@override
Widget build(BuildContext context) {
return AnimatedRotation(
duration: AppSizes.subMenuTransition,
curve: Curves.ease,
turns: open ? 0.25 : 0,
child: const Text('›', style: AppTextStyles.navArrow),
);
}
}
class _NavRow extends StatefulWidget {
const _NavRow({
super.key,
required this.title,
required this.collapsed,
required this.selected,
required this.onTap,
this.trailing,
});
final String title;
final bool collapsed;
final bool selected;
final VoidCallback onTap;
final Widget? trailing;
@override
State<_NavRow> createState() => _NavRowState();
}
class _NavRowState extends State<_NavRow> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final Color foreground = widget.selected || _hovered
? AppColors.primary
: AppColors.textSecondary;
final Color backgroundColor = widget.selected
? AppColors.primaryFaint
: (_hovered ? AppColors.navItemHover : Colors.transparent);
final Widget rowContent = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: widget.collapsed
? 0
: AppSizes.navItemPaddingHorizontal,
vertical: widget.collapsed
? AppSizes.navItemCollapsedPaddingVertical
: AppSizes.navItemPaddingVertical,
),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: widget.collapsed
? Center(
child: Text(
firstChar(widget.title),
style: AppTextStyles.navItem.copyWith(
color: foreground,
fontWeight: FontWeight.bold,
),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
widget.title,
style: AppTextStyles.navItem.copyWith(
color: foreground,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (widget.trailing != null) widget.trailing!,
],
),
),
),
);
if (widget.collapsed) {
return Tooltip(message: widget.title, child: rowContent);
}
return rowContent;
}
}
class _SubNavItem extends StatefulWidget {
const _SubNavItem({
super.key,
required this.title,
required this.active,
required this.onTap,
});
final String title;
final bool active;
final VoidCallback onTap;
@override
State<_SubNavItem> createState() => _SubNavItemState();
}
class _SubNavItemState extends State<_SubNavItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final Color foreground = widget.active || _hovered
? AppColors.primary
: AppColors.textSecondary;
final Color backgroundColor = widget.active
? AppColors.primaryFaint
: (_hovered ? AppColors.navItemHover : Colors.transparent);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.subNavItemPaddingHorizontal,
vertical: AppSizes.subNavItemPaddingVertical,
),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.title,
style: AppTextStyles.subNavItem.copyWith(color: foreground),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
}
}
class _SidebarFooter extends StatelessWidget {
const _SidebarFooter({
required this.collapsed,
required this.onSubmitSite,
});
final bool collapsed;
final VoidCallback onSubmitSite;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: AppSizes.sidebarFooterMarginTop),
padding: const EdgeInsets.only(top: AppSizes.sidebarFooterPaddingTop),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: AppColors.border)),
),
child: _SubmitButton(
collapsed: collapsed,
onTap: onSubmitSite,
),
);
}
}
class _SubmitButton extends StatefulWidget {
const _SubmitButton({required this.collapsed, required this.onTap});
final bool collapsed;
final VoidCallback onTap;
@override
State<_SubmitButton> createState() => _SubmitButtonState();
}
class _SubmitButtonState extends State<_SubmitButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final Color backgroundColor = _hovered
? AppColors.navItemHover
: Colors.transparent;
final Widget button = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: widget.collapsed
? 0
: AppSizes.submitButtonPaddingHorizontal,
vertical: widget.collapsed
? AppSizes.navItemCollapsedPaddingVertical
: AppSizes.submitButtonPaddingVertical,
),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: widget.collapsed
? Center(
child: Text(
'+',
style: AppTextStyles.submitButton.copyWith(
color: _hovered
? AppColors.primary
: AppColors.textSecondary,
fontWeight: FontWeight.bold,
),
),
)
: Text(
'提交网站',
style: AppTextStyles.submitButton.copyWith(
color: _hovered
? AppColors.primary
: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
return Tooltip(message: '提交网站', child: button);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import 'link_search_field.dart';
/// 顶部控制栏:折叠按钮 + 搜索框。
///
/// 对应 `index3.html` 的 `.top-bar`:整体宽 750px 并且**水平居中**
/// (`margin: 0 auto 36px auto`),下方与第一组卡片留 36px。
class TopBar extends StatelessWidget {
const TopBar({
super.key,
required this.searchController,
required this.searchHint,
required this.sidebarCollapsed,
required this.onToggleSidebar,
});
/// 搜索框控制器。
final TextEditingController searchController;
/// 搜索框占位文案。
final String searchHint;
/// 侧栏当前是否折叠,用于按钮的提示文案。
final bool sidebarCollapsed;
/// 折叠 / 展开侧栏。
final VoidCallback onToggleSidebar;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSizes.topBarMarginBottom),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: AppSizes.topBarMaxWidth,
),
child: Row(
children: <Widget>[
_ToggleButton(
collapsed: sidebarCollapsed,
onTap: onToggleSidebar,
),
const SizedBox(width: AppSizes.topBarGap),
Expanded(
child: LinkSearchField(
controller: searchController,
hintText: searchHint,
),
),
],
),
),
),
);
}
}
/// 折叠按钮(`.toggle-btn`)。
class _ToggleButton extends StatefulWidget {
const _ToggleButton({required this.collapsed, required this.onTap});
final bool collapsed;
final VoidCallback onTap;
@override
State<_ToggleButton> createState() => _ToggleButtonState();
}
class _ToggleButtonState extends State<_ToggleButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final Color foreground = _hovered
? AppColors.primary
: AppColors.textSecondary;
return Tooltip(
// 原型写的是 title="展开/折叠侧边栏",文案固定。
message: '展开/折叠侧边栏',
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
// `.toggle-btn { transition: all 0.2s }`。
curve: Curves.ease,
width: AppSizes.toggleButtonSize,
height: AppSizes.toggleButtonSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(
color: _hovered ? AppColors.primary : AppColors.border,
),
borderRadius: BorderRadius.circular(AppSizes.toggleButtonRadius),
boxShadow: AppColors.cardShadow,
),
// 原型这里就是一个 ☰ 字符(font-size: 18px)。
child: Text(
'☰',
style: TextStyle(fontSize: 18, color: foreground),
),
),
),
),
);
}
}
+4 -242
View File
@@ -1,245 +1,7 @@
import 'package:flutter/material.dart';
import 'models/nav_item.dart';
import 'models/category.dart';
import 'data/mock_data.dart';
import 'package:web/web.dart' as web;
import 'data/nav_data.dart';
import 'theme/app_theme.dart';
import 'app.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Navius 导航',
theme: AppTheme.lightTheme,
home: NaviusHomePage(),
);
}
}
class NaviusHomePage extends StatelessWidget {
const NaviusHomePage({super.key});
@override
Widget build(BuildContext context) {
// 获取分类数据
final categories = MockData.getCategories();
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(120),
child: Container(
decoration: BoxDecoration(
gradient: AppTheme.appBarGradient,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(30),
bottomRight: Radius.circular(30),
),
),
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 标题
const Text(
'Navius 导航',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.5,
),
),
const SizedBox(height: 4),
Text(
'${categories.length} 个分类 · ${_getTotalEntries(categories)} 个导航',
style: const TextStyle(
fontSize: 14,
color: Colors.white70,
),
),
],
),
),
),
),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return CategorySection(category: category);
},
),
);
}
// 计算总导航数
static int _getTotalEntries(List<Category> categories) {
int total = 0;
for (var category in categories) {
total += category.entries.length;
}
return total;
}
}
// 分类区块组件
class CategorySection extends StatelessWidget {
final Category category;
const CategorySection({super.key, required this.category});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 分类标题(美化版)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
// 分类名称
Text(
category.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Color(0xFF1E293B),
letterSpacing: 0.5,
),
),
const SizedBox(width: 12),
// 条目数量
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.systemTagColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${category.entries.length}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.systemTagTextColor,
fontWeight: FontWeight.w600,
),
),
),
const Spacer(),
// 系统标签
if (category.isSystem)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.systemTagColor,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'系统分类',
style: TextStyle(
fontSize: 11,
color: AppTheme.systemTagTextColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
// 导航网格(4列)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 1.0,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: category.entries.length,
itemBuilder: (context, index) {
final item = category.entries[index];
return NavItemCard(item: item);
},
),
const SizedBox(height: 8),
],
);
}
}
// 单个导航卡片组件
class NavItemCard extends StatelessWidget {
final NavItem item;
const NavItemCard({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: AppTheme.cardShadow,
),
child: InkWell(
onTap: () {
web.window.open(item.url, '_blank');
},
borderRadius: BorderRadius.circular(16),
hoverColor: AppTheme.primaryColor.withOpacity(0.05),
splashColor: AppTheme.primaryColor.withOpacity(0.1),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 图标(增大)
Text(
item.icon,
style: const TextStyle(fontSize: 36),
),
const SizedBox(height: 10),
// 标题
Text(
item.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 系统标签(改进样式)
if (item.isSystem)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.systemTagColor,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'默认',
style: TextStyle(
fontSize: 9,
color: AppTheme.systemTagTextColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
runApp(const NaviusApp());
}
-28
View File
@@ -1,28 +0,0 @@
import 'nav_item.dart';
// 导航分类模型
class Category {
final String id; // 分类唯一标识
final String name; // 分类名称
final bool isSystem; // 是否系统分类(true=不可修改)
final List<NavItem> entries; // 该分类下的导航条目
Category({
required this.id,
required this.name,
required this.isSystem,
required this.entries,
});
// 从 JSON 创建
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'],
name: json['name'],
isSystem: json['isSystem'] ?? false,
entries: (json['entries'] as List)
.map((e) => NavItem.fromJson(e))
.toList(),
);
}
}
-28
View File
@@ -1,28 +0,0 @@
// 导航条目模型
class NavItem {
final String id; // 唯一标识
final String title; // 显示名称
final String url; // 链接地址
final String icon; // 图标(emoji 或文字)
final bool isSystem; // 是否系统默认(true=不可修改)
// 构造函数
NavItem({
required this.id,
required this.title,
required this.url,
required this.icon,
required this.isSystem,
});
// 从 JSON 创建对象(后续从后端获取数据时使用)
factory NavItem.fromJson(Map<String, dynamic> json) {
return NavItem(
id: json['id'],
title: json['title'],
url: json['url'],
icon: json['icon'] ?? '🔗',
isSystem: json['isSystem'] ?? false,
);
}
}
-54
View File
@@ -1,54 +0,0 @@
import 'package:flutter/material.dart';
class AppTheme {
// 颜色方案
static const Color primaryColor = Color(0xFF2563EB);
static const Color secondaryColor = Color(0xFF7C3AED);
static const Color backgroundColor = Color(0xFFF8FAFC);
static const Color cardColor = Colors.white;
static const Color systemTagColor = Color(0xFFE2E8F0);
static const Color systemTagTextColor = Color(0xFF64748B);
// 渐变背景
static final LinearGradient appBarGradient = LinearGradient(
colors: [primaryColor, secondaryColor],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
);
// 卡片阴影
static final List<BoxShadow> cardShadow = [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 2,
blurRadius: 8,
offset: const Offset(0, 2),
),
];
// 主题数据
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
primary: primaryColor,
secondary: secondaryColor,
background: backgroundColor,
),
scaffoldBackgroundColor: backgroundColor,
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
color: cardColor,
shadowColor: Colors.transparent,
),
appBarTheme: const AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
),
);
}
+8
View File
@@ -208,6 +208,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.3.0"
web:
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/core/constants/app_breakpoints.dart';
void main() {
group('AppBreakpoints.gridColumnsFor', () {
test('宽度不足一个单元时退化为单列', () {
expect(AppBreakpoints.gridColumnsFor(200), 1);
expect(AppBreakpoints.gridColumnsFor(220), 1);
});
test('刚好放下两列与放不下时的边界', () {
// 两列 = 220 * 2 + 16 = 456
expect(AppBreakpoints.gridColumnsFor(456), 2);
expect(AppBreakpoints.gridColumnsFor(455), 1);
});
test('宽度越大列数越多', () {
expect(AppBreakpoints.gridColumnsFor(900), 3);
expect(AppBreakpoints.gridColumnsFor(1200), 5);
});
test('与 CSS 的 auto-fill 规则一致', () {
// 列数 = floor((可用宽度 + 间距) / (最小单元 + 间距))
for (final double width in <double>[300, 520, 740, 1080, 1440]) {
final int columns = AppBreakpoints.gridColumnsFor(width);
final double used = columns * 220 + (columns - 1) * 16;
expect(used, lessThanOrEqualTo(width));
expect(used + 236, greaterThan(width));
}
});
});
}
@@ -0,0 +1,111 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/features/home/data/mock_home_repository.dart';
import 'package:frontend/features/home/domain/models/home_data.dart';
import 'package:frontend/features/home/domain/models/link_item.dart';
import 'package:frontend/features/home/domain/models/link_section.dart';
import 'package:frontend/features/home/domain/models/nav_node.dart';
void main() {
const MockHomeRepository repository = MockHomeRepository();
final HomeData data = repository.fetchHomeData();
group('导航树(对照 index3.html 的 nav-group)', () {
test('三个一级条目:一个直接跳转,两个可展开分组', () {
expect(
data.navNodes.map((NavNode node) => node.title),
<String>['常用推荐', '素材资源', '开发工具'],
);
expect(data.navNodes[0].isGroup, isFalse);
expect(data.navNodes[1].children.length, 3);
expect(data.navNodes[2].children.length, 2);
});
test('叶子 id 与原型里的锚点一致', () {
expect(
data.navLeaves.map((NavNode node) => node.id),
<String>['common', 'design', 'icon', 'stock', 'dev', 'api'],
);
});
test('active 固定取第一个条目', () {
expect(data.activeSectionId, 'common');
});
});
group('内容区(对照 index3.html 的两段内容)', () {
test('只有两个分区', () {
expect(
data.sections.map((LinkSection s) => s.title),
<String>['常用推荐', '素材资源 - UI 设计'],
);
});
test('两个分区共五张卡片,与原型的卡片数一致', () {
expect(data.sectionById('common')!.items, hasLength(3));
expect(data.sectionById('design')!.items, hasLength(2));
expect(data.itemCount, 5);
expect(data.allItems, hasLength(5));
});
test('原型里没有对应元素的锚点取不到分区', () {
for (final String id in <String>['icon', 'stock', 'dev', 'api']) {
expect(data.sectionById(id), isNull, reason: '$id 不应该有内容');
}
});
test('分区 id 不重复', () {
final Set<String> ids = data.sections
.map((LinkSection s) => s.id)
.toSet();
expect(ids, hasLength(data.sections.length));
});
test('条目字段完整且地址合法', () {
for (final LinkItem item in data.allItems) {
expect(item.id, isNotEmpty);
expect(item.title, isNotEmpty);
expect(item.description, isNotEmpty);
expect(item.url, startsWith('https://'));
expect(item.host, isNotEmpty);
}
});
test('条目 id 全局唯一', () {
final List<String> ids = data.allItems
.map((LinkItem item) => item.id)
.toList();
expect(ids.toSet(), hasLength(ids.length));
});
});
group('LinkItem.initial', () {
test('英文取首字母并大写', () {
expect(
const LinkItem(id: 'a', title: 'github', url: 'https://a.com').initial,
'G',
);
});
test('中文取第一个字,不会被截成半个字符', () {
expect(
const LinkItem(id: 'a', title: '知乎', url: 'https://a.com').initial,
'知',
);
});
test('空标题有兜底字符', () {
expect(
const LinkItem(id: 'a', title: '', url: 'https://a.com').initial,
'·',
);
});
});
group('NavNode.shortLabel', () {
test('取首字', () {
expect(const NavNode(id: 'x', title: '素材资源').shortLabel, '素');
expect(const NavNode(id: 'x', title: 'GitHub').shortLabel, 'G');
expect(const NavNode(id: 'x', title: '').shortLabel, '·');
});
});
}
@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/core/constants/app_sizes.dart';
import 'package:frontend/core/theme/app_colors.dart';
import 'package:frontend/features/home/data/mock_home_repository.dart';
import 'package:frontend/features/home/presentation/pages/home_page.dart';
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
import 'package:frontend/features/home/presentation/widgets/link_section_view.dart';
import 'package:frontend/features/home/presentation/widgets/sidebar_nav.dart';
import '../../../helpers/fake_url_opener.dart';
void main() {
late FakeUrlOpener urlOpener;
setUp(() {
urlOpener = FakeUrlOpener();
});
Future<void> pumpPage(
WidgetTester tester, {
// 高度取 420:原型只有两段内容,视口再高就滚不动了,
// 「回到顶部」这类用例也就没有意义。
Size size = const Size(900, 420),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: HomePage(
repository: const MockHomeRepository(),
urlOpener: urlOpener,
),
),
);
await tester.pump();
}
/// 侧栏里的导航文字(避免和内容区的分区标题同名而混淆)。
Finder navText(String label) {
return find.descendant(
of: find.byType(SidebarNav),
matching: find.text(label),
);
}
/// 内容区的分区标题。
Finder sectionTitle(String label) {
return find.descendant(
of: find.byType(LinkSectionView),
matching: find.text(label),
);
}
double sidebarWidth(WidgetTester tester) {
return tester.getSize(find.byType(SidebarNav)).width;
}
Color? navColor(WidgetTester tester, String label) {
return tester.widget<Text>(navText(label)).style?.color;
}
/// 一级条目当前的底色(透明表示没有高亮)。
List<String> highlightedRows(WidgetTester tester) {
final List<String> rows = <String>[];
for (final String label in <String>['常用推荐', '素材资源', '开发工具']) {
final AnimatedContainer container = tester.widget<AnimatedContainer>(
find
.ancestor(
of: navText(label),
matching: find.byType(AnimatedContainer),
)
.first,
);
final Color? color = (container.decoration! as BoxDecoration).color;
if (color != null && color.toARGB32() != 0) {
rows.add(label);
}
}
return rows;
}
/// 主内容区的滚动控制器。
///
/// 侧栏的导航容器也是一个 SingleChildScrollView,所以这里用
/// 「搜索框的祖先」来精确定位主滚动视图,而不是靠 `.first` / `.last`。
ScrollController mainController(WidgetTester tester) {
final Finder mainScroll = find.ancestor(
of: find.byType(TextField),
matching: find.byType(SingleChildScrollView),
);
return tester.widget<SingleChildScrollView>(mainScroll).controller!;
}
double scrollOffset(WidgetTester tester) => mainController(tester).offset;
Future<void> expandMaterials(WidgetTester tester) async {
await tester.tap(navText('素材资源'));
await tester.pumpAndSettle();
}
/// 折叠态下导航项是空行(没有文字也没有图标),只能按位置定位:
/// 0 是侧栏本身,1~3 是三条一级导航。
Finder collapsedRow(int index) {
return find
.descendant(
of: find.byType(SidebarNav),
matching: find.byType(AnimatedContainer),
)
.at(index);
}
group('布局', () {
testWidgets('侧栏展开常驻,渲染 logo、三条导航与底部提交入口', (WidgetTester tester) async {
await pumpPage(tester);
expect(find.byType(SidebarNav), findsOneWidget);
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
expect(find.text('Suxing Nav'), findsOneWidget);
expect(navText('常用推荐'), findsOneWidget);
expect(navText('素材资源'), findsOneWidget);
expect(navText('开发工具'), findsOneWidget);
expect(navText('提交网站'), findsOneWidget);
expect(find.byType(AppBar), findsNothing);
});
testWidgets('子菜单默认收起', (WidgetTester tester) async {
await pumpPage(tester);
expect(navText('UI 设计'), findsNothing);
expect(navText('图标库'), findsNothing);
expect(navText('前端框架'), findsNothing);
});
testWidgets('两个分区与五张卡片,与原型一致', (WidgetTester tester) async {
await pumpPage(tester);
expect(find.byType(LinkSectionView), findsNWidgets(2));
expect(find.byType(LinkCard), findsNWidgets(5));
expect(sectionTitle('常用推荐'), findsOneWidget);
expect(sectionTitle('素材资源 - UI 设计'), findsOneWidget);
});
});
group('侧栏折叠(对应 #toggleBtn)', () {
testWidgets('点折叠按钮后宽度变为 64,logo 变成首字', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
await tester.pumpAndSettle();
expect(sidebarWidth(tester), AppSizes.sidebarCollapsedWidth);
expect(find.text('Suxing Nav'), findsNothing);
expect(find.text('S'), findsOneWidget);
// 折叠态下 .nav-text 与 .submit-text 都被隐藏,只剩空行。
expect(navText('常用推荐'), findsNothing);
expect(navText('提交网站'), findsNothing);
});
testWidgets('再点一次可以展开回来', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
await tester.pumpAndSettle();
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
expect(find.text('Suxing Nav'), findsOneWidget);
});
});
group('分组子菜单(对应 .has-sub 的脚本)', () {
testWidgets('点击分组展开子菜单,再点收起', (WidgetTester tester) async {
await pumpPage(tester);
await expandMaterials(tester);
expect(navText('UI 设计'), findsOneWidget);
expect(navText('图标库'), findsOneWidget);
expect(navText('高清壁纸'), findsOneWidget);
await tester.tap(navText('素材资源'));
await tester.pumpAndSettle();
expect(navText('UI 设计'), findsNothing);
});
testWidgets('折叠状态下点分组会先展开侧栏', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
await tester.pumpAndSettle();
expect(sidebarWidth(tester), AppSizes.sidebarCollapsedWidth);
await tester.tap(collapsedRow(2));
await tester.pumpAndSettle();
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
expect(navText('UI 设计'), findsOneWidget);
});
});
group('导航与内容', () {
testWidgets('高亮固定写在第一个条目上', (WidgetTester tester) async {
await pumpPage(tester);
expect(navColor(tester, '常用推荐'), AppColors.primary);
expect(navColor(tester, '素材资源'), AppColors.textSecondary);
expect(highlightedRows(tester), <String>['常用推荐']);
});
testWidgets('点有对应分区的导航会滚过去,高亮不变', (WidgetTester tester) async {
await pumpPage(tester);
final double before = scrollOffset(tester);
await expandMaterials(tester);
await tester.tap(navText('UI 设计'));
await tester.pumpAndSettle();
expect(scrollOffset(tester), greaterThan(before));
expect(navColor(tester, '常用推荐'), AppColors.primary);
expect(highlightedRows(tester), <String>['常用推荐']);
});
testWidgets('点没有对应分区的导航不会有任何反应', (WidgetTester tester) async {
await pumpPage(tester);
await expandMaterials(tester);
await tester.tap(navText('UI 设计'));
await tester.pumpAndSettle();
final double afterScroll = scrollOffset(tester);
// #icon 在原型里没有对应元素。
await tester.tap(navText('图标库'));
await tester.pumpAndSettle();
expect(scrollOffset(tester), afterScroll);
});
testWidgets('搜索框只接收输入,不过滤内容', (WidgetTester tester) async {
await pumpPage(tester);
await tester.enterText(find.byType(TextField), 'GitHub');
await tester.pumpAndSettle();
expect(find.byType(LinkSectionView), findsNWidgets(2));
expect(find.byType(LinkCard), findsNWidgets(5));
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
'GitHub',
);
});
});
group('滚动', () {
testWidgets('滚动主内容不会重建侧栏,也不会改动高亮', (WidgetTester tester) async {
await pumpPage(tester);
final ScrollController controller = mainController(tester);
AnimatedContainer firstRow() {
return tester.widget<AnimatedContainer>(
find
.ancestor(
of: navText('常用推荐'),
matching: find.byType(AnimatedContainer),
)
.first,
);
}
final AnimatedContainer beforeScroll = firstRow();
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(
identical(beforeScroll, firstRow()),
isTrue,
reason: '滚动主内容时侧栏被重建了',
);
expect(highlightedRows(tester), <String>['常用推荐']);
});
testWidgets('回到顶部把偏移归零', (WidgetTester tester) async {
await pumpPage(tester);
final ScrollController controller = mainController(tester);
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
expect(scrollOffset(tester), greaterThan(0));
await tester.tap(find.byTooltip('回到顶部'));
await tester.pumpAndSettle();
expect(scrollOffset(tester), 0);
});
});
group('其它', () {
testWidgets('点击卡片交由 UrlOpener 打开对应地址', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(find.text('GitHub'));
await tester.pump();
expect(urlOpener.openedUrls, <String>['https://github.com']);
});
testWidgets('点提交网站不会有任何反应', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(navText('提交网站'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.byType(SnackBar), findsNothing);
});
});
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/core/constants/app_sizes.dart';
import 'package:frontend/core/theme/app_colors.dart';
import 'package:frontend/features/home/domain/models/link_item.dart';
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
const LinkItem _item = LinkItem(
id: 'github',
title: 'GitHub',
url: 'https://github.com',
description: '代码托管与开源社区',
);
void main() {
late int openCount;
setUp(() {
openCount = 0;
});
Future<void> pumpCard(
WidgetTester tester, {
LinkItem item = _item,
Size size = const Size(280, 200),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
backgroundColor: AppColors.background,
body: Center(
child: SizedBox(
width: 260,
child: LinkCard(item: item, onOpen: () => openCount++),
),
),
),
),
);
await tester.pump();
}
testWidgets('渲染首字母图标、名称与描述', (WidgetTester tester) async {
await pumpCard(tester);
expect(find.text('G'), findsOneWidget);
expect(find.text('GitHub'), findsOneWidget);
expect(find.text('代码托管与开源社区'), findsOneWidget);
});
testWidgets('没有描述时不报错', (WidgetTester tester) async {
await pumpCard(
tester,
item: const LinkItem(id: 'x', title: '示例', url: 'https://example.com'),
);
expect(find.text('示'), findsOneWidget);
expect(find.text('示例'), findsOneWidget);
});
testWidgets('点击触发打开回调', (WidgetTester tester) async {
await pumpCard(tester);
await tester.tap(find.text('GitHub'));
await tester.pump();
expect(openCount, 1);
});
testWidgets('悬停时上浮', (WidgetTester tester) async {
await pumpCard(tester);
// AnimatedContainer 目标 transform 的 y 分量就是上浮距离:
// 直接断言它,不受 Tooltip 等外层 RenderObject 的影响。
double lift() {
final AnimatedContainer container = tester.widget<AnimatedContainer>(
find.descendant(
of: find.byType(LinkCard),
matching: find.byType(AnimatedContainer),
),
);
return container.transform?.getTranslation().y ?? 0;
}
expect(lift(), 0);
final TestGesture gesture = await tester.createGesture(
kind: PointerDeviceKind.mouse,
);
await gesture.addPointer(location: tester.getCenter(find.byType(LinkCard)));
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byType(LinkCard)));
await tester.pumpAndSettle();
expect(lift(), -AppSizes.cardHoverLift);
});
}
@@ -0,0 +1,12 @@
import 'package:frontend/core/services/url_opener.dart';
/// [UrlOpener] 的测试替身:只记录调用,不真的打开浏览器。
class FakeUrlOpener implements UrlOpener {
/// 依次记录的链接。
final List<String> openedUrls = <String>[];
@override
void open(String url) {
openedUrls.add(url);
}
}
-30
View File
@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
+21 -22
View File
@@ -1,45 +1,44 @@
<!DOCTYPE html>
<html>
<html lang="zh-CN">
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
如果应用部署在子路径下,把下面的 href 改成对应前缀,
必须以 "/" 开头和结尾。构建时会由 flutter build 的 --base-href 替换。
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<meta name="description" content="Suxing Nav —— 极简导航站,把常用站点收进一个页面,随手可用。">
<!-- iOS meta tags & icons -->
<!-- 浏览器地址栏与移动端状态栏配色,与 AppColors.primary 保持一致 -->
<meta name="theme-color" content="#3B82F6">
<!-- iOS 添加到主屏幕时使用的元信息 -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="frontend">
<meta name="apple-mobile-web-app-title" content="Suxing Nav">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>frontend</title>
<title>Suxing Nav</title>
<link rel="manifest" href="manifest.json">
<style>
/* Flutter 引擎加载完成前 body 是空的,先把底色铺成 AppColors.background,
避免首屏闪一下白。 */
body {
margin: 0;
background-color: #F7F9FB;
}
</style>
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
flutter_bootstrap.js 可以自定义,用来给 Flutter 加载器传参,
或在初始化期间给用户反馈。详见 Flutter 官方 Web 初始化文档。
-->
<script src="flutter_bootstrap.js" async></script>
</body>
+5 -6
View File
@@ -1,12 +1,11 @@
{
"name": "frontend",
"short_name": "frontend",
"name": "Suxing Nav",
"short_name": "Suxing Nav",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"background_color": "#F7F9FB",
"theme_color": "#3B82F6",
"description": "极简导航站,把常用站点收进一个页面,随手可用。",
"prefer_related_applications": false,
"icons": [
{
+258
View File
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>简约导航 - 极简网关</title>
<style>
:root {
--bg-color: #f7f9fb;
--card-bg: #ffffff;
--primary-color: #3b82f6;
--text-main: #1e293b;
--text-sub: #64748b;
--border-color: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
display: flex;
min-height: 100vh;
}
/* 侧边栏 */
.sidebar {
width: 220px;
background-color: var(--card-bg);
border-right: 1px solid var(--border-color);
padding: 24px 16px;
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
}
.logo {
font-size: 20px;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 32px;
padding-left: 12px;
}
.nav-item {
display: block;
padding: 12px 16px;
color: var(--text-sub);
text-decoration: none;
border-radius: 8px;
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.nav-item:hover, .nav-item.active {
background-color: #eff6ff;
color: var(--primary-color);
}
/* 主内容区 */
.main-content {
margin-left: 220px;
flex: 1;
padding: 40px;
max-width: 1200px;
}
/* 搜索框 */
.search-container {
margin-bottom: 40px;
max-width: 650px;
}
.search-box {
width: 100%;
padding: 14px 20px;
font-size: 15px;
border: 1px solid var(--border-color);
border-radius: 12px;
background-color: var(--card-bg);
box-shadow: var(--shadow);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-box:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
/* 分组与卡片 */
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-main);
display: flex;
align-items: center;
}
.section-title::before {
content: "";
display: inline-block;
width: 4px;
height: 16px;
background-color: var(--primary-color);
margin-right: 8px;
border-radius: 2px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 36px;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
box-shadow: var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
.card-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--primary-color);
margin-right: 12px;
flex-shrink: 0;
}
.card-info {
overflow: hidden;
}
.card-title {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
font-size: 12px;
color: var(--text-sub);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<!-- 侧边导航 -->
<aside class="sidebar">
<div class="logo">Suxing Nav</div>
<a href="#common" class="nav-item active">常用推荐</a>
<a href="#dev" class="nav-item">开发工具</a>
<a href="#design" class="nav-item">设计资源</a>
<a href="#media" class="nav-item">影音资讯</a>
</aside>
<!-- 主体内容 -->
<main class="main-content">
<!-- 搜索框 -->
<div class="search-container">
<input type="text" class="search-box" placeholder="输入关键字搜索全网资源...">
</div>
<!-- 分组 1 -->
<div id="common">
<div class="section-title">常用推荐</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">G</div>
<div class="card-info">
<div class="card-title">GitHub</div>
<div class="card-desc">代码托管与开源社区</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">Z</div>
<div class="card-title-wrap">
<div class="card-title">知乎</div>
<div class="card-desc">深度内容问答社区</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">B</div>
<div class="card-info">
<div class="card-title">哔哩哔哩</div>
<div class="card-desc">弹幕视频分享网站</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">V</div>
<div class="card-info">
<div class="card-title">V2EX</div>
<div class="card-desc">创意工作者讨论社区</div>
</div>
</a>
</div>
</div>
<!-- 分组 2 -->
<div id="dev">
<div class="section-title">开发工具</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">S</div>
<div class="card-info">
<div class="card-title">Stack Overflow</div>
<div class="card-desc">程序员技术问答</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">J</div>
<div class="card-info">
<div class="card-title">掘金</div>
<div class="card-desc">开发者技术社区</div>
</div>
</a>
</div>
</div>
</main>
</body>
</html>
+516
View File
@@ -0,0 +1,516 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>简约导航 - 极简网关</title>
<style>
:root {
--bg-color: #f7f9fb;
--card-bg: #ffffff;
--primary-color: #3b82f6;
--text-main: #1e293b;
--text-sub: #64748b;
--border-color: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
--sidebar-width: 230px;
--sidebar-collapsed-width: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
display: flex;
min-height: 100vh;
}
/* 侧边栏 */
.sidebar {
width: var(--sidebar-width);
background-color: var(--card-bg);
border-right: 1px solid var(--border-color);
padding: 20px 12px 12px 12px;
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 100;
overflow-x: hidden;
}
body.sidebar-collapsed .sidebar {
width: var(--sidebar-collapsed-width);
padding: 20px 8px 12px 8px;
}
/* Logo 居中显示 */
.sidebar-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
height: 40px;
text-align: center;
}
.logo {
font-size: 18px;
font-weight: 700;
color: var(--primary-color);
white-space: nowrap;
}
.logo-icon {
display: none;
font-size: 20px;
font-weight: 800;
color: var(--primary-color);
}
body.sidebar-collapsed .logo-text {
display: none;
}
body.sidebar-collapsed .logo-icon {
display: block;
}
/* 菜单区域 */
.nav-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.nav-group {
list-style: none;
}
.nav-item-wrapper {
margin-bottom: 4px;
}
.nav-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 16px;
color: var(--text-sub);
text-decoration: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.nav-link:hover {
background-color: #f8fafc;
color: var(--primary-color);
}
.nav-link.active {
background-color: #eff6ff;
color: var(--primary-color);
}
/* 小箭头样式与旋转特效 */
.arrow-icon {
font-size: 14px;
transition: transform 0.3s ease;
color: #94a3b8;
font-family: monospace;
font-weight: bold;
}
.nav-item-wrapper.open > .nav-link .arrow-icon {
transform: rotate(90deg);
}
/* 子菜单 */
.sub-menu {
list-style: none;
padding-left: 16px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0, 1, 0, 1);
}
.nav-item-wrapper.open .sub-menu {
max-height: 500px;
transition: max-height 0.3s ease-in-out;
}
.sub-menu .nav-link {
padding: 8px 16px;
font-size: 13px;
}
/* 侧边栏底部提交入口 */
.sidebar-footer {
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 8px;
}
.submit-btn {
display: flex;
align-items: center;
padding: 10px 16px;
color: var(--text-sub);
text-decoration: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
}
.submit-btn:hover {
background-color: #f1f5f9;
color: var(--primary-color);
}
/* 折叠状态隐藏样式 */
body.sidebar-collapsed .nav-text,
body.sidebar-collapsed .arrow-icon,
body.sidebar-collapsed .sub-menu,
body.sidebar-collapsed .submit-text {
display: none !important;
}
body.sidebar-collapsed .nav-link,
body.sidebar-collapsed .submit-btn {
justify-content: center;
padding: 12px 0;
}
/* 主内容区 */
.main-content {
margin-left: var(--sidebar-width);
flex: 1;
padding: 24px 40px 40px 40px;
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
body.sidebar-collapsed .main-content {
margin-left: var(--sidebar-collapsed-width);
}
/* 顶部控制栏 */
.top-bar {
display: flex;
align-items: center;
gap: 16px;
max-width: 750px;
margin: 0 auto 36px auto;
}
.toggle-btn {
background: var(--card-bg);
border: 1px solid var(--border-color);
font-size: 18px;
color: var(--text-sub);
cursor: pointer;
width: 44px;
height: 44px;
border-radius: 12px;
box-shadow: var(--shadow);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toggle-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.search-container {
flex: 1;
}
.search-box {
width: 100%;
height: 44px;
padding: 0 20px;
font-size: 15px;
border: 1px solid var(--border-color);
border-radius: 12px;
background-color: var(--card-bg);
box-shadow: var(--shadow);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-box:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
/* 分组与卡片 */
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-main);
display: flex;
align-items: center;
}
.section-title::before {
content: "";
display: inline-block;
width: 4px;
height: 16px;
background-color: var(--primary-color);
margin-right: 8px;
border-radius: 2px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 36px;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
box-shadow: var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
.card-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--primary-color);
margin-right: 12px;
flex-shrink: 0;
}
.card-info {
overflow: hidden;
}
.card-title {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
font-size: 12px;
color: var(--text-sub);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 纯圆形回到顶部按钮 */
.back-to-top {
position: fixed;
bottom: 30px;
right: 30px;
width: 44px;
height: 44px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 50%;
color: var(--text-sub);
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: var(--shadow);
transition: all 0.2s ease;
text-decoration: none;
z-index: 90;
}
.back-to-top:hover {
color: var(--primary-color);
border-color: var(--primary-color);
transform: translateY(-3px);
box-shadow: var(--shadow-hover);
}
</style>
</head>
<body>
<!-- 侧边导航 -->
<aside class="sidebar">
<!-- Logo 居中 -->
<div class="sidebar-header">
<div class="logo">
<span class="logo-text">Suxing Nav</span>
<span class="logo-icon">S</span>
</div>
</div>
<!-- 纯文字菜单(已移除所有 emoji 图标) -->
<div class="nav-container">
<ul class="nav-group">
<li class="nav-item-wrapper">
<a href="#common" class="nav-link active">
<span class="nav-text">常用推荐</span>
</a>
</li>
<li class="nav-item-wrapper">
<div class="nav-link has-sub">
<span class="nav-text">素材资源</span>
<span class="arrow-icon">›</span>
</div>
<ul class="sub-menu">
<li><a href="#design" class="nav-link"><span class="nav-text">UI 设计</span></a></li>
<li><a href="#icon" class="nav-link"><span class="nav-text">图标库</span></a></li>
<li><a href="#stock" class="nav-link"><span class="nav-text">高清壁纸</span></a></li>
</ul>
</li>
<li class="nav-item-wrapper">
<div class="nav-link has-sub">
<span class="nav-text">开发工具</span>
<span class="arrow-icon">›</span>
</div>
<ul class="sub-menu">
<li><a href="#dev" class="nav-link"><span class="nav-text">前端框架</span></a></li>
<li><a href="#api" class="nav-link"><span class="nav-text">在线 API</span></a></li>
</ul>
</li>
</ul>
</div>
<!-- 侧边栏底部纯文字提交按钮 -->
<div class="sidebar-footer">
<a href="#submit" class="submit-btn" title="提交网站">
<span class="submit-text">提交网站</span>
</a>
</div>
</aside>
<!-- 主体内容 -->
<main class="main-content">
<!-- 顶部控制栏 -->
<div class="top-bar">
<button class="toggle-btn" id="toggleBtn" title="展开/折叠侧边栏">☰</button>
<div class="search-container">
<input type="text" class="search-box" placeholder="输入关键字搜索全网资源...">
</div>
</div>
<!-- 分组 1 -->
<div id="common">
<div class="section-title">常用推荐</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">G</div>
<div class="card-info">
<div class="card-title">GitHub</div>
<div class="card-desc">代码托管与开源社区</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">Z</div>
<div class="card-info">
<div class="card-title">知乎</div>
<div class="card-desc">深度内容问答社区</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">B</div>
<div class="card-info">
<div class="card-title">哔哩哔哩</div>
<div class="card-desc">弹幕视频分享网站</div>
</div>
</a>
</div>
</div>
<!-- 分组 2 -->
<div id="design">
<div class="section-title">素材资源 - UI 设计</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">F</div>
<div class="card-info">
<div class="card-title">Figma</div>
<div class="card-desc">协同界面设计工具</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">D</div>
<div class="card-info">
<div class="card-title">Dribbble</div>
<div class="card-desc">全球设计作品分享社区</div>
</div>
</a>
</div>
</div>
</main>
<!-- 圆形回到顶部按钮 -->
<a href="#" class="back-to-top" title="回到顶部">↑</a>
<script>
// 1. 顶部按钮控制侧边栏折叠/展开
const toggleBtn = document.getElementById('toggleBtn');
toggleBtn.addEventListener('click', () => {
document.body.classList.toggle('sidebar-collapsed');
});
// 2. 小箭头菜单折叠/展开
const subMenuTriggers = document.querySelectorAll('.has-sub');
subMenuTriggers.forEach(trigger => {
trigger.addEventListener('click', () => {
if (document.body.classList.contains('sidebar-collapsed')) {
document.body.classList.remove('sidebar-collapsed');
}
const parentItem = trigger.closest('.nav-item-wrapper');
parentItem.classList.toggle('open');
});
});
</script>
</body>
</html>