feat: 实现导航页基础功能
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -149,6 +149,7 @@ HomeData _toHomeData(List<Object?> groups, Map<String, dynamic> siteMeta) {
|
||||
searchHint: siteMeta['searchHint'] as String? ?? _fallbackSearchHint,
|
||||
navNodes: navNodes,
|
||||
sections: sections,
|
||||
submitUrl: siteMeta['submitUrl'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,4 +80,5 @@ const HomeData mockHomeData = HomeData(
|
||||
searchHint: '输入关键字搜索全网资源...',
|
||||
navNodes: mockNavNodes,
|
||||
sections: mockLinkSections,
|
||||
submitUrl: 'https://mattermost.example.com',
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ class HomeData {
|
||||
required this.searchHint,
|
||||
required this.navNodes,
|
||||
required this.sections,
|
||||
this.submitUrl = '',
|
||||
});
|
||||
|
||||
/// 站点名,显示在侧栏顶部与浏览器标题。
|
||||
@@ -20,6 +21,11 @@ class HomeData {
|
||||
/// 搜索框占位文案。
|
||||
final String searchHint;
|
||||
|
||||
/// 「提交网站」跳转的地址(Mattermost 之类的收集入口),来自后端 `site.submitUrl`。
|
||||
///
|
||||
/// 为空表示后端没配,点了就不做任何事。
|
||||
final String submitUrl;
|
||||
|
||||
/// 侧栏导航树。
|
||||
final List<NavNode> navNodes;
|
||||
|
||||
|
||||
@@ -10,13 +10,9 @@ 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';
|
||||
|
||||
/// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。
|
||||
@@ -153,100 +149,14 @@ 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 的「一级分组 + 子分组」名字。
|
||||
/// 「提交网站」跳到后端配好的收集入口(Mattermost 之类)。
|
||||
///
|
||||
/// 后端只按分组名收更新,所以编辑时必须把 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);
|
||||
}
|
||||
}
|
||||
/// 地址在 `data/nav.json` 的 `site.submitUrl`;没配就什么都不做。
|
||||
void _openSubmitUrl(HomeData data) {
|
||||
if (data.submitUrl.isEmpty) {
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
widget.urlOpener.open(data.submitUrl);
|
||||
}
|
||||
|
||||
void _backToTop() {
|
||||
@@ -293,7 +203,7 @@ class _HomePageState extends State<HomePage> {
|
||||
openGroupIds: _openGroupIds,
|
||||
onToggleGroup: _toggleGroup,
|
||||
onSectionSelected: _goToSection,
|
||||
onSubmitSite: () => unawaited(_openSubmitDialog(data)),
|
||||
onSubmitSite: () => _openSubmitUrl(data),
|
||||
onToggleCollapse: _toggleSidebar,
|
||||
),
|
||||
Expanded(child: _buildMain(data)),
|
||||
@@ -334,9 +244,6 @@ 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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,8 @@ import '../../domain/models/link_item.dart';
|
||||
/// 站点卡片,对应 `index3.html` 里的 `.card`。
|
||||
///
|
||||
/// 结构是「36px 的字母方块 + 名称 + 描述」,悬停时上移 2px 并加强阴影。
|
||||
/// 用户提交 / 接口推送进来的条目(`item.editable`)在悬停时右上角会露出
|
||||
/// 「编辑 / 删除」;系统内置的条目没有这两个按钮——后端也不允许改。
|
||||
class LinkCard extends StatefulWidget {
|
||||
const LinkCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onOpen,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
const LinkCard({super.key, required this.item, required this.onOpen});
|
||||
|
||||
/// 站点数据。
|
||||
final LinkItem item;
|
||||
@@ -25,12 +17,6 @@ class LinkCard extends StatefulWidget {
|
||||
/// 打开回调。
|
||||
final VoidCallback onOpen;
|
||||
|
||||
/// 编辑回调;为空或条目不可修改时不显示编辑按钮。
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
/// 删除回调;为空或条目不可修改时不显示删除按钮。
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
State<LinkCard> createState() => _LinkCardState();
|
||||
}
|
||||
@@ -40,8 +26,6 @@ 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),
|
||||
@@ -69,68 +53,31 @@ class _LinkCardState extends State<LinkCard> {
|
||||
? AppColors.cardShadowHover
|
||||
: AppColors.cardShadow,
|
||||
),
|
||||
child: Stack(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
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,
|
||||
),
|
||||
],
|
||||
_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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 悬停才露出来,且不占正文的位置:不给可修改的卡片留常驻空白,
|
||||
// 也不让鼠标移上去时文字宽度跳一下。
|
||||
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!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSizes.cardTitleGap),
|
||||
Text(
|
||||
widget.item.description,
|
||||
style: AppTextStyles.cardDesc,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -139,69 +86,6 @@ 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});
|
||||
|
||||
@@ -14,8 +14,6 @@ class LinkSectionView extends StatelessWidget {
|
||||
super.key,
|
||||
required this.section,
|
||||
required this.onOpen,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
/// 分组数据。
|
||||
@@ -24,12 +22,6 @@ class LinkSectionView extends StatelessWidget {
|
||||
/// 打开某个站点。
|
||||
final ValueChanged<LinkItem> onOpen;
|
||||
|
||||
/// 编辑某个站点;为空时卡片不显示编辑入口。
|
||||
final ValueChanged<LinkItem>? onEdit;
|
||||
|
||||
/// 删除某个站点;为空时卡片不显示删除入口。
|
||||
final ValueChanged<LinkItem>? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@@ -55,12 +47,7 @@ class LinkSectionView extends StatelessWidget {
|
||||
for (final LinkItem item in section.items)
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: LinkCard(
|
||||
item: item,
|
||||
onOpen: () => onOpen(item),
|
||||
onEdit: onEdit == null ? null : () => onEdit!(item),
|
||||
onDelete: onDelete == null ? null : () => onDelete!(item),
|
||||
),
|
||||
child: LinkCard(item: item, onOpen: () => onOpen(item)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,757 +0,0 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user