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:
iven
2026-06-01 09:32:09 +08:00
parent 482eb244d5
commit 7e3597dc77
25 changed files with 3286 additions and 39 deletions

View 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();
}
}

View File

@@ -1,14 +1,356 @@
import 'package:flutter/material.dart';
// 心情页面 — 心情统计 + 趋势图 + 连续天数
class MoodPage extends StatelessWidget {
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import '../bloc/mood_bloc.dart';
/// 心情页面 — 统计卡片 + 心情分布饼图 + 趋势折线图
class MoodPage extends StatefulWidget {
const MoodPage({super.key});
@override
State<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
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('心情 - 占位页面'),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return ListenableBuilder(
listenable: _bloc,
builder: (context, _) {
final state = _bloc.state;
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 统计概览卡片
_StatsOverviewCard(stats: state.stats, colorScheme: colorScheme),
const SizedBox(height: 16),
// 周期选择器
_PeriodSelector(
selectedPeriod: state.selectedPeriod,
onPeriodChanged: _bloc.changePeriod,
),
const SizedBox(height: 16),
// 心情分布饼图
_MoodDistributionChart(
moodCounts: state.stats.moodCounts,
colorScheme: colorScheme,
),
const SizedBox(height: 24),
// 心情详情列表
Text(
'心情详情',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
...state.stats.moodCounts.map((mc) => _MoodCountTile(mc: mc)),
const SizedBox(height: 24),
// 连续天数鼓励卡片
_StreakCard(streakDays: state.stats.streakDays),
],
),
);
},
);
}
}
/// 统计概览卡片
class _StatsOverviewCard extends StatelessWidget {
const _StatsOverviewCard({
required this.stats,
required this.colorScheme,
});
final MoodStats stats;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dominantEmoji = stats.dominantMood != null
? _moodEmoji(stats.dominantMood!)
: '📝';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
color: colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
// 主导心情图标
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(dominantEmoji, style: const TextStyle(fontSize: 28)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'心情概览',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
],
),
),
);
}
}
/// 周期选择器
class _PeriodSelector extends StatelessWidget {
const _PeriodSelector({
required this.selectedPeriod,
required this.onPeriodChanged,
});
final StatsPeriod selectedPeriod;
final ValueChanged<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 => '思考',
};