feat: 实现导航页基础功能

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-19 21:53:15 +08:00
parent e2cc3ac30f
commit 1c98340fc5
42 changed files with 6054 additions and 140 deletions
@@ -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),
),
),
),
);
}
}
@@ -0,0 +1,91 @@
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 'dialog_buttons.dart';
/// 通用确认框,返回 true 表示用户点了确认。
///
/// [danger] 为 true 时确认按钮用错误色,用在删除这类不可逆操作上。
Future<bool> showConfirmDialog({
required BuildContext context,
required String title,
required String message,
required String confirmLabel,
String cancelLabel = '取消',
bool danger = false,
}) async {
final bool? confirmed = await showDialog<bool>(
context: context,
barrierColor: const Color(0x73111827),
builder: (BuildContext context) => _ConfirmDialog(
title: title,
message: message,
confirmLabel: confirmLabel,
cancelLabel: cancelLabel,
danger: danger,
),
);
return confirmed ?? false;
}
class _ConfirmDialog extends StatelessWidget {
const _ConfirmDialog({
required this.title,
required this.message,
required this.confirmLabel,
required this.cancelLabel,
required this.danger,
});
final String title;
final String message;
final String confirmLabel;
final String cancelLabel;
final bool danger;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.all(AppSizes.dialogPadding),
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
boxShadow: AppColors.cardShadowHover,
),
padding: const EdgeInsets.all(AppSizes.dialogPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: AppTextStyles.sectionTitle),
const SizedBox(height: AppSizes.spaceSm),
Text(message, style: AppTextStyles.emptyMessage),
const SizedBox(height: AppSizes.spaceXl),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
DialogGhostButton(
label: cancelLabel,
onTap: () => Navigator.of(context).pop(false),
),
const SizedBox(width: AppSizes.spaceMd),
DialogPrimaryButton(
label: confirmLabel,
danger: danger,
onTap: () => Navigator.of(context).pop(true),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
/// 对话框里的主按钮:实底、8px 圆角(和侧栏「提交网站」一致)。
///
/// [danger] 为 true 时用错误色,用在「删除」这类不可逆操作上。
class DialogPrimaryButton extends StatefulWidget {
const DialogPrimaryButton({
super.key,
required this.label,
required this.onTap,
this.danger = false,
});
/// 按钮文字。
final String label;
/// 点击回调;为 null 表示禁用。
final VoidCallback? onTap;
/// 是否用错误色(删除类操作)。
final bool danger;
@override
State<DialogPrimaryButton> createState() => _DialogPrimaryButtonState();
}
class _DialogPrimaryButtonState extends State<DialogPrimaryButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final bool enabled = widget.onTap != null;
final Color background = widget.danger
? AppColors.danger
: AppColors.primary;
return MouseRegion(
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.cardPaddingHorizontal,
vertical: 10,
),
decoration: BoxDecoration(
color: enabled ? background : AppColors.border,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.label,
style: AppTextStyles.submitButton.copyWith(
color: enabled ? AppColors.surface : AppColors.textSecondary,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w500,
),
),
),
),
);
}
}
/// 对话框里的次级按钮:描边、透明底。
class DialogGhostButton extends StatefulWidget {
const DialogGhostButton({super.key, required this.label, required this.onTap});
/// 按钮文字。
final String label;
/// 点击回调;为 null 表示禁用。
final VoidCallback? onTap;
@override
State<DialogGhostButton> createState() => _DialogGhostButtonState();
}
class _DialogGhostButtonState extends State<DialogGhostButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final bool enabled = widget.onTap != null;
return MouseRegion(
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.cardPaddingHorizontal,
vertical: 9,
),
decoration: BoxDecoration(
color: _hovered ? AppColors.primaryFaint : Colors.transparent,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.label,
style: AppTextStyles.submitButton.copyWith(
color: enabled ? AppColors.textSecondary : AppColors.navArrow,
),
),
),
),
);
}
}
@@ -5,11 +5,19 @@ import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../domain/models/link_item.dart';
/// 站点卡片,对应 `index.html` 里的 `.card`。
/// 站点卡片,对应 `index3.html` 里的 `.card`。
///
/// 结构是「36px 的字母方块 + 名称 + 描述」,悬停时上移 2px 并加强阴影。
/// 用户提交 / 接口推送进来的条目(`item.editable`)在悬停时右上角会露出
/// 「编辑 / 删除」;系统内置的条目没有这两个按钮——后端也不允许改。
class LinkCard extends StatefulWidget {
const LinkCard({super.key, required this.item, required this.onOpen});
const LinkCard({
super.key,
required this.item,
required this.onOpen,
this.onEdit,
this.onDelete,
});
/// 站点数据。
final LinkItem item;
@@ -17,6 +25,12 @@ class LinkCard extends StatefulWidget {
/// 打开回调。
final VoidCallback onOpen;
/// 编辑回调;为空或条目不可修改时不显示编辑按钮。
final VoidCallback? onEdit;
/// 删除回调;为空或条目不可修改时不显示删除按钮。
final VoidCallback? onDelete;
@override
State<LinkCard> createState() => _LinkCardState();
}
@@ -26,6 +40,8 @@ class _LinkCardState extends State<LinkCard> {
@override
Widget build(BuildContext context) {
final bool canManage = widget.item.editable;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
@@ -53,31 +69,68 @@ class _LinkCardState extends State<LinkCard> {
? AppColors.cardShadowHover
: AppColors.cardShadow,
),
child: Row(
child: Stack(
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,
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,
),
],
),
const SizedBox(height: AppSizes.cardTitleGap),
Text(
widget.item.description,
style: AppTextStyles.cardDesc,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
// 悬停才露出来,且不占正文的位置:不给可修改的卡片留常驻空白,
// 也不让鼠标移上去时文字宽度跳一下。
if (canManage)
Positioned(
right: 0,
top: 0,
child: AnimatedOpacity(
duration: AppSizes.transition,
opacity: _hovered ? 1 : 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.onEdit != null)
_CardAction(
icon: '✎',
tooltip: '编辑',
background: AppColors.surface,
onTap: widget.onEdit!,
),
if (widget.onEdit != null && widget.onDelete != null)
const SizedBox(width: AppSizes.spaceXs),
if (widget.onDelete != null)
_CardAction(
icon: '✕',
tooltip: '删除',
background: AppColors.surface,
danger: true,
onTap: widget.onDelete!,
),
],
),
),
),
],
),
),
@@ -86,6 +139,69 @@ class _LinkCardState extends State<LinkCard> {
}
}
/// 卡片右上角的小动作按钮(编辑 / 删除)。
class _CardAction extends StatefulWidget {
const _CardAction({
required this.icon,
required this.tooltip,
required this.background,
required this.onTap,
this.danger = false,
});
final String icon;
final String tooltip;
final Color background;
final VoidCallback onTap;
final bool danger;
@override
State<_CardAction> createState() => _CardActionState();
}
class _CardActionState extends State<_CardAction> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final Color foreground = _hovered
? (widget.danger ? AppColors.danger : AppColors.primary)
: AppColors.navArrow;
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
// 只处理自己的点击,不要让卡片把事件吃掉(否则会去打开网站)。
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
width: AppSizes.iconButtonSize,
height: AppSizes.iconButtonSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hovered
? (widget.danger
? AppColors.dangerFaint
: AppColors.primaryFaint)
: widget.background,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.icon,
style: AppTextStyles.cardDesc.copyWith(color: foreground),
),
),
),
),
);
}
}
/// 卡片左侧的字母方块(`.card-icon`)。
class _CardIcon extends StatelessWidget {
const _CardIcon({required this.initial});
@@ -5,12 +5,6 @@ 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,
@@ -18,10 +12,7 @@ class LinkSearchField extends StatefulWidget {
required this.hintText,
});
/// 文本控制器。
final TextEditingController controller;
/// 占位文案。
final String hintText;
@override
@@ -54,42 +45,52 @@ class _LinkSearchFieldState extends State<LinkSearchField> {
@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,
return GestureDetector(
// 输入框只占中间一行的高度,点上下留白也让它聚焦——
// 原型里整个 44px 高的盒子就是一个 <input>。
onTap: _focusNode.requestFocus,
child: AnimatedContainer(
duration: AppSizes.transition,
curve: Curves.ease,
height: AppSizes.searchHeight,
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.searchPaddingHorizontal,
),
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,
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(
color: _focused ? AppColors.primary : AppColors.border,
),
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
boxShadow: <BoxShadow>[
if (_focused)
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
else
...AppColors.cardShadow,
],
),
// 垂直居中不要靠固定的 contentPadding。
// 那个写法只在「字体行高 + 上下 padding 正好等于内容区高度」时成立,
// 换一个字体(浏览器里的系统字体行高通常更高)就会被挤偏。
// 这里让 TextField 按自身行高确定高度,再用 Align 把这一行放进
// 44px 的内容区里居中——和浏览器渲染单行 <input> 的方式一致,
// 与字体行高无关。
child: Align(
alignment: Alignment.centerLeft,
child: TextField(
controller: widget.controller,
focusNode: _focusNode,
style: AppTextStyles.searchInput,
cursorColor: AppColors.primary,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: widget.hintText,
hintStyle: AppTextStyles.searchHint,
),
),
),
),
);
@@ -14,6 +14,8 @@ class LinkSectionView extends StatelessWidget {
super.key,
required this.section,
required this.onOpen,
this.onEdit,
this.onDelete,
});
/// 分组数据。
@@ -22,6 +24,12 @@ class LinkSectionView extends StatelessWidget {
/// 打开某个站点。
final ValueChanged<LinkItem> onOpen;
/// 编辑某个站点;为空时卡片不显示编辑入口。
final ValueChanged<LinkItem>? onEdit;
/// 删除某个站点;为空时卡片不显示删除入口。
final ValueChanged<LinkItem>? onDelete;
@override
Widget build(BuildContext context) {
return Column(
@@ -47,7 +55,12 @@ class LinkSectionView extends StatelessWidget {
for (final LinkItem item in section.items)
SizedBox(
width: itemWidth,
child: LinkCard(item: item, onOpen: () => onOpen(item)),
child: LinkCard(
item: item,
onOpen: () => onOpen(item),
onEdit: onEdit == null ? null : () => onEdit!(item),
onDelete: onDelete == null ? null : () => onDelete!(item),
),
),
],
);
@@ -0,0 +1,757 @@
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';
import '../../domain/models/submit_site_draft.dart';
import 'dialog_buttons.dart';
/// 打开网站表单对话框,返回 true 表示保存成功了。
///
/// 新增和编辑共用这一个表单:新增时 [onSubmit] 打 `POST /api/sites`,
/// 编辑时打 `PATCH /api/sites/{id}`,并用 [initial] 预填现有内容。
Future<bool> showSiteFormDialog({
required BuildContext context,
required List<NavNode> navNodes,
required String heading,
required Future<void> Function(SubmitSiteDraft draft) onSubmit,
SubmitSiteDraft? initial,
}) async {
final bool? submitted = await showDialog<bool>(
context: context,
barrierColor: const Color(0x73111827),
builder: (BuildContext context) => SiteFormDialog(
navNodes: navNodes,
heading: heading,
onSubmit: onSubmit,
initial: initial,
),
);
return submitted ?? false;
}
/// 网站表单(新增 / 编辑)。
///
/// 字段与后端 `POST /api/sites` 一一对应:**没有「选择分组」这个动作**,
/// 只有分组名——后端只会在用户创建的分组里查找或新建,提交的内容永远进不了
/// 内置分组,所以界面上也要把这件事说清楚,并只给用户分组的建议。
class SiteFormDialog extends StatefulWidget {
const SiteFormDialog({
super.key,
required this.navNodes,
required this.heading,
required this.onSubmit,
this.initial,
});
/// 侧栏那棵树,用来给出「已有的用户分组」建议。
final List<NavNode> navNodes;
/// 对话框标题,例如「提交网站」或「编辑网站」。
final String heading;
/// 保存动作。抛异常时表单会留在原地并显示原因。
final Future<void> Function(SubmitSiteDraft draft) onSubmit;
/// 编辑时的现有内容;新增时为 null。
final SubmitSiteDraft? initial;
@override
State<SiteFormDialog> createState() => _SiteFormDialogState();
}
class _SiteFormDialogState extends State<SiteFormDialog> {
late final TextEditingController _title;
late final TextEditingController _url;
late final TextEditingController _description;
late final TextEditingController _group;
late final TextEditingController _subGroup;
late final TextEditingController _submittedBy;
Map<String, String?> _errors = <String, String?>{};
String? _serverError;
bool _submitting = false;
@override
void initState() {
super.initState();
final SubmitSiteDraft? initial = widget.initial;
_title = TextEditingController(text: initial?.title ?? '');
_url = TextEditingController(text: initial?.url ?? '');
_description = TextEditingController(text: initial?.description ?? '');
_group = TextEditingController(text: initial?.group ?? '');
_subGroup = TextEditingController(text: initial?.subGroup ?? '');
_submittedBy = TextEditingController(text: initial?.submittedBy ?? '');
}
@override
void dispose() {
_title.dispose();
_url.dispose();
_description.dispose();
_group.dispose();
_subGroup.dispose();
_submittedBy.dispose();
super.dispose();
}
/// 已有的用户分组名(内置分组不出现在这里——选了也用不了)。
List<String> get _groupSuggestions => widget.navNodes
.where((NavNode node) => node.editable)
.map((NavNode node) => node.title)
.toList(growable: false);
/// 当前填的分组下已有的用户子分组。
List<String> get _subGroupSuggestions {
final String group = _group.text.trim();
for (final NavNode node in widget.navNodes) {
if (node.editable && node.title == group) {
return node.children
.where((NavNode child) => child.editable)
.map((NavNode child) => child.title)
.toList(growable: false);
}
}
return const <String>[];
}
/// 某个字段的内容变了:清掉它上一次的错误,让用户边改边看到反馈。
void _clearError(String field) {
if (_errors[field] == null && _serverError == null) {
return;
}
setState(() {
_errors = <String, String?>{..._errors, field: null};
_serverError = null;
});
}
Future<void> _submit() async {
final Map<String, String?> errors = <String, String?>{
'title': _validateTitle(_title.text),
'url': _validateUrl(_url.text),
'description': _validateDescription(_description.text),
'group': _validateGroup(_group.text),
'subGroup': _validateSubGroup(_subGroup.text),
'submittedBy': _validateSubmittedBy(_submittedBy.text),
};
setState(() {
_errors = errors;
_serverError = null;
});
if (errors.values.any((String? message) => message != null)) {
return;
}
setState(() => _submitting = true);
try {
await widget.onSubmit(
SubmitSiteDraft(
title: _title.text.trim(),
url: _url.text.trim(),
description: _description.text.trim(),
group: _group.text.trim(),
subGroup: _subGroup.text.trim(),
submittedBy: _submittedBy.text.trim(),
),
);
if (!mounted) {
return;
}
Navigator.of(context).pop(true);
} catch (error) {
if (!mounted) {
return;
}
// 后端的中文说明原样显示,用户能看懂哪里不对。
setState(() {
_submitting = false;
_serverError = error.toString();
});
}
}
@override
Widget build(BuildContext context) {
return PopScope(
// 请求还在路上时不允许关闭:关了界面请求也回不来,用户会以为丢了。
canPop: !_submitting,
child: Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.all(AppSizes.dialogPadding),
child: Container(
constraints: const BoxConstraints(maxWidth: AppSizes.dialogMaxWidth),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
boxShadow: AppColors.cardShadowHover,
),
padding: const EdgeInsets.all(AppSizes.dialogPadding),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_Header(
heading: widget.heading,
submitting: _submitting,
onClose: () => Navigator.of(context).pop(false),
),
if (_serverError != null) ...<Widget>[
const SizedBox(height: AppSizes.spaceLg),
_ErrorBanner(message: _serverError!),
],
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '网站名称',
controller: _title,
hint: '例如:GitHub',
enabled: !_submitting,
error: _errors['title'],
onChanged: () => _clearError('title'),
),
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '网站地址',
controller: _url,
hint: 'https://…',
enabled: !_submitting,
error: _errors['url'],
onChanged: () => _clearError('url'),
),
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '一句话描述',
controller: _description,
hint: '这个站点是做什么的(可不填)',
enabled: !_submitting,
error: _errors['description'],
multiline: true,
onChanged: () => _clearError('description'),
),
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '分组',
controller: _group,
hint: '例如:AI 工具',
enabled: !_submitting,
error: _errors['group'],
onChanged: () {
_clearError('group');
// 分组一变,子分组建议跟着换。
setState(() {});
},
),
const SizedBox(height: AppSizes.fieldLabelGap),
const Text(
'提交的网站会放在你自己的分组里,不会进内置分类。',
style: AppTextStyles.emptyMessage,
),
if (_groupSuggestions.isNotEmpty) ...<Widget>[
const SizedBox(height: AppSizes.spaceSm),
_Suggestions(
label: '已有的分组',
values: _groupSuggestions,
enabled: !_submitting,
onPick: (String value) {
_group.text = value;
_clearError('group');
setState(() {});
},
),
],
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '子分组',
controller: _subGroup,
hint: '例如:模型社区(可不填)',
enabled: !_submitting,
error: _errors['subGroup'],
onChanged: () => _clearError('subGroup'),
),
if (_subGroupSuggestions.isNotEmpty) ...<Widget>[
const SizedBox(height: AppSizes.spaceSm),
_Suggestions(
label: '已有的子分组',
values: _subGroupSuggestions,
enabled: !_submitting,
onPick: (String value) {
_subGroup.text = value;
_clearError('subGroup');
setState(() {});
},
),
],
// 编辑时不让改提交者:后端 PATCH 本来也不接受这个字段。
if (widget.initial == null) ...<Widget>[
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '你的称呼或邮箱',
controller: _submittedBy,
hint: '可不填,方便回访时找你',
enabled: !_submitting,
error: _errors['submittedBy'],
onChanged: () => _clearError('submittedBy'),
),
],
const SizedBox(height: AppSizes.spaceXl),
_Actions(
submitting: _submitting,
onCancel: () => Navigator.of(context).pop(false),
onSubmit: _submit,
),
],
),
),
),
),
);
}
}
/// 标题行:标题 + 关闭按钮。
class _Header extends StatelessWidget {
const _Header({
required this.heading,
required this.submitting,
required this.onClose,
});
final String heading;
final bool submitting;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: Text(heading, style: AppTextStyles.sectionTitle),
),
_IconButton(
icon: '×',
tooltip: '关闭',
onTap: submitting ? null : onClose,
),
],
);
}
}
/// 后端返回的失败说明,放在表单顶部。
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.spaceMd,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.dangerFaint,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
message,
style: AppTextStyles.emptyMessage.copyWith(color: AppColors.danger),
),
);
}
}
/// 带标签、错误提示的输入框。
///
/// 视觉复用搜索框那一套(44px 高 / 12px 圆角 / 聚焦光晕):
/// 输入框按自身行高定高,再用 Align 放进盒子里居中,不靠固定 padding 凑高度。
class _InputBox extends StatefulWidget {
const _InputBox({
required this.label,
required this.controller,
required this.hint,
required this.enabled,
required this.onChanged,
this.error,
this.multiline = false,
});
final String label;
final TextEditingController controller;
final String hint;
final bool enabled;
final VoidCallback onChanged;
final String? error;
final bool multiline;
@override
State<_InputBox> createState() => _InputBoxState();
}
class _InputBoxState extends State<_InputBox> {
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) {
final bool hasError = widget.error != null;
final Color borderColor = hasError
? AppColors.danger
: (_focused ? AppColors.primary : AppColors.border);
final Widget field = TextField(
controller: widget.controller,
focusNode: _focusNode,
enabled: widget.enabled,
onChanged: (_) => widget.onChanged(),
maxLines: widget.multiline ? 3 : 1,
style: AppTextStyles.searchInput,
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: widget.hint,
hintStyle: AppTextStyles.searchHint,
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(widget.label, style: AppTextStyles.emptyMessage),
const SizedBox(height: AppSizes.fieldLabelGap),
GestureDetector(
// 点盒子里输入框以外的留白也能聚焦。
onTap: widget.enabled ? _focusNode.requestFocus : null,
child: AnimatedContainer(
duration: AppSizes.transition,
curve: Curves.ease,
height: widget.multiline
? AppSizes.fieldMultilineHeight
: AppSizes.searchHeight,
padding: EdgeInsets.symmetric(
horizontal: AppSizes.cardPaddingHorizontal,
vertical: widget.multiline ? 10 : 0,
),
decoration: BoxDecoration(
color: widget.enabled ? AppColors.surface : AppColors.background,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
// 聚焦时只剩光晕(原型 `.search-box:focus` 就是这个行为)。
boxShadow: <BoxShadow>[
if (_focused)
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
else if (hasError)
const BoxShadow(
color: AppColors.dangerFaint,
spreadRadius: 3,
)
else
...AppColors.cardShadow,
],
),
child: Align(
alignment: widget.multiline
? Alignment.topLeft
: Alignment.centerLeft,
child: field,
),
),
),
if (widget.error != null) ...<Widget>[
const SizedBox(height: AppSizes.spaceXs),
Text(
widget.error!,
style: AppTextStyles.emptyMessage.copyWith(
color: AppColors.danger,
),
),
],
],
);
}
}
/// 一行可点的建议值。
class _Suggestions extends StatelessWidget {
const _Suggestions({
required this.label,
required this.values,
required this.enabled,
required this.onPick,
});
final String label;
final List<String> values;
final bool enabled;
final ValueChanged<String> onPick;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 5),
child: Text(label, style: AppTextStyles.emptyMessage),
),
const SizedBox(width: AppSizes.spaceSm),
Expanded(
child: Wrap(
spacing: AppSizes.spaceSm,
runSpacing: AppSizes.spaceSm,
children: <Widget>[
for (final String value in values)
_SuggestionChip(
label: value,
onTap: enabled ? () => onPick(value) : null,
),
],
),
),
],
);
}
}
class _SuggestionChip extends StatefulWidget {
const _SuggestionChip({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
State<_SuggestionChip> createState() => _SuggestionChipState();
}
class _SuggestionChipState extends State<_SuggestionChip> {
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: 10, vertical: 4),
decoration: BoxDecoration(
color: _hovered ? AppColors.primaryFaint : AppColors.iconSurface,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.label,
style: AppTextStyles.cardDesc.copyWith(
color: _hovered ? AppColors.primary : AppColors.textSecondary,
),
),
),
),
);
}
}
/// 底部按钮:左「取消」,右「提交」。
class _Actions extends StatelessWidget {
const _Actions({
required this.submitting,
required this.onCancel,
required this.onSubmit,
});
final bool submitting;
final VoidCallback onCancel;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
DialogGhostButton(label: '取消', onTap: submitting ? null : onCancel),
const SizedBox(width: AppSizes.spaceMd),
DialogPrimaryButton(
label: submitting ? '提交中…' : '提交',
onTap: submitting ? null : onSubmit,
),
],
);
}
}
class _IconButton extends StatefulWidget {
const _IconButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
final String icon;
final String tooltip;
final VoidCallback? onTap;
@override
State<_IconButton> createState() => _IconButtonState();
}
class _IconButtonState extends State<_IconButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
width: AppSizes.iconButtonSize,
height: AppSizes.iconButtonSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hovered ? AppColors.iconSurface : Colors.transparent,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.icon,
style: AppTextStyles.cardTitle.copyWith(
color: AppColors.textSecondary,
),
),
),
),
),
);
}
}
// ---- 校验:口径与后端 validate.rs 保持一致 ----
const int _maxTitle = 60;
const int _maxDescription = 120;
const int _maxGroup = 30;
const int _maxSubmittedBy = 120;
String? _validateTitle(String value) {
if (value.trim().isEmpty) {
return '请填写网站名称';
}
if (charCount(value) > _maxTitle) {
return '网站名称最多 $_maxTitle 个字';
}
return null;
}
String? _validateUrl(String value) {
final String text = value.trim();
if (text.isEmpty) {
return '请填写网站地址';
}
final Uri? uri = Uri.tryParse(text);
if (uri == null || uri.host.isEmpty) {
return '地址要以 http:// 或 https:// 开头';
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return '地址只能是 http:// 或 https://';
}
if (_isInternalHost(uri.host)) {
return '不能填本机或内网地址';
}
return null;
}
String? _validateDescription(String value) {
if (charCount(value) > _maxDescription) {
return '描述最多 $_maxDescription 个字';
}
return null;
}
String? _validateGroup(String value) {
final String text = value.trim();
if (text.isEmpty) {
return '请填写分组名称';
}
if (charCount(text) > _maxGroup) {
return '分组名称最多 $_maxGroup 个字';
}
return null;
}
String? _validateSubGroup(String value) {
if (charCount(value) > _maxGroup) {
return '子分组名称最多 $_maxGroup 个字';
}
return null;
}
String? _validateSubmittedBy(String value) {
if (charCount(value) > _maxSubmittedBy) {
return '最多 $_maxSubmittedBy 个字';
}
return null;
}
/// 前端只拦最明显的几种内网地址,权威判断在后端。
bool _isInternalHost(String host) {
final String h = host.toLowerCase();
if (h == 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) {
return true;
}
if (h == '0.0.0.0' || h == '::1' || h == '[::1]') {
return true;
}
final List<String> parts = h.split('.');
if (parts.length != 4) {
return false;
}
final int? first = int.tryParse(parts[0]);
final int? second = int.tryParse(parts[1]);
if (first == null || second == null) {
return false;
}
if (first == 127 || first == 10) {
return true;
}
if (first == 192 && second == 168) {
return true;
}
if (first == 172 && second >= 16 && second <= 31) {
return true;
}
return first == 169 && second == 254;
}