- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
225 lines
7.4 KiB
Dart
225 lines
7.4 KiB
Dart
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 '../../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,
|
|
});
|
|
|
|
/// 站点数据。
|
|
final LinkItem item;
|
|
|
|
/// 打开回调。
|
|
final VoidCallback onOpen;
|
|
|
|
/// 编辑回调;为空或条目不可修改时不显示编辑按钮。
|
|
final VoidCallback? onEdit;
|
|
|
|
/// 删除回调;为空或条目不可修改时不显示删除按钮。
|
|
final VoidCallback? onDelete;
|
|
|
|
@override
|
|
State<LinkCard> createState() => _LinkCardState();
|
|
}
|
|
|
|
class _LinkCardState extends State<LinkCard> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bool canManage = widget.item.editable;
|
|
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: GestureDetector(
|
|
onTap: widget.onOpen,
|
|
child: AnimatedContainer(
|
|
duration: AppSizes.transition,
|
|
// `.card { transition: transform .2s, box-shadow .2s }`。
|
|
curve: Curves.ease,
|
|
transform: Matrix4.translationValues(
|
|
0,
|
|
_hovered ? -AppSizes.cardHoverLift : 0,
|
|
0,
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSizes.cardPaddingHorizontal,
|
|
vertical: AppSizes.cardPaddingVertical,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
border: Border.all(color: AppColors.border),
|
|
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
|
|
boxShadow: _hovered
|
|
? AppColors.cardShadowHover
|
|
: AppColors.cardShadow,
|
|
),
|
|
child: Stack(
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// 悬停才露出来,且不占正文的位置:不给可修改的卡片留常驻空白,
|
|
// 也不让鼠标移上去时文字宽度跳一下。
|
|
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!,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 卡片右上角的小动作按钮(编辑 / 删除)。
|
|
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});
|
|
|
|
final String initial;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: AppSizes.cardIconSize,
|
|
height: AppSizes.cardIconSize,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.iconSurface,
|
|
borderRadius: BorderRadius.circular(AppSizes.cardIconRadius),
|
|
),
|
|
child: Text(initial, style: AppTextStyles.cardIcon),
|
|
);
|
|
}
|
|
}
|