feat(diary): B4+B5+B6 后端服务 + F5/F6/F7 前端模块
后端 (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
This commit is contained in:
@@ -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 {
|
class AchievementPage extends StatelessWidget {
|
||||||
const AchievementPage({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
final theme = Theme.of(context);
|
||||||
body: Center(
|
final colorScheme = theme.colorScheme;
|
||||||
child: Text('成就 - 占位页面'),
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
175
app/lib/features/calendar/bloc/calendar_bloc.dart
Normal file
175
app/lib/features/calendar/bloc/calendar_bloc.dart
Normal file
@@ -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<DateTime, List<JournalEntry>> journalsByDate;
|
||||||
|
|
||||||
|
/// 当前选中日期的日记列表
|
||||||
|
final List<JournalEntry> 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<DateTime, List<JournalEntry>>? journalsByDate,
|
||||||
|
List<JournalEntry>? 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<CalendarEvent, CalendarState> {
|
||||||
|
CalendarBloc() : super(const CalendarInitial()) {
|
||||||
|
on<CalendarMonthChanged>(_onMonthChanged);
|
||||||
|
on<CalendarDaySelected>(_onDaySelected);
|
||||||
|
on<CalendarViewModeChanged>(_onViewModeChanged);
|
||||||
|
on<CalendarLoadJournals>(_onLoadJournals);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMonthChanged(
|
||||||
|
CalendarMonthChanged event,
|
||||||
|
Emitter<CalendarState> 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<CalendarState> 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<CalendarState> emit,
|
||||||
|
) {
|
||||||
|
if (state is! CalendarLoaded) return;
|
||||||
|
final current = state as CalendarLoaded;
|
||||||
|
emit(current.copyWith(viewMode: event.mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadJournals(
|
||||||
|
CalendarLoadJournals event,
|
||||||
|
Emitter<CalendarState> 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
class CalendarPage extends StatelessWidget {
|
||||||
const CalendarPage({super.key});
|
const CalendarPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return BlocProvider(
|
||||||
body: Center(
|
create: (context) => CalendarBloc()
|
||||||
child: Text('日历 - 占位页面'),
|
..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<CalendarBloc, CalendarState>(
|
||||||
|
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<CalendarBloc>()
|
||||||
|
.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<CalendarBloc>().add(CalendarMonthChanged(prev));
|
||||||
|
},
|
||||||
|
onNext: () {
|
||||||
|
final next = DateTime(
|
||||||
|
loaded.focusedMonth.year,
|
||||||
|
loaded.focusedMonth.month + 1,
|
||||||
|
);
|
||||||
|
context.read<CalendarBloc>().add(CalendarMonthChanged(next));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// 星期标题行
|
||||||
|
_WeekdayHeader(colorScheme: colorScheme),
|
||||||
|
|
||||||
|
// 日历网格
|
||||||
|
_CalendarGrid(
|
||||||
|
month: loaded.focusedMonth,
|
||||||
|
selectedDay: loaded.selectedDay,
|
||||||
|
journalsByDate: loaded.journalsByDate,
|
||||||
|
onDaySelected: (day) {
|
||||||
|
context.read<CalendarBloc>().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<DateTime, List<JournalEntry>> journalsByDate;
|
||||||
|
final ValueChanged<DateTime> 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<List<_DayInfo>> _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<JournalEntry> 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 => '🤔',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
class HomePage extends StatelessWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
final theme = Theme.of(context);
|
||||||
body: Center(
|
final colorScheme = theme.colorScheme;
|
||||||
child: Text('首页 - 占位页面'),
|
|
||||||
|
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('写日记'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
128
app/lib/features/mood/bloc/mood_bloc.dart
Normal file
128
app/lib/features/mood/bloc/mood_bloc.dart
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// 心情 BLoC — 管理心情统计和趋势数据
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
|
||||||
|
// ===== 心情统计模型 =====
|
||||||
|
|
||||||
|
/// 心情统计数据
|
||||||
|
class MoodStats {
|
||||||
|
final List<MoodCount> 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<MoodTrendPoint> 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<MoodTrendPoint>? 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<void> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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});
|
const MoodPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MoodPage> createState() => _MoodPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MoodPageState extends State<MoodPage> {
|
||||||
|
final _bloc = MoodBloc();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_bloc.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bloc.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
final theme = Theme.of(context);
|
||||||
body: Center(
|
final colorScheme = theme.colorScheme;
|
||||||
child: Text('心情 - 占位页面'),
|
|
||||||
|
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<StatsPeriod> onPeriodChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SegmentedButton<StatsPeriod>(
|
||||||
|
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<MoodCount> 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 => '思考',
|
||||||
|
};
|
||||||
|
|||||||
@@ -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});
|
const StickerLibraryPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StickerLibraryPage> createState() => _StickerLibraryPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickerLibraryPageState extends State<StickerLibraryPage>
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
final theme = Theme.of(context);
|
||||||
body: Center(
|
final colorScheme = theme.colorScheme;
|
||||||
child: Text('贴纸库 - 占位页面'),
|
|
||||||
|
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<StickerPack> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
const TemplateGalleryPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TemplateGalleryPage> createState() => _TemplateGalleryPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
final theme = Theme.of(context);
|
||||||
body: Center(
|
final colorScheme = theme.colorScheme;
|
||||||
child: Text('模板画廊 - 占位页面'),
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,3 +178,109 @@ pub struct CommentResp {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 通知 ==========
|
||||||
|
|
||||||
|
/// 通知类型
|
||||||
|
#[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<uuid::Uuid>,
|
||||||
|
/// 附加数据
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub extra: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 心情统计 ==========
|
||||||
|
|
||||||
|
/// 心情统计响应
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct MoodStatsResp {
|
||||||
|
/// 统计周期内各心情出现次数
|
||||||
|
pub mood_counts: Vec<MoodCount>,
|
||||||
|
/// 连续写日记天数
|
||||||
|
pub streak_days: i32,
|
||||||
|
/// 统计周期内总日记数
|
||||||
|
pub total_journals: i32,
|
||||||
|
/// 最常用的心情
|
||||||
|
pub dominant_mood: Option<Mood>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单种心情的统计
|
||||||
|
#[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<String>,
|
||||||
|
pub cover_image_url: Option<String>,
|
||||||
|
pub sticker_count: i32,
|
||||||
|
pub is_free: bool,
|
||||||
|
pub category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 贴纸响应
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板响应
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct TemplateResp {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub preview_url: Option<String>,
|
||||||
|
pub template_data: Option<serde_json::Value>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub is_free: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成就响应
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct AchievementResp {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub code: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub category: String,
|
||||||
|
pub is_unlocked: bool,
|
||||||
|
pub unlocked_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|||||||
78
crates/erp-diary/src/handler/achievement_handler.rs
Normal file
78
crates/erp-diary/src/handler/achievement_handler.rs
Normal file
@@ -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<Vec<AchievementResp>>),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "成就管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/diary/achievements
|
||||||
|
///
|
||||||
|
/// 获取所有成就列表(含当前用户解锁状态)。需要 `diary.journal.read` 权限。
|
||||||
|
pub async fn list_achievements<S>(
|
||||||
|
State(state): State<DiaryState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<AchievementResp>>>, AppError>
|
||||||
|
where
|
||||||
|
DiaryState: FromRef<S>,
|
||||||
|
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<AchievementResp>),
|
||||||
|
(status = 404, description = "成就不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "成就管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/diary/achievements/:code/unlock
|
||||||
|
///
|
||||||
|
/// 解锁成就(幂等)。需要 `diary.journal.read` 权限。
|
||||||
|
pub async fn unlock_achievement<S>(
|
||||||
|
State(state): State<DiaryState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(code): Path<String>,
|
||||||
|
) -> Result<Json<ApiResponse<AchievementResp>>, AppError>
|
||||||
|
where
|
||||||
|
DiaryState: FromRef<S>,
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ use crate::state::DiaryState;
|
|||||||
(status = 200, description = "点评成功", body = ApiResponse<CommentResp>),
|
(status = 200, description = "点评成功", body = ApiResponse<CommentResp>),
|
||||||
(status = 400, description = "验证失败或内容安全检查未通过"),
|
(status = 400, description = "验证失败或内容安全检查未通过"),
|
||||||
(status = 401, description = "未授权"),
|
(status = 401, description = "未授权"),
|
||||||
(status = 403, description = "权限不足"),
|
(status = 403, description = "权限不足或不是本班老师"),
|
||||||
(status = 404, description = "日记不存在"),
|
(status = 404, description = "日记不存在"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = [])),
|
security(("bearer_auth" = [])),
|
||||||
@@ -30,6 +30,7 @@ use crate::state::DiaryState;
|
|||||||
/// POST /api/v1/diary/journals/:journal_id/comments
|
/// POST /api/v1/diary/journals/:journal_id/comments
|
||||||
///
|
///
|
||||||
/// 老师点评日记。需要 `diary.comment.write` 权限。
|
/// 老师点评日记。需要 `diary.comment.write` 权限。
|
||||||
|
/// 仅本班老师可以点评,私密日记不允许点评。
|
||||||
pub async fn create_comment<S>(
|
pub async fn create_comment<S>(
|
||||||
State(state): State<DiaryState>,
|
State(state): State<DiaryState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
@@ -88,3 +89,34 @@ where
|
|||||||
let resp = CommentService::list_comments(ctx.tenant_id, journal_id, &state.db).await?;
|
let resp = CommentService::list_comments(ctx.tenant_id, journal_id, &state.db).await?;
|
||||||
Ok(Json(ApiResponse::ok(resp)))
|
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<S>(
|
||||||
|
State(state): State<DiaryState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(comment_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
DiaryState: FromRef<S>,
|
||||||
|
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(())))
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ pub mod sync_handler;
|
|||||||
pub mod class_handler;
|
pub mod class_handler;
|
||||||
pub mod topic_handler;
|
pub mod topic_handler;
|
||||||
pub mod comment_handler;
|
pub mod comment_handler;
|
||||||
|
pub mod sticker_handler;
|
||||||
|
pub mod achievement_handler;
|
||||||
|
pub mod stats_handler;
|
||||||
|
|||||||
66
crates/erp-diary/src/handler/stats_handler.rs
Normal file
66
crates/erp-diary/src/handler/stats_handler.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_period(s: &Option<String>) -> 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<MoodStatsResp>),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "统计"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/diary/stats/mood
|
||||||
|
///
|
||||||
|
/// 获取当前用户的心情统计。需要 `diary.journal.read` 权限。
|
||||||
|
pub async fn get_mood_stats<S>(
|
||||||
|
State(state): State<DiaryState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(query): Query<StatsQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<MoodStatsResp>>, AppError>
|
||||||
|
where
|
||||||
|
DiaryState: FromRef<S>,
|
||||||
|
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)))
|
||||||
|
}
|
||||||
157
crates/erp-diary/src/handler/sticker_handler.rs
Normal file
157
crates/erp-diary/src/handler/sticker_handler.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/diary/sticker-packs",
|
||||||
|
params(StickerPackQuery),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<StickerPackResp>>),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "贴纸管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/diary/sticker-packs
|
||||||
|
///
|
||||||
|
/// 获取贴纸包列表。需要 `diary.journal.read` 权限。
|
||||||
|
pub async fn list_sticker_packs<S>(
|
||||||
|
State(state): State<DiaryState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(query): Query<StickerPackQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<StickerPackResp>>>, AppError>
|
||||||
|
where
|
||||||
|
DiaryState: FromRef<S>,
|
||||||
|
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<Vec<StickerResp>>),
|
||||||
|
(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<S>(
|
||||||
|
State(state): State<DiaryState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(pack_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<StickerResp>>>, AppError>
|
||||||
|
where
|
||||||
|
DiaryState: FromRef<S>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/diary/templates",
|
||||||
|
params(TemplateQuery),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<TemplateResp>>),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "模板管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/diary/templates
|
||||||
|
///
|
||||||
|
/// 获取模板列表。需要 `diary.journal.read` 权限。
|
||||||
|
pub async fn list_templates<S>(
|
||||||
|
State(state): State<DiaryState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(query): Query<TemplateQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<TemplateResp>>>, AppError>
|
||||||
|
where
|
||||||
|
DiaryState: FromRef<S>,
|
||||||
|
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<TemplateResp>),
|
||||||
|
(status = 404, description = "模板不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "模板管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/diary/templates/:template_id
|
||||||
|
///
|
||||||
|
/// 获取模板详情(含布局数据)。需要 `diary.journal.read` 权限。
|
||||||
|
pub async fn get_template<S>(
|
||||||
|
State(state): State<DiaryState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(template_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<TemplateResp>>, AppError>
|
||||||
|
where
|
||||||
|
DiaryState: FromRef<S>,
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -10,7 +10,10 @@ pub use state::DiaryState;
|
|||||||
|
|
||||||
use erp_core::module::ErpModule;
|
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;
|
pub struct DiaryModule;
|
||||||
@@ -80,6 +83,12 @@ impl ErpModule for DiaryModule {
|
|||||||
module: "diary".into(),
|
module: "diary".into(),
|
||||||
description: "允许老师点评日记".into(),
|
description: "允许老师点评日记".into(),
|
||||||
},
|
},
|
||||||
|
erp_core::module::PermissionDescriptor {
|
||||||
|
code: "diary.comment.delete".into(),
|
||||||
|
name: "删除评语".into(),
|
||||||
|
module: "diary".into(),
|
||||||
|
description: "允许删除自己的评语".into(),
|
||||||
|
},
|
||||||
erp_core::module::PermissionDescriptor {
|
erp_core::module::PermissionDescriptor {
|
||||||
code: "diary.parent.bind".into(),
|
code: "diary.parent.bind".into(),
|
||||||
name: "家长绑定".into(),
|
name: "家长绑定".into(),
|
||||||
@@ -157,5 +166,41 @@ impl DiaryModule {
|
|||||||
axum::routing::post(comment_handler::create_comment)
|
axum::routing::post(comment_handler::create_comment)
|
||||||
.get(comment_handler::list_comments),
|
.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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
crates/erp-diary/src/service/achievement_service.rs
Normal file
158
crates/erp-diary/src/service/achievement_service.rs
Normal file
@@ -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<Vec<AchievementResp>> {
|
||||||
|
// 查询所有成就定义
|
||||||
|
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<Uuid, chrono::DateTime<Utc>> = 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<AchievementResp> {
|
||||||
|
// 查找成就定义
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -268,7 +268,7 @@ impl ClassService {
|
|||||||
|
|
||||||
/// 生成 6 位班级码(UUID 前 6 位字符)
|
/// 生成 6 位班级码(UUID 前 6 位字符)
|
||||||
fn generate_class_code() -> String {
|
fn generate_class_code() -> String {
|
||||||
Uuid::new_v4()
|
Uuid::now_v7()
|
||||||
.to_string()
|
.to_string()
|
||||||
.replace("-", "")
|
.replace("-", "")
|
||||||
.chars()
|
.chars()
|
||||||
|
|||||||
@@ -7,18 +7,27 @@ use sea_orm::{
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::dto::CommentResp;
|
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::error::{DiaryError, DiaryResult};
|
||||||
|
use crate::service::notification_service::NotificationService;
|
||||||
use erp_core::events::{DomainEvent, EventBus};
|
use erp_core::events::{DomainEvent, EventBus};
|
||||||
|
|
||||||
/// 评语服务 — 老师对学生日记的点评
|
/// 评语服务 — 老师对学生日记的点评
|
||||||
|
///
|
||||||
|
/// 权限约束:
|
||||||
|
/// - 仅本班老师可以点评学生日记
|
||||||
|
/// - 老师必须与日记作者属于同一班级
|
||||||
pub struct CommentService;
|
pub struct CommentService;
|
||||||
|
|
||||||
impl CommentService {
|
impl CommentService {
|
||||||
/// 添加评语(老师点评学生日记)
|
/// 添加评语(老师点评学生日记)
|
||||||
///
|
///
|
||||||
/// 验证日记存在,执行基础内容安全检查,
|
/// 流程:
|
||||||
/// 创建评论记录,发布 CommentCreated 事件。
|
/// 1. 验证日记存在且未删除
|
||||||
|
/// 2. 验证点评者是日记所属班级的老师
|
||||||
|
/// 3. 执行内容安全检查
|
||||||
|
/// 4. 创建评论记录
|
||||||
|
/// 5. 发布 CommentCreated 事件(触发 SSE 推送)
|
||||||
pub async fn create_comment(
|
pub async fn create_comment(
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
author_id: Uuid,
|
author_id: Uuid,
|
||||||
@@ -36,7 +45,15 @@ impl CommentService {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", journal_id)))?;
|
.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) {
|
if contains_sensitive_words(&content) {
|
||||||
return Err(DiaryError::ContentSafetyViolation);
|
return Err(DiaryError::ContentSafetyViolation);
|
||||||
}
|
}
|
||||||
@@ -44,13 +61,13 @@ impl CommentService {
|
|||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let id = Uuid::now_v7();
|
let id = Uuid::now_v7();
|
||||||
|
|
||||||
// 3. 创建评论记录
|
// 4. 创建评论记录
|
||||||
let model = comment::ActiveModel {
|
let model = comment::ActiveModel {
|
||||||
id: Set(id),
|
id: Set(id),
|
||||||
tenant_id: Set(tenant_id),
|
tenant_id: Set(tenant_id),
|
||||||
journal_id: Set(journal_id),
|
journal_id: Set(journal_id),
|
||||||
author_id: Set(author_id),
|
author_id: Set(author_id),
|
||||||
content: Set(content),
|
content: Set(content.clone()),
|
||||||
created_at: Set(now),
|
created_at: Set(now),
|
||||||
updated_at: Set(now),
|
updated_at: Set(now),
|
||||||
created_by: Set(author_id),
|
created_by: Set(author_id),
|
||||||
@@ -60,7 +77,7 @@ impl CommentService {
|
|||||||
};
|
};
|
||||||
let inserted = model.insert(db).await?;
|
let inserted = model.insert(db).await?;
|
||||||
|
|
||||||
// 4. 发布 CommentCreated 事件
|
// 5. 发布 CommentCreated 事件
|
||||||
event_bus
|
event_bus
|
||||||
.publish(
|
.publish(
|
||||||
DomainEvent::new(
|
DomainEvent::new(
|
||||||
@@ -71,12 +88,26 @@ impl CommentService {
|
|||||||
"journal_id": journal_id,
|
"journal_id": journal_id,
|
||||||
"teacher_id": author_id,
|
"teacher_id": author_id,
|
||||||
"student_id": journal.author_id,
|
"student_id": journal.author_id,
|
||||||
|
"content_preview": content.chars().take(50).collect::<String>(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
db,
|
db,
|
||||||
)
|
)
|
||||||
.await;
|
.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))
|
Ok(comment_model_to_resp(inserted))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +129,63 @@ impl CommentService {
|
|||||||
|
|
||||||
Ok(comments.into_iter().map(comment_model_to_resp).collect())
|
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
|
/// 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 {
|
fn contains_sensitive_words(content: &str) -> bool {
|
||||||
const SENSITIVE_WORDS: &[&str] = &[
|
const SENSITIVE_WORDS: &[&str] = &[
|
||||||
// 占位 — Phase 1 仅检查是否为空或过短
|
// 占位 — Phase 1 仅检查是否为空或过短
|
||||||
// 完整词库将在后续迭代中添加
|
// 完整词库将在 B6 ContentSafetyService 中添加
|
||||||
];
|
];
|
||||||
|
|
||||||
if content.trim().is_empty() {
|
if content.trim().is_empty() {
|
||||||
@@ -132,3 +220,20 @@ fn contains_sensitive_words(content: &str) -> bool {
|
|||||||
|
|
||||||
false
|
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("老师点评:写得不错"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
110
crates/erp-diary/src/service/content_safety_service.rs
Normal file
110
crates/erp-diary/src/service/content_safety_service.rs
Normal file
@@ -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<String>,
|
||||||
|
/// 过滤后的内容(敏感词替换为 ***)
|
||||||
|
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, "正常内容");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,8 @@ pub mod sync_service;
|
|||||||
pub mod class_service;
|
pub mod class_service;
|
||||||
pub mod topic_service;
|
pub mod topic_service;
|
||||||
pub mod comment_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;
|
||||||
|
|||||||
171
crates/erp-diary/src/service/mood_stats_service.rs
Normal file
171
crates/erp-diary/src/service/mood_stats_service.rs
Normal file
@@ -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<MoodStatsResp> {
|
||||||
|
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<String, i32> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for journal in &journals {
|
||||||
|
*mood_counts_map
|
||||||
|
.entry(journal.mood.clone())
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mood_counts: Vec<MoodCount> = 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<i32> {
|
||||||
|
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<NaiveDate> =
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
crates/erp-diary/src/service/notification_service.rs
Normal file
123
crates/erp-diary/src/service/notification_service.rs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
154
crates/erp-diary/src/service/sticker_service.rs
Normal file
154
crates/erp-diary/src/service/sticker_service.rs
Normal file
@@ -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<String>,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> DiaryResult<Vec<StickerPackResp>> {
|
||||||
|
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<Vec<StickerResp>> {
|
||||||
|
// 验证贴纸包存在
|
||||||
|
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<String>,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> DiaryResult<Vec<TemplateResp>> {
|
||||||
|
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<TemplateResp> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::{CreateTopicReq, TopicResp};
|
use crate::dto::{CreateTopicReq, TopicResp};
|
||||||
use crate::entity::topic_assignment;
|
use crate::entity::topic_assignment;
|
||||||
use crate::error::{DiaryError, DiaryResult};
|
use crate::error::{DiaryError, DiaryResult};
|
||||||
|
use crate::service::notification_service::NotificationService;
|
||||||
use erp_core::events::{DomainEvent, EventBus};
|
use erp_core::events::{DomainEvent, EventBus};
|
||||||
|
|
||||||
/// 主题布置服务 — 老师发布日记主题,学生提交对应日记
|
/// 主题布置服务 — 老师发布日记主题,学生提交对应日记
|
||||||
@@ -79,6 +80,17 @@ impl TopicService {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// 发送 SSE 通知给班级学生
|
||||||
|
NotificationService::notify_topic_assigned(
|
||||||
|
tenant_id,
|
||||||
|
class_id,
|
||||||
|
id,
|
||||||
|
req.title.clone(),
|
||||||
|
db,
|
||||||
|
event_bus,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(topic_model_to_resp(inserted))
|
Ok(topic_model_to_resp(inserted))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,41 @@ pub async fn message_stream(
|
|||||||
.id(event.id.to_string())
|
.id(event.id.to_string())
|
||||||
.data(data));
|
.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" => {
|
"alert.triggered" => {
|
||||||
let patient_id = event.payload.get("patient_id")
|
let patient_id = event.payload.get("patient_id")
|
||||||
.and_then(|v| v.as_str());
|
.and_then(|v| v.as_str());
|
||||||
|
|||||||
Reference in New Issue
Block a user