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

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-13 22:49:02 +08:00
parent 4075519075
commit e2cc3ac30f
40 changed files with 3379 additions and 515 deletions
@@ -0,0 +1,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,
),
),
],
),
);
}
}