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,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);
}
}