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 '../../../../core/theme/app_text_styles.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 '../../domain/models/nav_node.dart'; import '../../domain/models/submit_site_draft.dart'; import '../widgets/back_to_top_button.dart'; import '../widgets/confirm_dialog.dart'; import '../widgets/link_section_view.dart'; import '../widgets/sidebar_nav.dart'; import '../widgets/site_form_dialog.dart'; import '../widgets/top_bar.dart'; /// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。 /// /// 数据来自后端 `GET /api/nav`,所以页面要表达三种状态:加载中、加载失败 /// (带重试)、加载完成。布局与交互仍然以项目根目录的 `index3.html` 为准。 class HomePage extends StatefulWidget { const HomePage({ super.key, required this.repository, required this.urlOpener, }); /// 数据来源(当前是 HTTP 实现,测试里注入 mock)。 final HomeRepository repository; /// 打开链接的能力(当前是 Web 实现)。 final UrlOpener urlOpener; @override State createState() => _HomePageState(); } class _HomePageState extends State { HomeData? _data; String? _error; bool _loading = true; /// 有对应分区的分组,用于「点导航滚动过去」;数据到了之后才建。 Map _sectionKeys = {}; final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); /// 已展开的分组。原型初始全部收起。 final Set _openGroupIds = {}; /// 侧栏是否折叠。原型初始是展开的(body 上没有 sidebar-collapsed)。 bool _collapsed = false; @override void initState() { super.initState(); unawaited(_load()); } @override void dispose() { _scrollController.dispose(); _searchController.dispose(); super.dispose(); } /// 向后端取一次数据。失败时把说明留在 `_error` 里给用户看,并允许重试。 /// /// [showLoading] 为 false 时是「静默刷新」:页面继续显示旧内容, /// 失败了只提示一句,不会把已经渲染好的页面换成错误页。 Future _load({bool showLoading = true}) async { if (showLoading) { setState(() { _loading = true; _error = null; }); } try { final HomeData data = await widget.repository.fetchHomeData(); if (!mounted) { return; } setState(() { _data = data; _loading = false; _error = null; _sectionKeys = { for (final LinkSection section in data.sections) section.id: GlobalKey(), }; }); } catch (error) { if (!mounted) { return; } if (showLoading) { setState(() { _loading = false; _error = error.toString(); }); } else { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('刷新失败:$error'))); } } } // ---- 交互 ---- /// `#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); } }); } /// 点导航叶子:滚到对应分区。 /// /// 后端允许存在没有网站的分组,这类分组在内容区没有对应元素, /// 点了不会有任何反应——和原型里那几个空锚点一致。 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); /// 打开「提交网站」表单;提交成功后静默刷新一次,让新站点立刻出现在侧栏里。 Future _openSubmitDialog(HomeData data) async { final bool saved = await showSiteFormDialog( context: context, navNodes: data.navNodes, heading: '提交网站', onSubmit: (SubmitSiteDraft draft) => widget.repository.submitSite(draft), ); if (!saved || !mounted) { return; } _toast('已提交,感谢'); await _load(showLoading: false); } /// 编辑一个用户提交的站点:表单预填现有内容,保存走 `PATCH /api/sites/{id}`。 Future _openEditDialog(HomeData data, LinkItem item) async { final ({String group, String subGroup})? path = _groupPathOf( data.navNodes, item.categoryId, ); final bool saved = await showSiteFormDialog( context: context, navNodes: data.navNodes, heading: '编辑网站', initial: SubmitSiteDraft( title: item.title, url: item.url, description: item.description, group: path?.group ?? '', subGroup: path?.subGroup ?? '', ), onSubmit: (SubmitSiteDraft draft) => widget.repository.updateSite(item.id, draft), ); if (!saved || !mounted) { return; } _toast('已保存'); await _load(showLoading: false); } /// 删除一个用户提交的站点:先确认,再走 `DELETE /api/sites/{id}`。 Future _confirmDelete(LinkItem item) async { final bool confirmed = await showConfirmDialog( context: context, title: '删除网站', message: '确定要删除「${item.title}」吗?删掉之后不能恢复。', confirmLabel: '删除', danger: true, ); if (!confirmed || !mounted) { return; } try { await widget.repository.deleteSite(item.id); } catch (error) { if (mounted) { _toast('删除失败:$error'); } return; } if (!mounted) { return; } _toast('已删除'); await _load(showLoading: false); } void _toast(String message) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } /// 从侧栏树里反查某个分组 id 的「一级分组 + 子分组」名字。 /// /// 后端只按分组名收更新,所以编辑时必须把 id 还原成名字; /// 层级最多两级,这里只查两轮。 ({String group, String subGroup})? _groupPathOf( List nodes, String categoryId, ) { for (final NavNode root in nodes) { if (root.id == categoryId) { return (group: root.title, subGroup: ''); } for (final NavNode child in root.children) { if (child.id == categoryId) { return (group: root.title, subGroup: child.title); } } } return null; } void _backToTop() { _scrollController.animateTo( 0, duration: const Duration(milliseconds: 400), curve: Curves.easeOutCubic, ); } // ---- 布局 ---- @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, body: _buildBody(), ); } Widget _buildBody() { if (_loading) { return const _StatusView(message: '正在加载…', loading: true); } final String? error = _error; if (error != null) { return _StatusView(title: '加载失败', message: error, onRetry: _load); } return _buildLoaded(_data!); } Widget _buildLoaded(HomeData data) { return Stack( children: [ Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SidebarNav( siteName: data.siteName, navNodes: data.navNodes, // 原型把 active 写死在第一个条目上。 activeSectionId: data.activeSectionId, collapsed: _collapsed, openGroupIds: _openGroupIds, onToggleGroup: _toggleGroup, onSectionSelected: _goToSection, onSubmitSite: () => unawaited(_openSubmitDialog(data)), onToggleCollapse: _toggleSidebar, ), Expanded(child: _buildMain(data)), ], ), Positioned( right: AppSizes.backToTopOffset, bottom: AppSizes.backToTopOffset, child: BackToTopButton(onTap: _backToTop), ), ], ); } Widget _buildMain(HomeData data) { return SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.fromLTRB( AppSizes.contentPaddingHorizontal, AppSizes.contentPaddingTop, AppSizes.contentPaddingHorizontal, AppSizes.contentPaddingBottom, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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, onEdit: (LinkItem item) => unawaited(_openEditDialog(data, item)), onDelete: (LinkItem item) => unawaited(_confirmDelete(item)), ), ), ], ), ); } } /// 加载中 / 加载失败时占满内容区的提示。 class _StatusView extends StatelessWidget { const _StatusView({ required this.message, this.title, this.loading = false, this.onRetry, }); final String message; final String? title; final bool loading; final VoidCallback? onRetry; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(AppSizes.contentPaddingHorizontal), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (loading) ...[ const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(height: AppSizes.gridGap), ], if (title != null) ...[ Text(title!, style: AppTextStyles.emptyTitle), const SizedBox(height: 6), ], Text( message, style: AppTextStyles.emptyMessage, textAlign: TextAlign.center, ), if (onRetry != null) ...[ const SizedBox(height: AppSizes.gridGap), _RetryButton(onTap: onRetry!), ], ], ), ), ); } } /// 「重试」按钮:跟前端其它次级按钮同一套描边样式。 class _RetryButton extends StatefulWidget { const _RetryButton({required this.onTap}); final VoidCallback onTap; @override State<_RetryButton> createState() => _RetryButtonState(); } class _RetryButtonState extends State<_RetryButton> { 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.onTap, child: AnimatedContainer( duration: AppSizes.transition, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: _hovered ? AppColors.primaryFaint : Colors.transparent, border: Border.all(color: AppColors.primary), borderRadius: BorderRadius.circular(AppSizes.navItemRadius), ), child: Text( '重试', style: AppTextStyles.navItem.copyWith(color: AppColors.primary), ), ), ), ); } }