From 7e3597dc77ea6398921a818f8daee49a4a8b6d54 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 1 Jun 2026 09:32:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(diary):=20B4+B5+B6=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=20+=20F5/F6/F7=20=E5=89=8D=E7=AB=AF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 (erp-diary): - B4: CommentService 班级成员验证 + 删除评语 + SSE 通知推送 - B4: NotificationService 评语/主题/成就三类通知事件 - B5: StickerService 贴纸包列表 + 贴纸查询 + 模板管理 - B5: AchievementService 成就列表 + 解锁 + SSE 通知 - B6: MoodStatsService 心情统计 + 连续天数 - B6: ContentSafetyService 敏感词过滤框架 - SSE handler 增加 diary.notification.* 事件处理 - 新增 14 个 API 端点 + diary.comment.delete 权限 前端 (Flutter): - F5: CalendarBloc + 月视图日历 + 日记列表 - F6: MoodBloc + fl_chart 心情饼图 + 统计卡片 + 连续天数 - F7: 贴纸库分类浏览 + 模板画廊 - 首页改为日记流 + 心情快速选择 - 成就页改为徽章收集展示 验证: cargo check ✓ cargo test 17/17 ✓ flutter analyze 0 error --- .../achievement/views/achievement_page.dart | 226 +++++++- .../features/calendar/bloc/calendar_bloc.dart | 175 +++++++ .../calendar/views/calendar_page.dart | 483 +++++++++++++++++- app/lib/features/home/views/home_page.dart | 179 ++++++- app/lib/features/mood/bloc/mood_bloc.dart | 128 +++++ app/lib/features/mood/views/mood_page.dart | 352 ++++++++++++- .../stickers/views/sticker_library_page.dart | 212 +++++++- .../views/template_gallery_page.dart | 186 ++++++- crates/erp-diary/src/dto.rs | 106 ++++ .../src/handler/achievement_handler.rs | 78 +++ .../erp-diary/src/handler/comment_handler.rs | 34 +- crates/erp-diary/src/handler/mod.rs | 3 + crates/erp-diary/src/handler/stats_handler.rs | 66 +++ .../erp-diary/src/handler/sticker_handler.rs | 157 ++++++ crates/erp-diary/src/lib.rs | 47 +- .../src/service/achievement_service.rs | 158 ++++++ crates/erp-diary/src/service/class_service.rs | 2 +- .../erp-diary/src/service/comment_service.rs | 123 ++++- .../src/service/content_safety_service.rs | 110 ++++ crates/erp-diary/src/service/mod.rs | 5 + .../src/service/mood_stats_service.rs | 171 +++++++ .../src/service/notification_service.rs | 123 +++++ .../erp-diary/src/service/sticker_service.rs | 154 ++++++ crates/erp-diary/src/service/topic_service.rs | 12 + crates/erp-message/src/handler/sse_handler.rs | 35 ++ 25 files changed, 3286 insertions(+), 39 deletions(-) create mode 100644 app/lib/features/calendar/bloc/calendar_bloc.dart create mode 100644 app/lib/features/mood/bloc/mood_bloc.dart create mode 100644 crates/erp-diary/src/handler/achievement_handler.rs create mode 100644 crates/erp-diary/src/handler/stats_handler.rs create mode 100644 crates/erp-diary/src/handler/sticker_handler.rs create mode 100644 crates/erp-diary/src/service/achievement_service.rs create mode 100644 crates/erp-diary/src/service/content_safety_service.rs create mode 100644 crates/erp-diary/src/service/mood_stats_service.rs create mode 100644 crates/erp-diary/src/service/notification_service.rs create mode 100644 crates/erp-diary/src/service/sticker_service.rs diff --git a/app/lib/features/achievement/views/achievement_page.dart b/app/lib/features/achievement/views/achievement_page.dart index f89a8d0..71e39ba 100644 --- a/app/lib/features/achievement/views/achievement_page.dart +++ b/app/lib/features/achievement/views/achievement_page.dart @@ -1,13 +1,231 @@ -import 'package:flutter/material.dart'; +// 成就页面 — 徽章收集展示 +import 'package:flutter/material.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; + +/// 成就数据模型 +class Achievement { + final String id; + final String code; + final String name; + final String? description; + final String icon; + final String category; + final bool isUnlocked; + + const Achievement({ + required this.id, + required this.code, + required this.name, + this.description, + required this.icon, + required this.category, + this.isUnlocked = false, + }); +} + +/// 成就页面 — 徽章收集和展示 class AchievementPage extends StatelessWidget { const AchievementPage({super.key}); + static const _achievements = [ + Achievement(id: '1', code: 'first_diary', name: '初次落笔', description: '写下第一篇日记', icon: '✏️', category: 'writing', isUnlocked: true), + Achievement(id: '2', code: 'streak_7', name: '坚持一周', description: '连续写日记 7 天', icon: '🔥', category: 'writing'), + Achievement(id: '3', code: 'streak_30', name: '月度达人', description: '连续写日记 30 天', icon: '💪', category: 'writing'), + Achievement(id: '4', code: 'sticker_collector', name: '贴纸收藏家', description: '收集 10 张贴纸', icon: '🎨', category: 'collection'), + Achievement(id: '5', code: 'social_butterfly', name: '分享之星', description: '分享 5 篇日记到班级', icon: '🌟', category: 'social'), + Achievement(id: '6', code: 'mood_tracker', name: '心情记录员', description: '连续记录心情 14 天', icon: '🌈', category: 'writing'), + Achievement(id: '7', code: 'early_bird', name: '早起日记', description: '在早上 7 点前写日记', icon: '🌅', category: 'special'), + Achievement(id: '8', code: 'artist', name: '小画家', description: '在日记中画 10 幅涂鸦', icon: '🖌️', category: 'collection'), + ]; + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('成就 - 占位页面'), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final unlocked = _achievements.where((a) => a.isUnlocked).length; + + return Scaffold( + appBar: AppBar( + title: const Text('成就'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 进度概览 + _AchievementProgressCard( + unlocked: unlocked, + total: _achievements.length, + colorScheme: colorScheme, + ), + const SizedBox(height: 24), + Text( + '全部成就', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.75, + ), + itemCount: _achievements.length, + itemBuilder: (context, index) { + return _AchievementCard( + achievement: _achievements[index], + colorScheme: colorScheme, + ); + }, + ), + ], + ), + ), + ); + } +} + +/// 成就进度卡片 +class _AchievementProgressCard extends StatelessWidget { + const _AchievementProgressCard({ + required this.unlocked, + required this.total, + required this.colorScheme, + }); + + final int unlocked; + final int total; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final progress = total > 0 ? unlocked / total : 0.0; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + color: colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '收集进度', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + '$unlocked / $total', + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: progress, + minHeight: 10, + backgroundColor: colorScheme.primary.withValues(alpha: 0.15), + color: colorScheme.primary, + ), + ), + ], + ), + ), + ); + } +} + +/// 成就卡片 +class _AchievementCard extends StatelessWidget { + const _AchievementCard({ + required this.achievement, + required this.colorScheme, + }); + + final Achievement achievement; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: achievement.isUnlocked + ? AppColors.accent.withValues(alpha: 0.4) + : colorScheme.outlineVariant, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: achievement.isUnlocked + ? AppColors.accent.withValues(alpha: 0.15) + : colorScheme.onSurface.withValues(alpha: 0.05), + ), + alignment: Alignment.center, + child: achievement.isUnlocked + ? Text(achievement.icon, style: const TextStyle(fontSize: 28)) + : Icon( + Icons.lock_outline, + color: colorScheme.onSurface.withValues(alpha: 0.3), + ), + ), + const SizedBox(height: 8), + Text( + achievement.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: achievement.isUnlocked + ? colorScheme.onSurface + : colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + if (achievement.description != null) ...[ + const SizedBox(height: 4), + Text( + achievement.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues( + alpha: achievement.isUnlocked ? 0.6 : 0.3, + ), + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), ), ); } diff --git a/app/lib/features/calendar/bloc/calendar_bloc.dart b/app/lib/features/calendar/bloc/calendar_bloc.dart new file mode 100644 index 0000000..7a9fac9 --- /dev/null +++ b/app/lib/features/calendar/bloc/calendar_bloc.dart @@ -0,0 +1,175 @@ +// 日历 BLoC — 管理日历视图状态和日记列表 + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nuanji_app/data/models/journal_entry.dart'; + +// ===== Events ===== + +sealed class CalendarEvent { + const CalendarEvent(); +} + +/// 切换到指定月份 +final class CalendarMonthChanged extends CalendarEvent { + final DateTime month; + const CalendarMonthChanged(this.month); +} + +/// 选择某一天 +final class CalendarDaySelected extends CalendarEvent { + final DateTime day; + const CalendarDaySelected(this.day); +} + +/// 切换视图模式(月/周/时间轴) +final class CalendarViewModeChanged extends CalendarEvent { + final CalendarViewMode mode; + const CalendarViewModeChanged(this.mode); +} + +/// 加载某月的日记列表 +final class CalendarLoadJournals extends CalendarEvent { + final DateTime month; + const CalendarLoadJournals(this.month); +} + +// ===== State ===== + +/// 日历视图模式 +enum CalendarViewMode { month, week, timeline } + +/// 日历状态 +sealed class CalendarState { + const CalendarState(); +} + +/// 初始加载中 +final class CalendarInitial extends CalendarState { + const CalendarInitial(); +} + +/// 日历已加载 — 包含当前月份、选中日期、日记列表 +final class CalendarLoaded extends CalendarState { + /// 当前显示的月份 + final DateTime focusedMonth; + + /// 选中的日期 + final DateTime selectedDay; + + /// 当前月份所有日记(按日期索引) + final Map> journalsByDate; + + /// 当前选中日期的日记列表 + final List selectedDayJournals; + + /// 视图模式 + final CalendarViewMode viewMode; + + /// 是否正在加载 + final bool isLoading; + + const CalendarLoaded({ + required this.focusedMonth, + required this.selectedDay, + required this.journalsByDate, + required this.selectedDayJournals, + this.viewMode = CalendarViewMode.month, + this.isLoading = false, + }); + + CalendarLoaded copyWith({ + DateTime? focusedMonth, + DateTime? selectedDay, + Map>? journalsByDate, + List? selectedDayJournals, + CalendarViewMode? viewMode, + bool? isLoading, + }) => + CalendarLoaded( + focusedMonth: focusedMonth ?? this.focusedMonth, + selectedDay: selectedDay ?? this.selectedDay, + journalsByDate: journalsByDate ?? this.journalsByDate, + selectedDayJournals: selectedDayJournals ?? this.selectedDayJournals, + viewMode: viewMode ?? this.viewMode, + isLoading: isLoading ?? this.isLoading, + ); +} + +/// 加载失败 +final class CalendarError extends CalendarState { + final String message; + const CalendarError(this.message); +} + +// ===== BLoC ===== + +class CalendarBloc extends Bloc { + CalendarBloc() : super(const CalendarInitial()) { + on(_onMonthChanged); + on(_onDaySelected); + on(_onViewModeChanged); + on(_onLoadJournals); + } + + void _onMonthChanged( + CalendarMonthChanged event, + Emitter emit, + ) { + final currentState = state is CalendarLoaded ? state as CalendarLoaded : null; + + emit(CalendarLoaded( + focusedMonth: event.month, + selectedDay: event.month, + journalsByDate: currentState?.journalsByDate ?? {}, + selectedDayJournals: [], + viewMode: currentState?.viewMode ?? CalendarViewMode.month, + )); + + add(CalendarLoadJournals(event.month)); + } + + void _onDaySelected( + CalendarDaySelected event, + Emitter emit, + ) { + if (state is! CalendarLoaded) return; + final current = state as CalendarLoaded; + + // 查找选中日期的日记 + final dayKey = DateTime(event.day.year, event.day.month, event.day.day); + final dayJournals = current.journalsByDate[dayKey] ?? []; + + emit(current.copyWith( + selectedDay: event.day, + selectedDayJournals: dayJournals, + )); + } + + void _onViewModeChanged( + CalendarViewModeChanged event, + Emitter emit, + ) { + if (state is! CalendarLoaded) return; + final current = state as CalendarLoaded; + emit(current.copyWith(viewMode: event.mode)); + } + + Future _onLoadJournals( + CalendarLoadJournals event, + Emitter emit, + ) async { + if (state is! CalendarLoaded) return; + final current = state as CalendarLoaded; + + emit(current.copyWith(isLoading: true)); + + // Phase 1: 使用空数据占位,待 Repository 集成后替换 + // 实际将从 JournalRepository.loadByMonth(event.month) 获取 + await Future.delayed(const Duration(milliseconds: 300)); + + emit(current.copyWith( + isLoading: false, + journalsByDate: current.journalsByDate, + )); + } +} diff --git a/app/lib/features/calendar/views/calendar_page.dart b/app/lib/features/calendar/views/calendar_page.dart index ff30bac..bf32ff4 100644 --- a/app/lib/features/calendar/views/calendar_page.dart +++ b/app/lib/features/calendar/views/calendar_page.dart @@ -1,14 +1,489 @@ -import 'package:flutter/material.dart'; +// 日历页面 — 月视图 + 日记列表 +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/data/models/journal_entry.dart'; +import '../bloc/calendar_bloc.dart'; + +/// 日历页面 — 月视图 + 选中日期的日记列表 class CalendarPage extends StatelessWidget { const CalendarPage({super.key}); @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('日历 - 占位页面'), + return BlocProvider( + create: (context) => CalendarBloc() + ..add(CalendarMonthChanged(DateTime.now())), + child: const _CalendarView(), + ); + } +} + +class _CalendarView extends StatelessWidget { + const _CalendarView(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return BlocBuilder( + builder: (context, state) { + if (state is CalendarInitial) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is CalendarError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: colorScheme.error), + const SizedBox(height: 16), + Text(state.message, style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: () => context.read() + .add(CalendarMonthChanged(DateTime.now())), + child: const Text('重试'), + ), + ], + ), + ); + } + + if (state is! CalendarLoaded) return const SizedBox.shrink(); + final loaded = state; + + return Column( + children: [ + // 月份导航 + _MonthNavigator( + month: loaded.focusedMonth, + onPrevious: () { + final prev = DateTime( + loaded.focusedMonth.year, + loaded.focusedMonth.month - 1, + ); + context.read().add(CalendarMonthChanged(prev)); + }, + onNext: () { + final next = DateTime( + loaded.focusedMonth.year, + loaded.focusedMonth.month + 1, + ); + context.read().add(CalendarMonthChanged(next)); + }, + ), + + // 星期标题行 + _WeekdayHeader(colorScheme: colorScheme), + + // 日历网格 + _CalendarGrid( + month: loaded.focusedMonth, + selectedDay: loaded.selectedDay, + journalsByDate: loaded.journalsByDate, + onDaySelected: (day) { + context.read().add(CalendarDaySelected(day)); + }, + ), + + const Divider(height: 1), + + // 选中日期的日记列表 + Expanded( + child: loaded.selectedDayJournals.isEmpty + ? _EmptyDayView(selectedDay: loaded.selectedDay) + : _DayJournalList(journals: loaded.selectedDayJournals), + ), + ], + ); + }, + ); + } +} + +/// 月份导航栏 +class _MonthNavigator extends StatelessWidget { + const _MonthNavigator({ + required this.month, + required this.onPrevious, + required this.onNext, + }); + + final DateTime month; + final VoidCallback onPrevious; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final monthName = _formatMonth(month); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: onPrevious, + icon: const Icon(Icons.chevron_left), + tooltip: '上个月', + ), + Text( + monthName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + IconButton( + onPressed: onNext, + icon: const Icon(Icons.chevron_right), + tooltip: '下个月', + ), + ], + ), + ); + } + + String _formatMonth(DateTime date) { + const months = [ + '1月', '2月', '3月', '4月', '5月', '6月', + '7月', '8月', '9月', '10月', '11月', '12月', + ]; + return '${date.year}年 ${months[date.month - 1]}'; + } +} + +/// 星期标题行 +class _WeekdayHeader extends StatelessWidget { + const _WeekdayHeader({required this.colorScheme}); + + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + const weekdays = ['一', '二', '三', '四', '五', '六', '日']; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: weekdays.map((day) { + return Expanded( + child: Center( + child: Text( + day, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ), + ), + ); + }).toList(), ), ); } } + +/// 日历网格 — 6行7列 +class _CalendarGrid extends StatelessWidget { + const _CalendarGrid({ + required this.month, + required this.selectedDay, + required this.journalsByDate, + required this.onDaySelected, + }); + + final DateTime month; + final DateTime selectedDay; + final Map> journalsByDate; + final ValueChanged onDaySelected; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final today = DateTime.now(); + final days = _generateDays(month); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: days.map((week) { + return Row( + children: week.map((dayInfo) { + return Expanded( + child: _DayCell( + dayInfo: dayInfo, + isToday: dayInfo.date.year == today.year && + dayInfo.date.month == today.month && + dayInfo.date.day == today.day, + isSelected: dayInfo.date.year == selectedDay.year && + dayInfo.date.month == selectedDay.month && + dayInfo.date.day == selectedDay.day, + hasJournals: journalsByDate.containsKey( + DateTime(dayInfo.date.year, dayInfo.date.month, dayInfo.date.day), + ), + colorScheme: colorScheme, + onTap: () => onDaySelected(dayInfo.date), + ), + ); + }).toList(), + ); + }).toList(), + ), + ); + } + + /// 生成当月日历数据(包含前后补齐) + List> _generateDays(DateTime month) { + final firstDay = DateTime(month.year, month.month, 1); + // 周一为第一天:weekday 1=Mon...7=Sun + final startOffset = firstDay.weekday - 1; + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + + final allDays = <_DayInfo>[]; + + // 前面的空白 + for (var i = 0; i < startOffset; i++) { + allDays.add(_DayInfo(date: firstDay.subtract(Duration(days: startOffset - i)), isCurrentMonth: false)); + } + + // 当月日期 + for (var d = 1; d <= daysInMonth; d++) { + allDays.add(_DayInfo( + date: DateTime(month.year, month.month, d), + isCurrentMonth: true, + )); + } + + // 后面的补齐到完整行 + while (allDays.length % 7 != 0) { + final last = allDays.last.date; + allDays.add(_DayInfo(date: last.add(const Duration(days: 1)), isCurrentMonth: false)); + } + + // 分周 + return List.generate(allDays.length ~/ 7, (i) => allDays.sublist(i * 7, (i + 1) * 7)); + } +} + +class _DayInfo { + const _DayInfo({required this.date, required this.isCurrentMonth}); + final DateTime date; + final bool isCurrentMonth; +} + +/// 单日格子 +class _DayCell extends StatelessWidget { + const _DayCell({ + required this.dayInfo, + required this.isToday, + required this.isSelected, + required this.hasJournals, + required this.colorScheme, + required this.onTap, + }); + + final _DayInfo dayInfo; + final bool isToday; + final bool isSelected; + final bool hasJournals; + final ColorScheme colorScheme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + height: 44, + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? colorScheme.primary + : isToday + ? colorScheme.primaryContainer + : null, + ), + alignment: Alignment.center, + child: Text( + '${dayInfo.date.day}', + style: TextStyle( + fontSize: 14, + fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal, + color: !dayInfo.isCurrentMonth + ? colorScheme.onSurface.withValues(alpha: 0.3) + : isSelected + ? colorScheme.onPrimary + : colorScheme.onSurface, + ), + ), + ), + // 日记指示点 + if (hasJournals) + Container( + width: 4, + height: 4, + margin: const EdgeInsets.only(top: 2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? colorScheme.onPrimary + : AppColors.accent, + ), + ), + ], + ), + ), + ); + } +} + +/// 无日记的空状态 +class _EmptyDayView extends StatelessWidget { + const _EmptyDayView({required this.selectedDay}); + + final DateTime selectedDay; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.edit_note_rounded, + size: 64, + color: theme.colorScheme.onSurface.withValues(alpha: 0.2), + ), + const SizedBox(height: 16), + Text( + '${selectedDay.month}月${selectedDay.day}日', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + '这一天还没有日记', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 24), + FilledButton.tonal( + onPressed: () => context.go('/editor'), + child: const Text('写一篇'), + ), + ], + ), + ); + } +} + +/// 日记列表 +class _DayJournalList extends StatelessWidget { + const _DayJournalList({required this.journals}); + + final List journals; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: journals.length, + itemBuilder: (context, index) { + final journal = journals[index]; + final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: InkWell( + onTap: () => context.go('/editor?id=${journal.id}'), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // 心情图标 + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: moodColor.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + _moodEmoji(journal.mood), + style: const TextStyle(fontSize: 20), + ), + ), + const SizedBox(width: 12), + // 标题和标签 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + journal.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (journal.tags.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + journal.tags.take(3).join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ], + ), + ), + Icon( + Icons.chevron_right, + color: colorScheme.onSurface.withValues(alpha: 0.3), + ), + ], + ), + ), + ), + ); + }, + ); + } + + String _moodEmoji(Mood mood) { + return switch (mood) { + Mood.happy => '😊', + Mood.calm => '😌', + Mood.sad => '😢', + Mood.angry => '😠', + Mood.thinking => '🤔', + }; + } +} diff --git a/app/lib/features/home/views/home_page.dart b/app/lib/features/home/views/home_page.dart index 485ff98..c21bc94 100644 --- a/app/lib/features/home/views/home_page.dart +++ b/app/lib/features/home/views/home_page.dart @@ -1,13 +1,184 @@ -import 'package:flutter/material.dart'; +// 首页 — 日记流 + 心情概览 +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/data/models/journal_entry.dart'; + +/// 首页 — 展示最近日记流和心情概览 class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('首页 - 占位页面'), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + appBar: AppBar( + title: Text( + '暖记', + style: theme.textTheme.headlineSmall?.copyWith( + fontFamily: 'Caveat', + color: colorScheme.primary, + ), + ), + actions: [ + IconButton( + onPressed: () => context.go('/stickers'), + icon: const Icon(Icons.emoji_emotions_outlined), + tooltip: '贴纸库', + ), + IconButton( + onPressed: () => context.go('/templates'), + icon: const Icon(Icons.dashboard_customize_outlined), + tooltip: '模板', + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 心情快速选择卡片 + _QuickMoodCard(colorScheme: colorScheme), + const SizedBox(height: 20), + + // 最近日记标题 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '最近日记', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + TextButton( + onPressed: () => context.go('/calendar'), + child: const Text('查看全部'), + ), + ], + ), + const SizedBox(height: 12), + + // 日记流占位 — 待数据层集成后替换 + const _EmptyJournalState(), + ], + ), + ), + ); + } +} + +/// 心情快速选择卡片 +class _QuickMoodCard extends StatelessWidget { + const _QuickMoodCard({required this.colorScheme}); + + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final moods = [ + ('😊', '开心', Mood.happy), + ('😌', '平静', Mood.calm), + ('😢', '难过', Mood.sad), + ('😠', '生气', Mood.angry), + ('🤔', '思考', Mood.thinking), + ]; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '今天心情如何?', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: moods.map((mood) { + return GestureDetector( + onTap: () => context.go('/editor'), + child: Column( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (AppColors.moodColors[mood.$3.value] ?? + colorScheme.primary) + .withValues(alpha: 0.15), + ), + alignment: Alignment.center, + child: Text(mood.$1, style: const TextStyle(fontSize: 22)), + ), + const SizedBox(height: 4), + Text( + mood.$2, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ), + ); + } +} + +/// 空日记状态 +class _EmptyJournalState extends StatelessWidget { + const _EmptyJournalState(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + children: [ + Icon( + Icons.edit_note_rounded, + size: 64, + color: colorScheme.onSurface.withValues(alpha: 0.2), + ), + const SizedBox(height: 16), + Text( + '开始你的第一篇手账日记吧!', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () => context.go('/editor'), + icon: const Icon(Icons.add_rounded), + label: const Text('写日记'), + ), + ], + ), ), ); } diff --git a/app/lib/features/mood/bloc/mood_bloc.dart b/app/lib/features/mood/bloc/mood_bloc.dart new file mode 100644 index 0000000..e6dc9d0 --- /dev/null +++ b/app/lib/features/mood/bloc/mood_bloc.dart @@ -0,0 +1,128 @@ +// 心情 BLoC — 管理心情统计和趋势数据 + +import 'package:flutter/material.dart'; +import 'package:nuanji_app/data/models/journal_entry.dart'; + +// ===== 心情统计模型 ===== + +/// 心情统计数据 +class MoodStats { + final List moodCounts; + final int streakDays; + final int totalJournals; + final Mood? dominantMood; + + const MoodStats({ + this.moodCounts = const [], + this.streakDays = 0, + this.totalJournals = 0, + this.dominantMood, + }); +} + +/// 单种心情的统计 +class MoodCount { + final Mood mood; + final int count; + final double percentage; + + const MoodCount({ + required this.mood, + required this.count, + required this.percentage, + }); +} + +/// 心情趋势数据点 +class MoodTrendPoint { + final DateTime date; + final Mood mood; + final int journalCount; + + const MoodTrendPoint({ + required this.date, + required this.mood, + required this.journalCount, + }); +} + +/// 统计周期 +enum StatsPeriod { week, month, quarter } + +// ===== 心情页面状态 ===== + +/// 心情页面状态 +class MoodState { + final MoodStats stats; + final List trendData; + final StatsPeriod selectedPeriod; + final bool isLoading; + final String? errorMessage; + + const MoodState({ + this.stats = const MoodStats(), + this.trendData = const [], + this.selectedPeriod = StatsPeriod.week, + this.isLoading = false, + this.errorMessage, + }); + + MoodState copyWith({ + MoodStats? stats, + List? trendData, + StatsPeriod? selectedPeriod, + bool? isLoading, + String? errorMessage, + }) => + MoodState( + stats: stats ?? this.stats, + trendData: trendData ?? this.trendData, + selectedPeriod: selectedPeriod ?? this.selectedPeriod, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); +} + +// ===== 心情 BLoC ===== + +class MoodBloc extends ChangeNotifier { + MoodState _state = const MoodState(); + MoodState get state => _state; + + /// 切换统计周期 + void changePeriod(StatsPeriod period) { + _state = _state.copyWith(selectedPeriod: period, isLoading: true); + notifyListeners(); + _loadStats(); + } + + /// 加载统计数据 + Future _loadStats() async { + // Phase 1: 占位数据,待 API 集成后替换 + await Future.delayed(const Duration(milliseconds: 300)); + + _state = _state.copyWith( + isLoading: false, + stats: MoodStats( + moodCounts: [ + const MoodCount(mood: Mood.happy, count: 12, percentage: 40.0), + const MoodCount(mood: Mood.calm, count: 8, percentage: 26.7), + const MoodCount(mood: Mood.thinking, count: 5, percentage: 16.7), + const MoodCount(mood: Mood.sad, count: 3, percentage: 10.0), + const MoodCount(mood: Mood.angry, count: 2, percentage: 6.6), + ], + streakDays: 7, + totalJournals: 30, + dominantMood: Mood.happy, + ), + ); + notifyListeners(); + } + + /// 初始加载 + void load() { + _state = _state.copyWith(isLoading: true); + notifyListeners(); + _loadStats(); + } +} diff --git a/app/lib/features/mood/views/mood_page.dart b/app/lib/features/mood/views/mood_page.dart index 4477769..03389e2 100644 --- a/app/lib/features/mood/views/mood_page.dart +++ b/app/lib/features/mood/views/mood_page.dart @@ -1,14 +1,356 @@ -import 'package:flutter/material.dart'; +// 心情页面 — 心情统计 + 趋势图 + 连续天数 -class MoodPage extends StatelessWidget { +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/data/models/journal_entry.dart'; +import '../bloc/mood_bloc.dart'; + +/// 心情页面 — 统计卡片 + 心情分布饼图 + 趋势折线图 +class MoodPage extends StatefulWidget { const MoodPage({super.key}); + @override + State createState() => _MoodPageState(); +} + +class _MoodPageState extends State { + final _bloc = MoodBloc(); + + @override + void initState() { + super.initState(); + _bloc.load(); + } + + @override + void dispose() { + _bloc.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('心情 - 占位页面'), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return ListenableBuilder( + listenable: _bloc, + builder: (context, _) { + final state = _bloc.state; + + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 统计概览卡片 + _StatsOverviewCard(stats: state.stats, colorScheme: colorScheme), + const SizedBox(height: 16), + + // 周期选择器 + _PeriodSelector( + selectedPeriod: state.selectedPeriod, + onPeriodChanged: _bloc.changePeriod, + ), + const SizedBox(height: 16), + + // 心情分布饼图 + _MoodDistributionChart( + moodCounts: state.stats.moodCounts, + colorScheme: colorScheme, + ), + const SizedBox(height: 24), + + // 心情详情列表 + Text( + '心情详情', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ...state.stats.moodCounts.map((mc) => _MoodCountTile(mc: mc)), + + const SizedBox(height: 24), + + // 连续天数鼓励卡片 + _StreakCard(streakDays: state.stats.streakDays), + ], + ), + ); + }, + ); + } +} + +/// 统计概览卡片 +class _StatsOverviewCard extends StatelessWidget { + const _StatsOverviewCard({ + required this.stats, + required this.colorScheme, + }); + + final MoodStats stats; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dominantEmoji = stats.dominantMood != null + ? _moodEmoji(stats.dominantMood!) + : '📝'; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + color: colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + // 主导心情图标 + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.primary.withValues(alpha: 0.15), + ), + alignment: Alignment.center, + child: Text(dominantEmoji, style: const TextStyle(fontSize: 28)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '心情概览', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '共 ${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays} 天', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), ), ); } } + +/// 周期选择器 +class _PeriodSelector extends StatelessWidget { + const _PeriodSelector({ + required this.selectedPeriod, + required this.onPeriodChanged, + }); + + final StatsPeriod selectedPeriod; + final ValueChanged onPeriodChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: const [ + ButtonSegment(value: StatsPeriod.week, label: Text('周')), + ButtonSegment(value: StatsPeriod.month, label: Text('月')), + ButtonSegment(value: StatsPeriod.quarter, label: Text('季')), + ], + selected: {selectedPeriod}, + onSelectionChanged: (set) => onPeriodChanged(set.first), + ); + } +} + +/// 心情分布饼图 +class _MoodDistributionChart extends StatelessWidget { + const _MoodDistributionChart({ + required this.moodCounts, + required this.colorScheme, + }); + + final List moodCounts; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + if (moodCounts.isEmpty) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: SizedBox( + height: 200, + child: PieChart( + PieChartData( + sections: moodCounts.map((mc) { + final color = AppColors.moodColors[mc.mood.value] ?? colorScheme.primary; + return PieChartSectionData( + value: mc.count.toDouble(), + color: color, + radius: 50, + title: '${mc.percentage.toStringAsFixed(0)}%', + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ); + }).toList(), + sectionsSpace: 2, + centerSpaceRadius: 40, + ), + ), + ), + ), + ); + } +} + +/// 心情计数列表项 +class _MoodCountTile extends StatelessWidget { + const _MoodCountTile({required this.mc}); + + final MoodCount mc; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Text(_moodEmoji(mc.mood), style: const TextStyle(fontSize: 20)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _moodLabel(mc.mood), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: mc.percentage / 100, + backgroundColor: color.withValues(alpha: 0.15), + color: color, + minHeight: 6, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 48, + child: Text( + '${mc.count} 篇', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } +} + +/// 连续天数鼓励卡片 +class _StreakCard extends StatelessWidget { + const _StreakCard({required this.streakDays}); + + final int streakDays; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + color: AppColors.tertiary.withValues(alpha: 0.15), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + const Text('🔥', style: TextStyle(fontSize: 32)), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '连续 $streakDays 天', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + streakDays >= 7 + ? '太棒了!你已经坚持了一周 ✨' + : '继续加油,坚持就是胜利!', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ===== 辅助函数 ===== + +String _moodEmoji(Mood mood) => switch (mood) { + Mood.happy => '😊', + Mood.calm => '😌', + Mood.sad => '😢', + Mood.angry => '😠', + Mood.thinking => '🤔', + }; + +String _moodLabel(Mood mood) => switch (mood) { + Mood.happy => '开心', + Mood.calm => '平静', + Mood.sad => '难过', + Mood.angry => '生气', + Mood.thinking => '思考', + }; diff --git a/app/lib/features/stickers/views/sticker_library_page.dart b/app/lib/features/stickers/views/sticker_library_page.dart index 9cfb6b8..dc691e7 100644 --- a/app/lib/features/stickers/views/sticker_library_page.dart +++ b/app/lib/features/stickers/views/sticker_library_page.dart @@ -1,13 +1,215 @@ -import 'package:flutter/material.dart'; +// 贴纸库页面 — 贴纸包浏览 + 贴纸网格 -class StickerLibraryPage extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; + +/// 贴纸包数据模型 +class StickerPack { + final String id; + final String name; + final String? coverEmoji; + final int stickerCount; + final bool isFree; + final String? category; + + const StickerPack({ + required this.id, + required this.name, + this.coverEmoji, + this.stickerCount = 0, + this.isFree = true, + this.category, + }); +} + +/// 贴纸库页面 — 分类浏览贴纸包 +class StickerLibraryPage extends StatefulWidget { const StickerLibraryPage({super.key}); + @override + State createState() => _StickerLibraryPageState(); +} + +class _StickerLibraryPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // Phase 1 占位数据 + final _categories = ['全部', '动物', '食物', '自然', '节日', '表情']; + + final _packs = const [ + StickerPack(id: '1', name: '可爱猫咪', coverEmoji: '🐱', stickerCount: 24, isFree: true, category: '动物'), + StickerPack(id: '2', name: '小兔子系列', coverEmoji: '🐰', stickerCount: 20, isFree: true, category: '动物'), + StickerPack(id: '3', name: '甜品派对', coverEmoji: '🍰', stickerCount: 18, isFree: true, category: '食物'), + StickerPack(id: '4', name: '花朵合集', coverEmoji: '🌸', stickerCount: 22, isFree: true, category: '自然'), + StickerPack(id: '5', name: '夏日清凉', coverEmoji: '🍉', stickerCount: 16, isFree: true, category: '食物'), + StickerPack(id: '6', name: '星空物语', coverEmoji: '⭐', stickerCount: 20, isFree: false, category: '自然'), + StickerPack(id: '7', name: '开心表情', coverEmoji: '😄', stickerCount: 30, isFree: true, category: '表情'), + StickerPack(id: '8', name: '新年快乐', coverEmoji: '🎉', stickerCount: 15, isFree: false, category: '节日'), + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _categories.length, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('贴纸库 - 占位页面'), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('贴纸库'), + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: _categories.map((c) => Tab(text: c)).toList(), + ), + ), + body: TabBarView( + controller: _tabController, + children: _categories.map((category) { + final filtered = category == '全部' + ? _packs + : _packs.where((p) => p.category == category).toList(); + return _StickerPackGrid(packs: filtered, colorScheme: colorScheme); + }).toList(), + ), + ); + } +} + +/// 贴纸包网格 +class _StickerPackGrid extends StatelessWidget { + const _StickerPackGrid({ + required this.packs, + required this.colorScheme, + }); + + final List packs; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + if (packs.isEmpty) { + return const Center(child: Text('暂无贴纸包')); + } + + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.85, + ), + itemCount: packs.length, + itemBuilder: (context, index) { + final pack = packs[index]; + return _StickerPackCard(pack: pack, colorScheme: colorScheme); + }, + ); + } +} + +/// 贴纸包卡片 +class _StickerPackCard extends StatelessWidget { + const _StickerPackCard({ + required this.pack, + required this.colorScheme, + }); + + final StickerPack pack; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: InkWell( + onTap: () { + // Phase 1: 展示贴纸包详情页(待实现) + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('打开贴纸包: ${pack.name}')), + ); + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 贴纸包封面图标 + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.center, + child: Text( + pack.coverEmoji ?? '🎨', + style: const TextStyle(fontSize: 32), + ), + ), + const SizedBox(height: 12), + // 名称 + Text( + pack.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // 数量和价格标签 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${pack.stickerCount} 张', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + if (!pack.isFree) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '积分', + style: theme.textTheme.labelSmall?.copyWith( + color: AppColors.accent, + ), + ), + ), + ], + ], + ), + ], + ), + ), ), ); } diff --git a/app/lib/features/templates/views/template_gallery_page.dart b/app/lib/features/templates/views/template_gallery_page.dart index 7f4e734..7742047 100644 --- a/app/lib/features/templates/views/template_gallery_page.dart +++ b/app/lib/features/templates/views/template_gallery_page.dart @@ -1,13 +1,189 @@ -import 'package:flutter/material.dart'; +// 模板画廊页面 — 日记模板浏览和选择 -class TemplateGalleryPage extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; + +/// 模板数据模型 +class Template { + final String id; + final String name; + final String emoji; + final String? category; + final bool isFree; + final String? description; + + const Template({ + required this.id, + required this.name, + required this.emoji, + this.category, + this.isFree = true, + this.description, + }); +} + +/// 模板画廊页面 — 浏览和选择日记模板 +class TemplateGalleryPage extends StatefulWidget { const TemplateGalleryPage({super.key}); + @override + State createState() => _TemplateGalleryPageState(); +} + +class _TemplateGalleryPageState extends State { + String _selectedCategory = '全部'; + + final _categories = ['全部', '日常', '旅行', '校园', '节日', '创意']; + + // Phase 1 占位数据 + final _templates = const [ + Template(id: '1', name: '今日心情', emoji: '💭', category: '日常', description: '记录今天的心情和感受'), + Template(id: '2', name: '校园日记', emoji: '📚', category: '校园', description: '在学校的一天'), + Template(id: '3', name: '旅行手账', emoji: '🗺️', category: '旅行', description: '记录旅行中的美好瞬间'), + Template(id: '4', name: '美食记录', emoji: '🍜', category: '日常', description: '记录今天吃到的美食'), + Template(id: '5', name: '读书笔记', emoji: '📖', category: '校园', description: '记录读完一本书的感想'), + Template(id: '6', name: '节日特辑', emoji: '🎄', category: '节日', description: '特别的节日记录'), + Template(id: '7', name: '自然观察', emoji: '🌿', category: '创意', description: '记录大自然的发现'), + Template(id: '8', name: '梦想清单', emoji: '✨', category: '创意', description: '写下心中的梦想'), + Template(id: '9', name: '周末时光', emoji: '☀️', category: '日常', description: '悠闲的周末记录'), + Template(id: '10', name: '运动打卡', emoji: '🏃', category: '日常', description: '记录运动和锻炼'), + ]; + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('模板画廊 - 占位页面'), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final filtered = _selectedCategory == '全部' + ? _templates + : _templates.where((t) => t.category == _selectedCategory).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('模板画廊'), + ), + body: Column( + children: [ + // 分类选择器 + SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: _categories.map((cat) { + final isSelected = cat == _selectedCategory; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + selected: isSelected, + label: Text(cat), + onSelected: (_) { + setState(() => _selectedCategory = cat); + }, + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.primary, + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 8), + + // 模板网格 + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.78, + ), + itemCount: filtered.length, + itemBuilder: (context, index) { + return _TemplateCard(template: filtered[index]); + }, + ), + ), + ], + ), + ); + } +} + +/// 模板卡片 +class _TemplateCard extends StatelessWidget { + const _TemplateCard({required this.template}); + + final Template template; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: InkWell( + onTap: () { + // 使用模板创建日记 + context.go('/editor?template=${template.id}'); + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 模板预览区 + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primaryContainer.withValues(alpha: 0.5), + AppColors.tertiary.withValues(alpha: 0.3), + ], + ), + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.center, + child: Text( + template.emoji, + style: const TextStyle(fontSize: 36), + ), + ), + const SizedBox(height: 12), + // 模板名称 + Text( + template.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + // 描述 + if (template.description != null) + Text( + template.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), + ), ), ); } diff --git a/crates/erp-diary/src/dto.rs b/crates/erp-diary/src/dto.rs index edb7b90..eafa3b1 100644 --- a/crates/erp-diary/src/dto.rs +++ b/crates/erp-diary/src/dto.rs @@ -178,3 +178,109 @@ pub struct CommentResp { pub content: String, pub created_at: chrono::DateTime, } + +// ========== 通知 ========== + +/// 通知类型 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NotificationType { + /// 收到评语 + CommentReceived, + /// 主题布置 + TopicAssigned, + /// 成就解锁 + AchievementUnlocked, + /// 班级动态 + ClassUpdate, +} + +/// SSE 通知推送负载 +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct NotificationPayload { + /// 通知类型 + pub notification_type: NotificationType, + /// 目标用户 ID + pub recipient_id: uuid::Uuid, + /// 通知标题 + pub title: String, + /// 通知内容 + pub body: String, + /// 关联业务 ID(评语 ID / 主题 ID / 成就 ID) + pub business_id: Option, + /// 附加数据 + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +// ========== 心情统计 ========== + +/// 心情统计响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct MoodStatsResp { + /// 统计周期内各心情出现次数 + pub mood_counts: Vec, + /// 连续写日记天数 + pub streak_days: i32, + /// 统计周期内总日记数 + pub total_journals: i32, + /// 最常用的心情 + pub dominant_mood: Option, +} + +/// 单种心情的统计 +#[derive(Debug, Serialize, ToSchema)] +pub struct MoodCount { + pub mood: Mood, + pub count: i32, + pub percentage: f64, +} + +// ========== 贴纸/模板 ========== + +/// 贴纸包响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct StickerPackResp { + pub id: uuid::Uuid, + pub name: String, + pub description: Option, + pub cover_image_url: Option, + pub sticker_count: i32, + pub is_free: bool, + pub category: Option, +} + +/// 贴纸响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct StickerResp { + pub id: uuid::Uuid, + pub pack_id: uuid::Uuid, + pub name: String, + pub image_url: String, + pub category: Option, +} + +/// 模板响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct TemplateResp { + pub id: uuid::Uuid, + pub name: String, + pub description: Option, + pub preview_url: Option, + pub template_data: Option, + pub category: Option, + pub is_free: bool, +} + +/// 成就响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct AchievementResp { + pub id: uuid::Uuid, + pub code: String, + pub name: String, + pub description: Option, + pub icon: Option, + pub category: String, + pub is_unlocked: bool, + pub unlocked_at: Option>, +} diff --git a/crates/erp-diary/src/handler/achievement_handler.rs b/crates/erp-diary/src/handler/achievement_handler.rs new file mode 100644 index 0000000..8e62efb --- /dev/null +++ b/crates/erp-diary/src/handler/achievement_handler.rs @@ -0,0 +1,78 @@ +// 成就 API 处理器 + +use axum::extract::{Extension, FromRef, Path, State}; +use axum::response::Json; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::dto::AchievementResp; +use crate::service::achievement_service::AchievementService; +use crate::state::DiaryState; + +#[utoipa::path( + get, + path = "/api/v1/diary/achievements", + responses( + (status = 200, description = "成功", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "成就管理" +)] +/// GET /api/v1/diary/achievements +/// +/// 获取所有成就列表(含当前用户解锁状态)。需要 `diary.journal.read` 权限。 +pub async fn list_achievements( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = + AchievementService::list_achievements(ctx.tenant_id, ctx.user_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/diary/achievements/{code}/unlock", + params(("code" = String, Path, description = "成就编码")), + responses( + (status = 200, description = "解锁成功", body = ApiResponse), + (status = 404, description = "成就不存在"), + ), + security(("bearer_auth" = [])), + tag = "成就管理" +)] +/// POST /api/v1/diary/achievements/:code/unlock +/// +/// 解锁成就(幂等)。需要 `diary.journal.read` 权限。 +pub async fn unlock_achievement( + State(state): State, + Extension(ctx): Extension, + Path(code): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = AchievementService::unlock_achievement( + ctx.tenant_id, + ctx.user_id, + &code, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-diary/src/handler/comment_handler.rs b/crates/erp-diary/src/handler/comment_handler.rs index aab066b..8ee453e 100644 --- a/crates/erp-diary/src/handler/comment_handler.rs +++ b/crates/erp-diary/src/handler/comment_handler.rs @@ -21,7 +21,7 @@ use crate::state::DiaryState; (status = 200, description = "点评成功", body = ApiResponse), (status = 400, description = "验证失败或内容安全检查未通过"), (status = 401, description = "未授权"), - (status = 403, description = "权限不足"), + (status = 403, description = "权限不足或不是本班老师"), (status = 404, description = "日记不存在"), ), security(("bearer_auth" = [])), @@ -30,6 +30,7 @@ use crate::state::DiaryState; /// POST /api/v1/diary/journals/:journal_id/comments /// /// 老师点评日记。需要 `diary.comment.write` 权限。 +/// 仅本班老师可以点评,私密日记不允许点评。 pub async fn create_comment( State(state): State, Extension(ctx): Extension, @@ -88,3 +89,34 @@ where let resp = CommentService::list_comments(ctx.tenant_id, journal_id, &state.db).await?; Ok(Json(ApiResponse::ok(resp))) } + +#[utoipa::path( + delete, + path = "/api/v1/diary/comments/{comment_id}", + params(("comment_id" = Uuid, Path, description = "评语ID")), + responses( + (status = 200, description = "删除成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足或不是评语作者"), + (status = 404, description = "评语不存在"), + ), + security(("bearer_auth" = [])), + tag = "评语管理" +)] +/// DELETE /api/v1/diary/comments/:comment_id +/// +/// 删除评语。仅评语作者可以删除自己的评语。需要 `diary.comment.delete` 权限。 +pub async fn delete_comment( + State(state): State, + Extension(ctx): Extension, + Path(comment_id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.comment.delete")?; + + CommentService::delete_comment(ctx.tenant_id, ctx.user_id, comment_id, &state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-diary/src/handler/mod.rs b/crates/erp-diary/src/handler/mod.rs index 453d0fb..9aa9a6f 100644 --- a/crates/erp-diary/src/handler/mod.rs +++ b/crates/erp-diary/src/handler/mod.rs @@ -5,3 +5,6 @@ pub mod sync_handler; pub mod class_handler; pub mod topic_handler; pub mod comment_handler; +pub mod sticker_handler; +pub mod achievement_handler; +pub mod stats_handler; diff --git a/crates/erp-diary/src/handler/stats_handler.rs b/crates/erp-diary/src/handler/stats_handler.rs new file mode 100644 index 0000000..9119afb --- /dev/null +++ b/crates/erp-diary/src/handler/stats_handler.rs @@ -0,0 +1,66 @@ +// 统计 API 处理器 + +use axum::extract::{Extension, FromRef, Query, State}; +use axum::response::Json; +use serde::Deserialize; +use utoipa::IntoParams; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::dto::MoodStatsResp; +use crate::service::mood_stats_service::{MoodStatsService, StatsPeriod}; +use crate::state::DiaryState; + +/// 统计查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct StatsQuery { + /// 统计周期:week / month / quarter(默认 month) + pub period: Option, +} + +fn parse_period(s: &Option) -> StatsPeriod { + match s.as_deref() { + Some("week") => StatsPeriod::Week, + Some("quarter") => StatsPeriod::Quarter, + _ => StatsPeriod::Month, + } +} + +#[utoipa::path( + get, + path = "/api/v1/diary/stats/mood", + params(StatsQuery), + responses( + (status = 200, description = "成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "统计" +)] +/// GET /api/v1/diary/stats/mood +/// +/// 获取当前用户的心情统计。需要 `diary.journal.read` 权限。 +pub async fn get_mood_stats( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let period = parse_period(&query.period); + + let resp = MoodStatsService::get_mood_stats( + ctx.tenant_id, + ctx.user_id, + period, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-diary/src/handler/sticker_handler.rs b/crates/erp-diary/src/handler/sticker_handler.rs new file mode 100644 index 0000000..b463b42 --- /dev/null +++ b/crates/erp-diary/src/handler/sticker_handler.rs @@ -0,0 +1,157 @@ +// 贴纸与模板 API 处理器 + +use axum::extract::{Extension, FromRef, Path, Query, State}; +use axum::response::Json; +use serde::Deserialize; +use utoipa::IntoParams; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::dto::{StickerPackResp, StickerResp, TemplateResp}; +use crate::service::sticker_service::StickerService; +use crate::state::DiaryState; + +/// 贴纸包查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct StickerPackQuery { + pub category: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/diary/sticker-packs", + params(StickerPackQuery), + responses( + (status = 200, description = "成功", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "贴纸管理" +)] +/// GET /api/v1/diary/sticker-packs +/// +/// 获取贴纸包列表。需要 `diary.journal.read` 权限。 +pub async fn list_sticker_packs( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = StickerService::list_sticker_packs( + ctx.tenant_id, + query.category, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/sticker-packs/{pack_id}/stickers", + params(("pack_id" = Uuid, Path, description = "贴纸包ID")), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 404, description = "贴纸包不存在"), + ), + security(("bearer_auth" = [])), + tag = "贴纸管理" +)] +/// GET /api/v1/diary/sticker-packs/:pack_id/stickers +/// +/// 获取贴纸包内的贴纸列表。需要 `diary.journal.read` 权限。 +pub async fn list_stickers_in_pack( + State(state): State, + Extension(ctx): Extension, + Path(pack_id): Path, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = + StickerService::list_stickers_in_pack(ctx.tenant_id, pack_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +/// 模板查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct TemplateQuery { + pub category: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/diary/templates", + params(TemplateQuery), + responses( + (status = 200, description = "成功", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "模板管理" +)] +/// GET /api/v1/diary/templates +/// +/// 获取模板列表。需要 `diary.journal.read` 权限。 +pub async fn list_templates( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = StickerService::list_templates( + ctx.tenant_id, + query.category, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/templates/{template_id}", + params(("template_id" = Uuid, Path, description = "模板ID")), + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 404, description = "模板不存在"), + ), + security(("bearer_auth" = [])), + tag = "模板管理" +)] +/// GET /api/v1/diary/templates/:template_id +/// +/// 获取模板详情(含布局数据)。需要 `diary.journal.read` 权限。 +pub async fn get_template( + State(state): State, + Extension(ctx): Extension, + Path(template_id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = + StickerService::get_template(ctx.tenant_id, template_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-diary/src/lib.rs b/crates/erp-diary/src/lib.rs index 9a9d346..232bf1a 100644 --- a/crates/erp-diary/src/lib.rs +++ b/crates/erp-diary/src/lib.rs @@ -10,7 +10,10 @@ pub use state::DiaryState; use erp_core::module::ErpModule; -use crate::handler::{journal_handler, sync_handler, class_handler, topic_handler, comment_handler}; +use crate::handler::{ + journal_handler, sync_handler, class_handler, topic_handler, comment_handler, + sticker_handler, achievement_handler, stats_handler, +}; /// 暖记日记业务模块 pub struct DiaryModule; @@ -80,6 +83,12 @@ impl ErpModule for DiaryModule { module: "diary".into(), description: "允许老师点评日记".into(), }, + erp_core::module::PermissionDescriptor { + code: "diary.comment.delete".into(), + name: "删除评语".into(), + module: "diary".into(), + description: "允许删除自己的评语".into(), + }, erp_core::module::PermissionDescriptor { code: "diary.parent.bind".into(), name: "家长绑定".into(), @@ -157,5 +166,41 @@ impl DiaryModule { axum::routing::post(comment_handler::create_comment) .get(comment_handler::list_comments), ) + .route( + "/diary/comments/{comment_id}", + axum::routing::delete(comment_handler::delete_comment), + ) + // 贴纸管理 + .route( + "/diary/sticker-packs", + axum::routing::get(sticker_handler::list_sticker_packs), + ) + .route( + "/diary/sticker-packs/{pack_id}/stickers", + axum::routing::get(sticker_handler::list_stickers_in_pack), + ) + // 模板管理 + .route( + "/diary/templates", + axum::routing::get(sticker_handler::list_templates), + ) + .route( + "/diary/templates/{template_id}", + axum::routing::get(sticker_handler::get_template), + ) + // 成就管理 + .route( + "/diary/achievements", + axum::routing::get(achievement_handler::list_achievements), + ) + .route( + "/diary/achievements/{code}/unlock", + axum::routing::post(achievement_handler::unlock_achievement), + ) + // 统计 + .route( + "/diary/stats/mood", + axum::routing::get(stats_handler::get_mood_stats), + ) } } diff --git a/crates/erp-diary/src/service/achievement_service.rs b/crates/erp-diary/src/service/achievement_service.rs new file mode 100644 index 0000000..f81c4ab --- /dev/null +++ b/crates/erp-diary/src/service/achievement_service.rs @@ -0,0 +1,158 @@ +// 成就服务 — 成就定义与解锁逻辑 + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::dto::AchievementResp; +use crate::entity::{achievement, user_achievement}; +use crate::error::{DiaryError, DiaryResult}; +use crate::service::notification_service::NotificationService; +use erp_core::events::EventBus; + +/// 成就服务 — 规则引擎 + 徽章解锁 +/// +/// Phase 1 成就规则(客户端触发): +/// - first_diary: 写第一篇日记 +/// - streak_7: 连续写日记 7 天 +/// - streak_30: 连续写日记 30 天 +/// - sticker_collector: 收集 10 张贴纸 +/// - social_butterfly: 分享 5 篇日记到班级 +pub struct AchievementService; + +impl AchievementService { + /// 获取所有成就列表(含用户解锁状态) + pub async fn list_achievements( + tenant_id: Uuid, + user_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + // 查询所有成就定义 + let achievements = achievement::Entity::find() + .filter(achievement::Column::TenantId.eq(tenant_id)) + .filter(achievement::Column::DeletedAt.is_null()) + .order_by_asc(achievement::Column::SortOrder) + .all(db) + .await?; + + // 查询用户已解锁的成就 + let unlocked = user_achievement::Entity::find() + .filter(user_achievement::Column::UserId.eq(user_id)) + .filter(user_achievement::Column::TenantId.eq(tenant_id)) + .filter(user_achievement::Column::DeletedAt.is_null()) + .all(db) + .await?; + + // 构建已解锁集合 + let unlocked_map: std::collections::HashMap> = unlocked + .into_iter() + .map(|ua| (ua.achievement_id, ua.unlocked_at)) + .collect(); + + Ok(achievements + .into_iter() + .map(|a| { + let (is_unlocked, unlocked_at) = unlocked_map + .get(&a.id) + .map(|t| (true, Some(*t))) + .unwrap_or((false, None)); + + AchievementResp { + id: a.id, + code: a.code, + name: a.name, + description: a.description, + icon: a.icon, + category: a.category, + is_unlocked, + unlocked_at, + } + }) + .collect()) + } + + /// 解锁成就 + /// + /// 幂等操作:如果已解锁则直接返回。解锁后发送 SSE 通知。 + pub async fn unlock_achievement( + tenant_id: Uuid, + user_id: Uuid, + achievement_code: &str, + db: &DatabaseConnection, + event_bus: &EventBus, + ) -> DiaryResult { + // 查找成就定义 + let ach = achievement::Entity::find() + .filter(achievement::Column::TenantId.eq(tenant_id)) + .filter(achievement::Column::Code.eq(achievement_code)) + .filter(achievement::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| { + DiaryError::NotFound(format!("成就 {} 不存在", achievement_code)) + })?; + + // 检查是否已解锁 + let existing = user_achievement::Entity::find() + .filter(user_achievement::Column::UserId.eq(user_id)) + .filter(user_achievement::Column::AchievementId.eq(ach.id)) + .filter(user_achievement::Column::DeletedAt.is_null()) + .one(db) + .await?; + + if existing.is_some() { + // 已解锁,幂等返回 + return Ok(AchievementResp { + id: ach.id, + code: ach.code.clone(), + name: ach.name.clone(), + description: ach.description.clone(), + icon: ach.icon.clone(), + category: ach.category.clone(), + is_unlocked: true, + unlocked_at: existing.map(|e| e.unlocked_at), + }); + } + + // 创建解锁记录 + let now = Utc::now(); + let system_user = Uuid::nil(); + let model = user_achievement::ActiveModel { + user_id: Set(user_id), + achievement_id: Set(ach.id), + tenant_id: Set(tenant_id), + unlocked_at: Set(now), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), + }; + model.insert(db).await?; + + // 发送成就解锁通知 + NotificationService::notify_achievement_unlocked( + tenant_id, + user_id, + ach.code.clone(), + ach.name.clone(), + db, + event_bus, + ) + .await; + + Ok(AchievementResp { + id: ach.id, + code: ach.code, + name: ach.name, + description: ach.description, + icon: ach.icon, + category: ach.category, + is_unlocked: true, + unlocked_at: Some(now), + }) + } +} diff --git a/crates/erp-diary/src/service/class_service.rs b/crates/erp-diary/src/service/class_service.rs index 11f6e24..9eae824 100644 --- a/crates/erp-diary/src/service/class_service.rs +++ b/crates/erp-diary/src/service/class_service.rs @@ -268,7 +268,7 @@ impl ClassService { /// 生成 6 位班级码(UUID 前 6 位字符) fn generate_class_code() -> String { - Uuid::new_v4() + Uuid::now_v7() .to_string() .replace("-", "") .chars() diff --git a/crates/erp-diary/src/service/comment_service.rs b/crates/erp-diary/src/service/comment_service.rs index 22d758b..9766ac9 100644 --- a/crates/erp-diary/src/service/comment_service.rs +++ b/crates/erp-diary/src/service/comment_service.rs @@ -7,18 +7,27 @@ use sea_orm::{ use uuid::Uuid; use crate::dto::CommentResp; -use crate::entity::{comment, journal_entry}; +use crate::entity::{class_member, comment, journal_entry}; use crate::error::{DiaryError, DiaryResult}; +use crate::service::notification_service::NotificationService; use erp_core::events::{DomainEvent, EventBus}; /// 评语服务 — 老师对学生日记的点评 +/// +/// 权限约束: +/// - 仅本班老师可以点评学生日记 +/// - 老师必须与日记作者属于同一班级 pub struct CommentService; impl CommentService { /// 添加评语(老师点评学生日记) /// - /// 验证日记存在,执行基础内容安全检查, - /// 创建评论记录,发布 CommentCreated 事件。 + /// 流程: + /// 1. 验证日记存在且未删除 + /// 2. 验证点评者是日记所属班级的老师 + /// 3. 执行内容安全检查 + /// 4. 创建评论记录 + /// 5. 发布 CommentCreated 事件(触发 SSE 推送) pub async fn create_comment( tenant_id: Uuid, author_id: Uuid, @@ -36,7 +45,15 @@ impl CommentService { .await? .ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", journal_id)))?; - // 2. 简单内容安全检查(基础敏感词过滤) + // 2. 班级成员验证:点评者必须是日记所属班级的老师 + if let Some(class_id) = journal.class_id { + Self::verify_teacher_in_class(tenant_id, author_id, class_id, db).await?; + } else { + // 私密日记(无班级)不允许点评 + return Err(DiaryError::Forbidden); + } + + // 3. 简单内容安全检查(基础敏感词过滤) if contains_sensitive_words(&content) { return Err(DiaryError::ContentSafetyViolation); } @@ -44,13 +61,13 @@ impl CommentService { let now = Utc::now(); let id = Uuid::now_v7(); - // 3. 创建评论记录 + // 4. 创建评论记录 let model = comment::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), journal_id: Set(journal_id), author_id: Set(author_id), - content: Set(content), + content: Set(content.clone()), created_at: Set(now), updated_at: Set(now), created_by: Set(author_id), @@ -60,7 +77,7 @@ impl CommentService { }; let inserted = model.insert(db).await?; - // 4. 发布 CommentCreated 事件 + // 5. 发布 CommentCreated 事件 event_bus .publish( DomainEvent::new( @@ -71,12 +88,26 @@ impl CommentService { "journal_id": journal_id, "teacher_id": author_id, "student_id": journal.author_id, + "content_preview": content.chars().take(50).collect::(), }), ), db, ) .await; + // 6. 发送 SSE 通知给学生 + NotificationService::notify_comment_created( + tenant_id, + journal.author_id, + author_id, + id, + journal_id, + content.chars().take(50).collect(), + db, + event_bus, + ) + .await; + Ok(comment_model_to_resp(inserted)) } @@ -98,6 +129,63 @@ impl CommentService { Ok(comments.into_iter().map(comment_model_to_resp).collect()) } + + /// 删除评语(仅作者可删除自己的评语) + pub async fn delete_comment( + tenant_id: Uuid, + user_id: Uuid, + comment_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult<()> { + let model = comment::Entity::find() + .filter(comment::Column::Id.eq(comment_id)) + .filter(comment::Column::TenantId.eq(tenant_id)) + .filter(comment::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("评语 {} 不存在", comment_id)))?; + + // 仅评语作者可以删除 + if model.author_id != user_id { + return Err(DiaryError::Forbidden); + } + + let now = Utc::now(); + let current_version = model.version; + let mut active: comment::ActiveModel = model.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(user_id); + active.version = Set(current_version + 1); + active.update(db).await?; + + Ok(()) + } + + /// 验证用户是指定班级的老师 + /// + /// 检查 class_members 表中是否存在 (class_id, user_id, role=teacher) 记录。 + async fn verify_teacher_in_class( + tenant_id: Uuid, + user_id: Uuid, + class_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult<()> { + let membership = class_member::Entity::find() + .filter(class_member::Column::ClassId.eq(class_id)) + .filter(class_member::Column::UserId.eq(user_id)) + .filter(class_member::Column::TenantId.eq(tenant_id)) + .filter(class_member::Column::Role.eq("teacher")) + .filter(class_member::Column::DeletedAt.is_null()) + .one(db) + .await?; + + if membership.is_none() { + return Err(DiaryError::Forbidden); + } + + Ok(()) + } } /// comment::Model -> CommentResp @@ -113,11 +201,11 @@ fn comment_model_to_resp(model: comment::Model) -> CommentResp { /// 基础敏感词检查 /// -/// Phase 1 使用简单字符串匹配,后续迭代替换为完整词库。 +/// Phase 1 使用简单字符串匹配,B6 阶段替换为 ContentSafetyService。 fn contains_sensitive_words(content: &str) -> bool { const SENSITIVE_WORDS: &[&str] = &[ // 占位 — Phase 1 仅检查是否为空或过短 - // 完整词库将在后续迭代中添加 + // 完整词库将在 B6 ContentSafetyService 中添加 ]; if content.trim().is_empty() { @@ -132,3 +220,20 @@ fn contains_sensitive_words(content: &str) -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_content_is_sensitive() { + assert!(contains_sensitive_words("")); + assert!(contains_sensitive_words(" ")); + } + + #[test] + fn normal_content_is_not_sensitive() { + assert!(!contains_sensitive_words("今天天气真好!")); + assert!(!contains_sensitive_words("老师点评:写得不错")); + } +} diff --git a/crates/erp-diary/src/service/content_safety_service.rs b/crates/erp-diary/src/service/content_safety_service.rs new file mode 100644 index 0000000..70d8f1f --- /dev/null +++ b/crates/erp-diary/src/service/content_safety_service.rs @@ -0,0 +1,110 @@ +// 内容安全服务 — 敏感词过滤(含谐音/拼音变体) +// +// Phase 1 使用基础字符串匹配 + 简单变体检测。 +// 后续迭代可接入第三方内容安全 API。 + +/// 内容安全服务 — 敏感词过滤 +pub struct ContentSafetyService; + +/// 敏感词级别 +#[derive(Debug, Clone, PartialEq)] +pub enum SafetyLevel { + /// 安全 + Safe, + /// 需审核 + NeedsReview, + /// 违规 + Violation, +} + +/// 安全检查结果 +#[derive(Debug, Clone)] +pub struct SafetyCheckResult { + /// 安全级别 + pub level: SafetyLevel, + /// 命中的敏感词列表 + pub matched_words: Vec, + /// 过滤后的内容(敏感词替换为 ***) + pub filtered_content: String, +} + +impl ContentSafetyService { + /// 检查内容安全性 + /// + /// 返回检查结果,包含安全级别、命中的敏感词和过滤后的内容。 + /// Phase 1 仅使用基础词库,B6 阶段将扩展为完整词库。 + pub fn check_content(content: &str) -> SafetyCheckResult { + let mut matched_words = Vec::new(); + let mut filtered = content.to_string(); + + // Phase 1 基础敏感词库 + // 完整词库将在后续迭代中从配置文件加载 + const SENSITIVE_WORDS: &[&str] = &[ + // 占位 — Phase 1 仅做框架搭建 + // 实际词库将包含:暴力、色情、政治、侮辱等类别 + // 以及常见谐音和拼音变体 + ]; + + for word in SENSITIVE_WORDS { + if content.contains(word) { + matched_words.push(word.to_string()); + filtered = filtered.replace(word, "***"); + } + } + + let level = if matched_words.is_empty() { + SafetyLevel::Safe + } else { + SafetyLevel::Violation + }; + + SafetyCheckResult { + level, + matched_words, + filtered_content: filtered, + } + } + + /// 快速检查内容是否安全 + /// + /// 返回 true 表示内容安全,false 表示包含敏感内容。 + pub fn is_safe(content: &str) -> bool { + Self::check_content(content).level == SafetyLevel::Safe + } + + /// 过滤内容中的敏感词 + /// + /// 返回过滤后的内容,敏感词替换为 ***。 + pub fn filter_content(content: &str) -> String { + Self::check_content(content).filtered_content + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_content_passes() { + let result = ContentSafetyService::check_content("今天天气真好,我和同学一起写日记"); + assert_eq!(result.level, SafetyLevel::Safe); + assert!(result.matched_words.is_empty()); + } + + #[test] + fn empty_content_is_safe() { + let result = ContentSafetyService::check_content(""); + assert_eq!(result.level, SafetyLevel::Safe); + } + + #[test] + fn is_safe_shortcut_works() { + assert!(ContentSafetyService::is_safe("正常内容")); + } + + #[test] + fn filter_content_returns_original_when_safe() { + let filtered = ContentSafetyService::filter_content("正常内容"); + assert_eq!(filtered, "正常内容"); + } +} diff --git a/crates/erp-diary/src/service/mod.rs b/crates/erp-diary/src/service/mod.rs index 4bcf3c3..ec5b0af 100644 --- a/crates/erp-diary/src/service/mod.rs +++ b/crates/erp-diary/src/service/mod.rs @@ -5,3 +5,8 @@ pub mod sync_service; pub mod class_service; pub mod topic_service; pub mod comment_service; +pub mod notification_service; +pub mod sticker_service; +pub mod achievement_service; +pub mod mood_stats_service; +pub mod content_safety_service; diff --git a/crates/erp-diary/src/service/mood_stats_service.rs b/crates/erp-diary/src/service/mood_stats_service.rs new file mode 100644 index 0000000..4cf34b9 --- /dev/null +++ b/crates/erp-diary/src/service/mood_stats_service.rs @@ -0,0 +1,171 @@ +// 心情统计服务 — 心情趋势与连续天数 + +use chrono::{Duration, NaiveDate, Utc}; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::dto::{Mood, MoodCount, MoodStatsResp}; +use crate::entity::journal_entry; +use crate::error::DiaryResult; + +/// 统计查询范围 +#[derive(Debug, Clone, Deserialize)] +pub enum StatsPeriod { + /// 最近 7 天 + Week, + /// 最近 30 天 + Month, + /// 最近 90 天 + Quarter, +} + +impl StatsPeriod { + /// 转换为天数 + pub fn days(&self) -> i64 { + match self { + StatsPeriod::Week => 7, + StatsPeriod::Month => 30, + StatsPeriod::Quarter => 90, + } + } +} + +/// 心情统计服务 — 聚合查询、趋势分析、连续天数 +pub struct MoodStatsService; + +impl MoodStatsService { + /// 获取心情统计 + /// + /// 统计指定时间范围内各心情出现次数、连续写日记天数、 + /// 最常用心情等数据。 + pub async fn get_mood_stats( + tenant_id: Uuid, + user_id: Uuid, + period: StatsPeriod, + db: &DatabaseConnection, + ) -> DiaryResult { + let since_date = (Utc::now() - Duration::days(period.days())).date_naive(); + + // 查询时间范围内的日记 + let journals = journal_entry::Entity::find() + .filter(journal_entry::Column::TenantId.eq(tenant_id)) + .filter(journal_entry::Column::AuthorId.eq(user_id)) + .filter(journal_entry::Column::Date.gte(since_date)) + .filter(journal_entry::Column::DeletedAt.is_null()) + .all(db) + .await?; + + let total_journals = journals.len() as i32; + + // 计算各心情出现次数 + let mut mood_counts_map: std::collections::HashMap = + std::collections::HashMap::new(); + for journal in &journals { + *mood_counts_map + .entry(journal.mood.clone()) + .or_insert(0) += 1; + } + + let mood_counts: Vec = mood_counts_map + .iter() + .map(|(mood, &count)| { + let percentage = if total_journals > 0 { + (count as f64 / total_journals as f64) * 100.0 + } else { + 0.0 + }; + MoodCount { + mood: parse_mood(mood), + count, + percentage, + } + }) + .collect(); + + // 查找最常用心情 + let dominant_mood = mood_counts + .iter() + .max_by_key(|mc| mc.count) + .map(|mc| mc.mood.clone()); + + // 计算连续写日记天数 + let streak_days = Self::calculate_streak(tenant_id, user_id, db).await?; + + Ok(MoodStatsResp { + mood_counts, + streak_days, + total_journals, + dominant_mood, + }) + } + + /// 计算连续写日记天数 + /// + /// 从今天开始往前数,连续有日记记录的天数。 + async fn calculate_streak( + tenant_id: Uuid, + user_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult { + let journals = journal_entry::Entity::find() + .filter(journal_entry::Column::TenantId.eq(tenant_id)) + .filter(journal_entry::Column::AuthorId.eq(user_id)) + .filter(journal_entry::Column::DeletedAt.is_null()) + .all(db) + .await?; + + // 收集所有有日记的日期 + let mut dates: std::collections::HashSet = + journals.into_iter().map(|j| j.date).collect(); + + let mut streak = 0i32; + let mut check_date = Utc::now().date_naive(); + + // 从今天开始往前检查 + while dates.remove(&check_date) { + streak += 1; + check_date -= Duration::days(1); + } + + Ok(streak) + } +} + +/// 从字符串解析心情枚举 +fn parse_mood(s: &str) -> Mood { + match s { + "happy" => Mood::Happy, + "calm" => Mood::Calm, + "sad" => Mood::Sad, + "angry" => Mood::Angry, + "thinking" => Mood::Thinking, + _ => Mood::Happy, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_mood_known_values() { + assert!(matches!(parse_mood("happy"), Mood::Happy)); + assert!(matches!(parse_mood("calm"), Mood::Calm)); + assert!(matches!(parse_mood("sad"), Mood::Sad)); + assert!(matches!(parse_mood("angry"), Mood::Angry)); + assert!(matches!(parse_mood("thinking"), Mood::Thinking)); + } + + #[test] + fn parse_mood_unknown_defaults_happy() { + assert!(matches!(parse_mood("unknown"), Mood::Happy)); + } + + #[test] + fn stats_period_days() { + assert_eq!(StatsPeriod::Week.days(), 7); + assert_eq!(StatsPeriod::Month.days(), 30); + assert_eq!(StatsPeriod::Quarter.days(), 90); + } +} diff --git a/crates/erp-diary/src/service/notification_service.rs b/crates/erp-diary/src/service/notification_service.rs new file mode 100644 index 0000000..afaf46b --- /dev/null +++ b/crates/erp-diary/src/service/notification_service.rs @@ -0,0 +1,123 @@ +// 通知服务 — 将日记事件转化为 SSE 推送通知 +// +// 此服务监听日记模块的领域事件,通过 EventBus 发布通知事件, +// SSE handler (erp-message) 负责将通知推送给在线用户。 + +use sea_orm::DatabaseConnection; +use uuid::Uuid; + +use erp_core::events::{DomainEvent, EventBus}; + +use crate::dto::{NotificationPayload, NotificationType}; + +/// 通知服务 — 将日记领域事件转化为 SSE 推送 +pub struct NotificationService; + +impl NotificationService { + /// 评语创建通知 + /// + /// 当老师点评日记后,通知学生收到新评语。 + pub async fn notify_comment_created( + tenant_id: Uuid, + student_id: Uuid, + teacher_id: Uuid, + comment_id: Uuid, + journal_id: Uuid, + content_preview: String, + db: &DatabaseConnection, + event_bus: &EventBus, + ) { + let payload = NotificationPayload { + notification_type: NotificationType::CommentReceived, + recipient_id: student_id, + title: "收到新评语".to_string(), + body: content_preview, + business_id: Some(comment_id), + extra: Some(serde_json::json!({ + "journal_id": journal_id, + "teacher_id": teacher_id, + })), + }; + + Self::publish_notification(tenant_id, payload, db, event_bus).await; + } + + /// 主题布置通知 + /// + /// 当老师布置新主题后,通知班级所有学生。 + pub async fn notify_topic_assigned( + tenant_id: Uuid, + class_id: Uuid, + topic_id: Uuid, + title: String, + db: &DatabaseConnection, + event_bus: &EventBus, + ) { + let payload = NotificationPayload { + notification_type: NotificationType::TopicAssigned, + recipient_id: Uuid::nil(), // 班级广播,SSE handler 按 class_id 过滤 + title: "新主题布置".to_string(), + body: title, + business_id: Some(topic_id), + extra: Some(serde_json::json!({ + "class_id": class_id, + })), + }; + + Self::publish_notification(tenant_id, payload, db, event_bus).await; + } + + /// 成就解锁通知 + /// + /// 当用户解锁成就后,通知该用户。 + pub async fn notify_achievement_unlocked( + tenant_id: Uuid, + user_id: Uuid, + achievement_code: String, + achievement_name: String, + db: &DatabaseConnection, + event_bus: &EventBus, + ) { + let payload = NotificationPayload { + notification_type: NotificationType::AchievementUnlocked, + recipient_id: user_id, + title: "恭喜解锁新成就!".to_string(), + body: format!("你解锁了「{}」成就", achievement_name), + business_id: None, + extra: Some(serde_json::json!({ + "achievement_code": achievement_code, + })), + }; + + Self::publish_notification(tenant_id, payload, db, event_bus).await; + } + + /// 发布通知事件到 EventBus + /// + /// 使用 `diary.notification` 作为事件类型前缀, + /// SSE handler 可据此识别并推送给在线用户。 + async fn publish_notification( + tenant_id: Uuid, + payload: NotificationPayload, + db: &DatabaseConnection, + event_bus: &EventBus, + ) { + let event_type = match &payload.notification_type { + NotificationType::CommentReceived => "diary.notification.comment", + NotificationType::TopicAssigned => "diary.notification.topic", + NotificationType::AchievementUnlocked => "diary.notification.achievement", + NotificationType::ClassUpdate => "diary.notification.class_update", + }; + + event_bus + .publish( + DomainEvent::new( + event_type, + tenant_id, + serde_json::to_value(&payload).unwrap_or_default(), + ), + db, + ) + .await; + } +} diff --git a/crates/erp-diary/src/service/sticker_service.rs b/crates/erp-diary/src/service/sticker_service.rs new file mode 100644 index 0000000..e09dd89 --- /dev/null +++ b/crates/erp-diary/src/service/sticker_service.rs @@ -0,0 +1,154 @@ +// 贴纸服务 — 贴纸包与贴纸管理 + +use sea_orm::{ + ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::dto::{StickerPackResp, StickerResp, TemplateResp}; +use crate::entity::{sticker, sticker_pack, template}; +use crate::error::{DiaryError, DiaryResult}; + +/// 贴纸服务 — 贴纸包浏览、贴纸查询、模板管理 +pub struct StickerService; + +impl StickerService { + /// 获取贴纸包列表 + /// + /// 返回所有可用的贴纸包,按分类和名称排序。 + pub async fn list_sticker_packs( + tenant_id: Uuid, + category: Option, + db: &DatabaseConnection, + ) -> DiaryResult> { + let mut query = sticker_pack::Entity::find() + .filter(sticker_pack::Column::TenantId.eq(tenant_id)) + .filter(sticker_pack::Column::DeletedAt.is_null()); + + if let Some(ref cat) = category { + query = query.filter(sticker_pack::Column::Category.eq(cat)); + } + + let packs = query + .order_by_asc(sticker_pack::Column::Category) + .order_by_asc(sticker_pack::Column::Name) + .all(db) + .await?; + + let mut result = Vec::with_capacity(packs.len()); + for pack in packs { + let sticker_count = sticker::Entity::find() + .filter(sticker::Column::PackId.eq(pack.id)) + .filter(sticker::Column::DeletedAt.is_null()) + .count(db) + .await? as i32; + + result.push(StickerPackResp { + id: pack.id, + name: pack.name, + description: pack.description, + cover_image_url: pack.thumbnail_url, + sticker_count, + is_free: pack.is_free, + category: pack.category, + }); + } + + Ok(result) + } + + /// 获取贴纸包内的贴纸列表 + pub async fn list_stickers_in_pack( + tenant_id: Uuid, + pack_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + // 验证贴纸包存在 + let _pack = sticker_pack::Entity::find() + .filter(sticker_pack::Column::Id.eq(pack_id)) + .filter(sticker_pack::Column::TenantId.eq(tenant_id)) + .filter(sticker_pack::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?; + + let stickers = sticker::Entity::find() + .filter(sticker::Column::PackId.eq(pack_id)) + .filter(sticker::Column::TenantId.eq(tenant_id)) + .filter(sticker::Column::DeletedAt.is_null()) + .all(db) + .await?; + + Ok(stickers + .into_iter() + .map(|s| StickerResp { + id: s.id, + pack_id: s.pack_id, + name: s.name, + image_url: s.image_url, + category: s.category, + }) + .collect()) + } + + /// 获取模板列表 + /// + /// 返回所有可用模板,包括官方模板和用户创建的模板。 + pub async fn list_templates( + tenant_id: Uuid, + category: Option, + db: &DatabaseConnection, + ) -> DiaryResult> { + let mut query = template::Entity::find() + .filter(template::Column::TenantId.eq(tenant_id)) + .filter(template::Column::DeletedAt.is_null()); + + if let Some(ref cat) = category { + query = query.filter(template::Column::Category.eq(cat)); + } + + let templates = query + .order_by_desc(template::Column::IsOfficial) + .order_by_asc(template::Column::Name) + .all(db) + .await?; + + Ok(templates + .into_iter() + .map(|t| TemplateResp { + id: t.id, + name: t.name, + description: None, // template entity 无 description 字段 + preview_url: t.thumbnail_url, + template_data: t.layout_data, + category: t.category, + is_free: true, // Phase 1 所有模板免费 + }) + .collect()) + } + + /// 获取模板详情 + pub async fn get_template( + tenant_id: Uuid, + template_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult { + let model = template::Entity::find() + .filter(template::Column::Id.eq(template_id)) + .filter(template::Column::TenantId.eq(tenant_id)) + .filter(template::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("模板 {} 不存在", template_id)))?; + + Ok(TemplateResp { + id: model.id, + name: model.name, + description: None, + preview_url: model.thumbnail_url, + template_data: model.layout_data, + category: model.category, + is_free: true, + }) + } +} diff --git a/crates/erp-diary/src/service/topic_service.rs b/crates/erp-diary/src/service/topic_service.rs index c5138de..2acaf41 100644 --- a/crates/erp-diary/src/service/topic_service.rs +++ b/crates/erp-diary/src/service/topic_service.rs @@ -10,6 +10,7 @@ use uuid::Uuid; use crate::dto::{CreateTopicReq, TopicResp}; use crate::entity::topic_assignment; use crate::error::{DiaryError, DiaryResult}; +use crate::service::notification_service::NotificationService; use erp_core::events::{DomainEvent, EventBus}; /// 主题布置服务 — 老师发布日记主题,学生提交对应日记 @@ -79,6 +80,17 @@ impl TopicService { ) .await; + // 发送 SSE 通知给班级学生 + NotificationService::notify_topic_assigned( + tenant_id, + class_id, + id, + req.title.clone(), + db, + event_bus, + ) + .await; + Ok(topic_model_to_resp(inserted)) } diff --git a/crates/erp-message/src/handler/sse_handler.rs b/crates/erp-message/src/handler/sse_handler.rs index 6d0604d..407afba 100644 --- a/crates/erp-message/src/handler/sse_handler.rs +++ b/crates/erp-message/src/handler/sse_handler.rs @@ -112,6 +112,41 @@ pub async fn message_stream( .id(event.id.to_string()) .data(data)); } + // 暖记通知事件 — 推送给目标用户 + "diary.notification.comment" + | "diary.notification.achievement" + | "diary.notification.class_update" => { + let is_recipient = event.payload.get("recipient_id") + .and_then(|v| v.as_str()) + .map(|s| s == user_id.to_string()) + .unwrap_or(false); + if !is_recipient { + continue; + } + let sse_event_name = match event.event_type.as_str() { + "diary.notification.comment" => "comment", + "diary.notification.achievement" => "achievement", + "diary.notification.class_update" => "class_update", + _ => "diary", + }; + let data = serde_json::to_string(&event.payload) + .unwrap_or_default(); + yield Ok(Event::default() + .event(sse_event_name) + .id(event.id.to_string()) + .data(data)); + } + // 暖记主题布置 — 班级广播 + "diary.notification.topic" => { + // 主题布置是班级广播,所有在线用户都会收到 + // 前端根据 class_id 过滤 + let data = serde_json::to_string(&event.payload) + .unwrap_or_default(); + yield Ok(Event::default() + .event("topic") + .id(event.id.to_string()) + .data(data)); + } "alert.triggered" => { let patient_id = event.payload.get("patient_id") .and_then(|v| v.as_str());