feat: 实现导航页基础功能
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -5,21 +5,24 @@ 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';
|
||||
|
||||
/// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。
|
||||
///
|
||||
/// 完全按项目根目录的 `index3.html` 还原:结构、样式、交互都以那份文件为准,
|
||||
/// 不额外增加原型里没有的行为。原型里没有的东西也照样不实现——
|
||||
/// 搜索框不参与过滤(原型没有对应脚本)、`active` 固定写在第一个导航条目上、
|
||||
/// 没有对应锚点的导航项点击后不会有任何反应。
|
||||
/// 数据来自后端 `GET /api/nav`,所以页面要表达三种状态:加载中、加载失败
|
||||
/// (带重试)、加载完成。布局与交互仍然以项目根目录的 `index3.html` 为准。
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({
|
||||
super.key,
|
||||
@@ -27,7 +30,7 @@ class HomePage extends StatefulWidget {
|
||||
required this.urlOpener,
|
||||
});
|
||||
|
||||
/// 数据来源(当前是 mock 实现)。
|
||||
/// 数据来源(当前是 HTTP 实现,测试里注入 mock)。
|
||||
final HomeRepository repository;
|
||||
|
||||
/// 打开链接的能力(当前是 Web 实现)。
|
||||
@@ -38,13 +41,12 @@ class HomePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
/// 首页数据只在初始化时取一次。
|
||||
late final HomeData _data = widget.repository.fetchHomeData();
|
||||
HomeData? _data;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
/// 有对应锚点的分区,用于「点导航滚动过去」。
|
||||
late final Map<String, GlobalKey> _sectionKeys = <String, GlobalKey>{
|
||||
for (final LinkSection section in _data.sections) section.id: GlobalKey(),
|
||||
};
|
||||
/// 有对应分区的分组,用于「点导航滚动过去」;数据到了之后才建。
|
||||
Map<String, GlobalKey> _sectionKeys = <String, GlobalKey>{};
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
@@ -55,6 +57,12 @@ class _HomePageState extends State<HomePage> {
|
||||
/// 侧栏是否折叠。原型初始是展开的(body 上没有 sidebar-collapsed)。
|
||||
bool _collapsed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
@@ -62,6 +70,48 @@ class _HomePageState extends State<HomePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 向后端取一次数据。失败时把说明留在 `_error` 里给用户看,并允许重试。
|
||||
///
|
||||
/// [showLoading] 为 false 时是「静默刷新」:页面继续显示旧内容,
|
||||
/// 失败了只提示一句,不会把已经渲染好的页面换成错误页。
|
||||
Future<void> _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 = <String, GlobalKey>{
|
||||
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`。
|
||||
@@ -83,10 +133,10 @@ class _HomePageState extends State<HomePage> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 点导航叶子:滚到对应锚点。
|
||||
/// 点导航叶子:滚到对应分区。
|
||||
///
|
||||
/// 原型里 `#icon`、`#stock`、`#dev`、`#api` 没有对应元素,
|
||||
/// 对应 `<a href="#...">` 点了不会有任何反应,这里保持一致。
|
||||
/// 后端允许存在没有网站的分组,这类分组在内容区没有对应元素,
|
||||
/// 点了不会有任何反应——和原型里那几个空锚点一致。
|
||||
void _goToSection(String sectionId) {
|
||||
final BuildContext? sectionContext = _sectionKeys[sectionId]?.currentContext;
|
||||
if (sectionContext == null) {
|
||||
@@ -103,6 +153,102 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
void _openItem(LinkItem item) => widget.urlOpener.open(item.url);
|
||||
|
||||
/// 打开「提交网站」表单;提交成功后静默刷新一次,让新站点立刻出现在侧栏里。
|
||||
Future<void> _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<void> _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<void> _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<NavNode> 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,
|
||||
@@ -117,39 +263,52 @@ class _HomePageState extends State<HomePage> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMain() {
|
||||
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: <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,
|
||||
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(
|
||||
@@ -163,11 +322,11 @@ class _HomePageState extends State<HomePage> {
|
||||
children: <Widget>[
|
||||
TopBar(
|
||||
searchController: _searchController,
|
||||
searchHint: _data.searchHint,
|
||||
searchHint: data.searchHint,
|
||||
sidebarCollapsed: _collapsed,
|
||||
onToggleSidebar: _toggleSidebar,
|
||||
),
|
||||
for (final LinkSection section in _data.sections)
|
||||
for (final LinkSection section in data.sections)
|
||||
Padding(
|
||||
// 原型里每个 .grid 都带 margin-bottom: 36px。
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.sectionGap),
|
||||
@@ -175,6 +334,9 @@ class _HomePageState extends State<HomePage> {
|
||||
key: _sectionKeys[section.id],
|
||||
section: section,
|
||||
onOpen: _openItem,
|
||||
onEdit: (LinkItem item) =>
|
||||
unawaited(_openEditDialog(data, item)),
|
||||
onDelete: (LinkItem item) => unawaited(_confirmDelete(item)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -182,3 +344,92 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载中 / 加载失败时占满内容区的提示。
|
||||
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: <Widget>[
|
||||
if (loading) ...<Widget>[
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(height: AppSizes.gridGap),
|
||||
],
|
||||
if (title != null) ...<Widget>[
|
||||
Text(title!, style: AppTextStyles.emptyTitle),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: AppTextStyles.emptyMessage,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...<Widget>[
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user