feat: 实现站内通知系统
- 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型
- 后端: 实现 schema/repository/service/router 全套通知 API
- GET /api/v1/notifications (列表+游标分页)
- GET /api/v1/notifications/unread-count
- PATCH /api/v1/notifications/{id}/read (幂等)
- PATCH /api/v1/notifications/mark-all-read (幂等)
- 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url)
- 后端: 19 个单元测试全部通过
- Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI)
- Flutter: Home 页通知按钮接入真实页面,显示未读 badge
- Flutter: 14 个测试全部通过
- 协议文档: notification-inbox-protocol.md 及错误码注册
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../data/models/notification_item.dart';
|
||||
|
||||
class NotificationListItem extends StatelessWidget {
|
||||
const NotificationListItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final NotificationItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.isRead ? colors.surface : colors.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
right: AppSpacing.sm,
|
||||
),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
item.body,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
if (diff.inMinutes < 1) return '刚刚';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
|
||||
if (diff.inDays < 1) return '${diff.inHours}小时前';
|
||||
if (diff.inDays < 30) return '${diff.inDays}天前';
|
||||
return '${dt.month}/${dt.day}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user