diff --git a/Cargo.lock b/Cargo.lock index b6e244a..388c07a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1457,6 +1457,7 @@ dependencies = [ "chrono", "erp-auth", "erp-core", + "redis", "sea-orm", "serde", "serde_json", diff --git a/app/lib/app.dart b/app/lib/app.dart index f5a26a1..2f9bd0d 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -16,6 +16,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart' show ListenableProvider; +import 'config/app_config.dart'; import 'core/theme/app_theme.dart'; import 'core/routing/app_router.dart'; import 'data/remote/api_client.dart'; @@ -35,7 +36,8 @@ class NuanjiApp extends StatelessWidget { @override Widget build(BuildContext context) { // 创建全局依赖(App 生命周期内单例) - final apiClient = ApiClient(); + final config = AppConfig.fromEnvironment(); + final apiClient = ApiClient(baseUrl: config.apiBaseUrl); final authRepository = AuthRepository(apiClient: apiClient); // 离线优先:Isar 为主要本地仓库,Remote 供 SyncEngine 推送 // Web 平台:Isar 3.x 不支持 Web,直接使用远程仓库 diff --git a/app/lib/config/app_config.dart b/app/lib/config/app_config.dart new file mode 100644 index 0000000..ba5f2df --- /dev/null +++ b/app/lib/config/app_config.dart @@ -0,0 +1,48 @@ +// 应用环境配置 — 通过 --dart-define 注入 +// +// 使用方式: +// flutter run --dart-define=API_BASE_URL=http://localhost:3000/api/v1 +// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1 + +/// 应用环境配置 — 集中管理所有外部服务地址 +class AppConfig { + /// API 基础 URL(后端 Axum 服务地址) + final String apiBaseUrl; + + /// SSE 推送服务 URL(通常与 API 同一地址) + final String sseBaseUrl; + + const AppConfig({ + required this.apiBaseUrl, + required this.sseBaseUrl, + }); + + /// 从编译时环境变量构建配置 + /// + /// 使用 `--dart-define` 注入,未设置时使用默认值。 + factory AppConfig.fromEnvironment({ + String defaultApiBaseUrl = 'http://localhost:3000/api/v1', + String defaultSseBaseUrl = 'http://localhost:3000/api/v1', + }) { + // const String.fromEnvironment 在编译时求值 + const apiBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://localhost:3000/api/v1', + ); + const sseBaseUrl = String.fromEnvironment( + 'SSE_BASE_URL', + defaultValue: 'http://localhost:3000/api/v1', + ); + + return AppConfig( + apiBaseUrl: apiBaseUrl, + sseBaseUrl: sseBaseUrl, + ); + } + + /// 开发环境默认配置 + static const dev = AppConfig( + apiBaseUrl: 'http://localhost:3000/api/v1', + sseBaseUrl: 'http://localhost:3000/api/v1', + ); +} diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index e189211..416cda4 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -12,6 +12,7 @@ export '../../widgets/responsive_scaffold.dart'; import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../widgets/responsive_scaffold.dart'; @@ -32,11 +33,15 @@ import '../../features/onboarding/views/onboarding_page.dart'; import '../../features/class_/views/class_page.dart'; import '../../features/teacher/views/teacher_page.dart'; import '../../features/parent/views/parent_page.dart'; +import '../../features/parent/bloc/parent_bloc.dart'; import '../../features/achievement/views/achievement_page.dart'; import '../../features/stickers/views/sticker_library_page.dart'; import '../../features/templates/views/template_gallery_page.dart'; import '../../features/settings/views/settings_page.dart'; import '../../features/auth/bloc/auth_bloc.dart'; +import '../../features/search/bloc/search_bloc.dart'; +import '../../data/repositories/journal_repository.dart'; +import '../../data/remote/api_client.dart'; // Shell 分支键 final _rootNavigatorKey = GlobalKey(); @@ -146,11 +151,17 @@ GoRouter createAppRouter(AuthBloc authBloc) { name: 'calendar', builder: (context, state) => const CalendarPage(), ), - // 发现页(复用搜索页,后续可替换为独立 DiscoverPage) + // 发现页(搜索页 — 标签+心情筛选日记) GoRoute( path: '/discover', name: 'discover', - builder: (context, state) => const SearchPage(), + builder: (context, state) { + final journalRepo = context.read(); + return BlocProvider( + create: (_) => SearchBloc(journalRepository: journalRepo), + child: const SearchPage(), + ); + }, ), GoRoute( path: '/profile', @@ -178,6 +189,20 @@ GoRouter createAppRouter(AuthBloc authBloc) { parentNavigatorKey: _rootNavigatorKey, builder: (context, state) => const MoodPage(), ), + // 周概览(全屏,从日历页进入) + GoRoute( + path: '/weekly', + name: 'weekly', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const WeeklyPage(), + ), + // 月度概览(全屏,从日历页进入) + GoRoute( + path: '/monthly', + name: 'monthly', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const MonthlyPage(), + ), GoRoute( path: '/class', name: 'class', @@ -194,7 +219,12 @@ GoRouter createAppRouter(AuthBloc authBloc) { path: '/parent', name: 'parent', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const ParentPage(), + builder: (context, state) { + return BlocProvider( + create: (_) => ParentBloc(api: context.read()), + child: const ParentPage(), + ); + }, ), GoRoute( path: '/achievements', diff --git a/app/lib/data/repositories/isar_journal_repository.dart b/app/lib/data/repositories/isar_journal_repository.dart index 9511322..b785359 100644 --- a/app/lib/data/repositories/isar_journal_repository.dart +++ b/app/lib/data/repositories/isar_journal_repository.dart @@ -32,6 +32,8 @@ class IsarJournalRepository implements JournalRepository { DateTime? dateTo, int? page, int? pageSize, + String? mood, + String? tag, }) async { var query = _isar.journalEntryCollections .where() @@ -46,6 +48,16 @@ class IsarJournalRepository implements JournalRepository { query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch); } + // 心情过滤 + if (mood != null) { + query = query.and().moodEqualTo(mood); + } + + // 标签过滤:Isar tagsJson 字段存储 JSON 数组,用 contains 匹配 + if (tag != null) { + query = query.and().tagsJsonContains(tag); + } + // 按日期降序排列 var results = await query .sortByDateEpochDesc() diff --git a/app/lib/data/repositories/journal_repository.dart b/app/lib/data/repositories/journal_repository.dart index 68e07cc..30daeb8 100644 --- a/app/lib/data/repositories/journal_repository.dart +++ b/app/lib/data/repositories/journal_repository.dart @@ -15,12 +15,14 @@ import '../models/journal_element.dart'; /// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间) /// - [page]/[pageSize]: 分页参数,从 1 开始 abstract class JournalRepository { - /// 获取日记列表(支持日期范围过滤和分页) + /// 获取日记列表(支持日期范围、心情、标签过滤和分页) Future> getJournals({ DateTime? dateFrom, DateTime? dateTo, int? page, int? pageSize, + String? mood, + String? tag, }); /// 获取单篇日记(返回 null 表示不存在) @@ -62,6 +64,8 @@ class InMemoryJournalRepository implements JournalRepository { DateTime? dateTo, int? page, int? pageSize, + String? mood, + String? tag, }) async { var results = _journals.values.toList(); @@ -73,6 +77,16 @@ class InMemoryJournalRepository implements JournalRepository { results = results.where((j) => j.date.isBefore(dateTo)).toList(); } + // 心情过滤 + if (mood != null) { + results = results.where((j) => j.mood.value == mood).toList(); + } + + // 标签过滤(日记 tags 列表包含指定标签) + if (tag != null) { + results = results.where((j) => j.tags.contains(tag)).toList(); + } + // 按日期降序排列(最新在前) results.sort((a, b) => b.date.compareTo(a.date)); diff --git a/app/lib/data/repositories/remote_journal_repository.dart b/app/lib/data/repositories/remote_journal_repository.dart index c3a4e0f..7a9838b 100644 --- a/app/lib/data/repositories/remote_journal_repository.dart +++ b/app/lib/data/repositories/remote_journal_repository.dart @@ -19,6 +19,8 @@ class RemoteJournalRepository implements JournalRepository { DateTime? dateTo, int? page, int? pageSize, + String? mood, + String? tag, }) async { final queryParams = {}; // 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒) @@ -30,6 +32,8 @@ class RemoteJournalRepository implements JournalRepository { } if (page != null) queryParams['page'] = page; if (pageSize != null) queryParams['page_size'] = pageSize; + if (mood != null) queryParams['mood'] = mood; + if (tag != null) queryParams['tag'] = tag; final response = await _api.get('/diary/journals', queryParams: queryParams); final body = response.data as Map; diff --git a/app/lib/data/services/sse_notification_service.dart b/app/lib/data/services/sse_notification_service.dart index 0562002..d01bbc8 100644 --- a/app/lib/data/services/sse_notification_service.dart +++ b/app/lib/data/services/sse_notification_service.dart @@ -39,7 +39,7 @@ class SseNotificationService { SseNotificationService({ required String token, - String baseUrl = 'http://localhost:8080/api/v1', + String baseUrl = 'http://localhost:3000/api/v1', }) : _token = token, _baseUrl = baseUrl; diff --git a/app/lib/features/parent/bloc/parent_bloc.dart b/app/lib/features/parent/bloc/parent_bloc.dart new file mode 100644 index 0000000..40361b4 --- /dev/null +++ b/app/lib/features/parent/bloc/parent_bloc.dart @@ -0,0 +1,140 @@ +// 家长中心 BLoC — 管理家长-孩子绑定和数据操作 +// +// 状态机: ParentInitial → ParentLoading → ParentChildrenLoaded / ParentJournalsLoaded / ParentDataExported / ParentDataDeleted / ParentError +// API: /diary/parent/* 端点 + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../data/remote/api_client.dart'; + +part 'parent_event.dart'; +part 'parent_state.dart'; + +/// 家长中心 BLoC — 处理孩子绑定、日记查看、数据导出/删除 +class ParentBloc extends Bloc { + final ApiClient _api; + + ParentBloc({required ApiClient api}) + : _api = api, + super(const ParentInitial()) { + on(_onLoadChildren); + on(_onBindChild); + on(_onViewJournals); + on(_onExportData); + on(_onDeleteData); + on(_onUnbindChild); + } + + /// 加载已绑定的孩子列表 + Future _onLoadChildren( + ParentLoadChildren event, + Emitter emit, + ) async { + emit(const ParentLoading()); + try { + final response = await _api.get('/diary/parent/children'); + final body = response.data as Map; + final items = body['data'] as List? ?? []; + final children = items + .map((j) => ChildBinding.fromJson(j as Map)) + .toList(); + emit(ParentChildrenLoaded(children)); + } catch (e) { + emit(const ParentError('加载孩子列表失败')); + } + } + + /// 绑定孩子(输入孩子 ID) + Future _onBindChild( + ParentBindChild event, + Emitter emit, + ) async { + emit(const ParentLoading()); + try { + await _api.post('/diary/parent/bind', data: { + 'child_id': event.childId, + }); + // 绑定成功后重新加载列表 + add(const ParentLoadChildren()); + } catch (e) { + emit(const ParentError('绑定失败,请检查孩子 ID')); + } + } + + /// 查看孩子日记(只读) + Future _onViewJournals( + ParentViewJournals event, + Emitter emit, + ) async { + emit(const ParentLoading()); + try { + final response = await _api.get( + '/diary/parent/journals', + queryParams: { + 'child_id': event.childId, + 'page': 1, + 'page_size': 50, + }, + ); + final body = response.data as Map; + final items = body['data'] as List? ?? []; + emit(ParentJournalsLoaded( + childId: event.childId, + journals: items.cast>(), + )); + } catch (e) { + emit(const ParentError('加载日记失败')); + } + } + + /// 导出孩子数据(PIPL 合规) + Future _onExportData( + ParentExportData event, + Emitter emit, + ) async { + emit(const ParentLoading()); + try { + final response = await _api.get( + '/diary/parent/export', + queryParams: {'child_id': event.childId}, + ); + emit(ParentDataExported( + childId: event.childId, + data: response.data as Map, + )); + } catch (e) { + emit(const ParentError('导出失败')); + } + } + + /// 删除孩子数据(PIPL 合规,需二次确认) + Future _onDeleteData( + ParentDeleteData event, + Emitter emit, + ) async { + emit(const ParentLoading()); + try { + await _api.delete('/diary/parent/data', data: { + 'child_id': event.childId, + }); + emit(ParentDataDeleted(event.childId)); + } catch (e) { + emit(const ParentError('删除失败')); + } + } + + /// 解绑孩子 + Future _onUnbindChild( + ParentUnbindChild event, + Emitter emit, + ) async { + try { + await _api.delete('/diary/parent/bind', data: { + 'child_id': event.childId, + }); + add(const ParentLoadChildren()); + } catch (e) { + emit(const ParentError('解绑失败')); + } + } +} diff --git a/app/lib/features/parent/bloc/parent_event.dart b/app/lib/features/parent/bloc/parent_event.dart new file mode 100644 index 0000000..905b75b --- /dev/null +++ b/app/lib/features/parent/bloc/parent_event.dart @@ -0,0 +1,43 @@ +// 家长中心事件 — ParentBloc 接收的用户操作 + +part of 'parent_bloc.dart'; + +/// 家长中心事件基类 +sealed class ParentEvent { + const ParentEvent(); +} + +/// 加载绑定的孩子列表 +final class ParentLoadChildren extends ParentEvent { + const ParentLoadChildren(); +} + +/// 绑定孩子(输入孩子 ID) +final class ParentBindChild extends ParentEvent { + final String childId; + const ParentBindChild(this.childId); +} + +/// 查看孩子日记 +final class ParentViewJournals extends ParentEvent { + final String childId; + const ParentViewJournals(this.childId); +} + +/// 导出孩子数据 +final class ParentExportData extends ParentEvent { + final String childId; + const ParentExportData(this.childId); +} + +/// 删除孩子数据 +final class ParentDeleteData extends ParentEvent { + final String childId; + const ParentDeleteData(this.childId); +} + +/// 解绑孩子 +final class ParentUnbindChild extends ParentEvent { + final String childId; + const ParentUnbindChild(this.childId); +} diff --git a/app/lib/features/parent/bloc/parent_state.dart b/app/lib/features/parent/bloc/parent_state.dart new file mode 100644 index 0000000..913c18e --- /dev/null +++ b/app/lib/features/parent/bloc/parent_state.dart @@ -0,0 +1,79 @@ +// 家长中心状态 — ParentBloc 输出的 UI 状态 + +part of 'parent_bloc.dart'; + +/// 家长中心状态基类 +sealed class ParentState { + const ParentState(); +} + +/// 初始状态 +final class ParentInitial extends ParentState { + const ParentInitial(); +} + +/// 加载中 +final class ParentLoading extends ParentState { + const ParentLoading(); +} + +/// 孩子列表已加载 +final class ParentChildrenLoaded extends ParentState { + final List children; + const ParentChildrenLoaded(this.children); +} + +/// 孩子日记已加载(只读) +final class ParentJournalsLoaded extends ParentState { + final String childId; + final List> journals; + const ParentJournalsLoaded({ + required this.childId, + required this.journals, + }); +} + +/// 数据已导出 +final class ParentDataExported extends ParentState { + final String childId; + final Map data; + const ParentDataExported({ + required this.childId, + required this.data, + }); +} + +/// 数据已删除 +final class ParentDataDeleted extends ParentState { + final String childId; + const ParentDataDeleted(this.childId); +} + +/// 出错 +final class ParentError extends ParentState { + final String message; + const ParentError(this.message); +} + +// ===== 模型 ===== + +/// 家长-孩子绑定关系 +class ChildBinding { + final String bindingId; + final String childId; + final DateTime? verifiedAt; + + const ChildBinding({ + required this.bindingId, + required this.childId, + this.verifiedAt, + }); + + factory ChildBinding.fromJson(Map json) => ChildBinding( + bindingId: json['binding_id'] as String, + childId: json['child_id'] as String, + verifiedAt: json['verified_at'] != null + ? DateTime.tryParse(json['verified_at'] as String) + : null, + ); +} diff --git a/app/lib/features/parent/views/parent_page.dart b/app/lib/features/parent/views/parent_page.dart index 968149e..02e32d5 100644 --- a/app/lib/features/parent/views/parent_page.dart +++ b/app/lib/features/parent/views/parent_page.dart @@ -1,12 +1,47 @@ -// 家长页面 — 只读查看 + 孩子数据管理 +// 家长中心页面 — 只读查看孩子日记 + 心情统计 + 数据导出/删除 +// +// 通过 ParentBloc 驱动家长中心状态: +// - 进入页面 → ParentLoadChildren 加载孩子列表 +// - 无孩子 → 显示绑定入口 +// - 有孩子 → 显示孩子卡片 + 4 个功能按钮 +// - 日记查看 → ParentViewJournals → 日记列表 +// - 数据导出 → ParentExportData → 展示导出结果 +// - 数据删除 → 确认对话框 → ParentDeleteData +// 保留 PIPL 合规提示。 import 'package:flutter/material.dart'; -import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; -/// 家长中心页面 — 家长查看孩子日记和统计 -class ParentPage extends StatelessWidget { +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_radius.dart'; +import '../bloc/parent_bloc.dart'; + +/// 家长中心页面 — 家长查看孩子日记和统计数据 +class ParentPage extends StatefulWidget { const ParentPage({super.key}); + @override + State createState() => _ParentPageState(); +} + +class _ParentPageState extends State { + final _childIdController = TextEditingController(); + + @override + void initState() { + super.initState(); + // 进入页面自动加载孩子列表 + context.read().add(const ParentLoadChildren()); + } + + @override + void dispose() { + _childIdController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -14,102 +49,342 @@ class ParentPage extends StatelessWidget { return Scaffold( appBar: AppBar(title: const Text('家长中心')), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + body: BlocConsumer( + listener: (context, state) { + if (state is ParentError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: AppColors.error, + ), + ); + } + if (state is ParentDataDeleted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('孩子数据已删除'), + backgroundColor: AppColors.success, + ), + ); + // 删除后重新加载列表 + context.read().add(const ParentLoadChildren()); + } + }, + builder: (context, state) { + if (state is ParentLoading) { + return const Center(child: CircularProgressIndicator()); + } + + // 日记列表视图 + if (state is ParentJournalsLoaded) { + return _JournalListView( + childId: state.childId, + journals: state.journals, + onBack: () => + context.read().add(const ParentLoadChildren()), + ); + } + + // 导出数据视图 + if (state is ParentDataExported) { + return _ExportDataView( + childId: state.childId, + data: state.data, + onBack: () => + context.read().add(const ParentLoadChildren()), + ); + } + + // 孩子列表或绑定入口 + final children = state is ParentChildrenLoaded ? state.children : []; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 页面标题区 + _HeaderCard(theme: theme, colorScheme: colorScheme), + const SizedBox(height: 20), + + // 孩子列表或绑定入口 + if (children.isEmpty) ...[ + _BindChildSection( + controller: _childIdController, + onBind: () { + final childId = _childIdController.text.trim(); + if (childId.isEmpty) return; + context.read().add(ParentBindChild(childId)); + _childIdController.clear(); + }, + ), + ] else ...[ + _ChildListSection( + children: children, + theme: theme, + colorScheme: colorScheme, + ), + const SizedBox(height: 20), + _ActionGrid( + children: children, + onViewJournals: (childId) => context + .read() + .add(ParentViewJournals(childId)), + onExport: (childId) => context + .read() + .add(ParentExportData(childId)), + onDelete: (childId) => _showDeleteConfirmDialog( + context, + childId, + ), + onMoodStats: (childId) { + // 复用已有心情统计页面 — 带孩子 ID 参数 + context.push('/mood?child_id=$childId'); + }, + ), + ], + + const SizedBox(height: 24), + + // PIPL 提示 + _PiplNotice(theme: theme, colorScheme: colorScheme), + ], + ), + ); + }, + ), + ); + } + + /// 删除确认对话框 + void _showDeleteConfirmDialog(BuildContext context, String childId) { + final theme = Theme.of(context); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: AppColors.warning, size: 28), + const SizedBox(width: 12), + const Text('确认删除'), + ], + ), + content: Text( + '此操作将永久删除该孩子的所有日记数据,包括文字、图片和贴纸。' + '\n\n根据《个人信息保护法》,此操作不可撤销。', + style: theme.textTheme.bodyMedium, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('取消'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: AppColors.error, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.smBorder, + ), + ), + onPressed: () { + Navigator.of(dialogContext).pop(); + context.read().add(ParentDeleteData(childId)); + }, + child: const Text('确认删除'), + ), + ], + ), + ); + } +} + +// ===== 子组件 ===== + +/// 标题卡片 — 孩子信息概览 +class _HeaderCard extends StatelessWidget { + const _HeaderCard({ + required this.theme, + required this.colorScheme, + }); + + final ThemeData theme; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), + color: colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: AppColors.rose.withValues(alpha: 0.2), + child: const Text('👶', style: TextStyle(fontSize: 24)), + ), + 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( + '查看和管理孩子的日记数据', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 绑定孩子入口 — 输入框 + 绑定按钮 +class _BindChildSection extends StatelessWidget { + const _BindChildSection({ + required this.controller, + required this.onBind, + }); + + final TextEditingController controller; + final VoidCallback onBind; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.lgBorder, + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 孩子信息卡片 - Card( - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), - color: colorScheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: AppColors.rose.withValues(alpha: 0.2), - child: const Text('👶', style: TextStyle(fontSize: 24)), - ), - 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('查看和管理孩子的日记数据', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.6), - )), - ], + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.link, + color: AppColors.accent, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '绑定孩子账号', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '输入孩子的账号 ID 建立绑定关系', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: '输入孩子 ID', + prefixIcon: const Icon(Icons.person_outline, size: 20), + border: OutlineInputBorder( + borderRadius: AppRadius.smBorder, + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: AppRadius.smBorder, + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: AppRadius.smBorder, + borderSide: BorderSide(color: AppColors.accent, width: 2), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, ), ), - ], + onSubmitted: (_) => onBind(), + ), ), - ), - ), - const SizedBox(height: 20), - - // 功能列表 - _ParentActionCard( - icon: Icons.auto_stories_outlined, - iconColor: AppColors.accent, - title: '日记查看', - subtitle: '只读查看孩子的日记和评语', - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('F9: 家长日记查看待实现')), - ), + const SizedBox(width: 12), + FilledButton( + onPressed: onBind, + style: FilledButton.styleFrom( + backgroundColor: AppColors.accent, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.smBorder, + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + ), + child: const Text('绑定'), + ), + ], ), const SizedBox(height: 12), - _ParentActionCard( - icon: Icons.bar_chart_outlined, - iconColor: AppColors.secondary, - title: '心情统计', - subtitle: '查看孩子的写作频率和心情趋势', - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('F9: 心情统计待实现')), - ), - ), - const SizedBox(height: 12), - _ParentActionCard( - icon: Icons.timer_outlined, - iconColor: AppColors.tertiary, - title: '使用时间', - subtitle: '设置孩子每天的使用时间限制', - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('F9: 使用时间限制待实现')), - ), - ), - const SizedBox(height: 12), - _ParentActionCard( - icon: Icons.download_outlined, - iconColor: colorScheme.primary, - title: '数据管理', - subtitle: '导出或删除孩子的日记数据', - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('F9: 数据管理待实现')), - ), - ), - const SizedBox(height: 24), - - // PIPL 提示 Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), + color: AppColors.tertiarySoftLight, + borderRadius: AppRadius.smBorder, ), child: Row( children: [ - Icon(Icons.shield_outlined, size: 18, color: colorScheme.onSurface.withValues(alpha: 0.5)), + Icon( + Icons.info_outline, + size: 16, + color: AppColors.tertiary, + ), const SizedBox(width: 8), Expanded( child: Text( - '根据《个人信息保护法》,您有权查阅、更正、删除和导出孩子的数据。', + '孩子的 ID 可在孩子的"我的"页面中查看', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.5), + color: AppColors.fg2Light, ), ), ), @@ -123,10 +398,220 @@ class ParentPage extends StatelessWidget { } } -class _ParentActionCard extends StatelessWidget { - const _ParentActionCard({ +/// 孩子列表 — 显示已绑定的孩子 +class _ChildListSection extends StatelessWidget { + const _ChildListSection({ + required this.children, + required this.theme, + required this.colorScheme, + }); + + final List children; + final ThemeData theme; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '已绑定的孩子 (${children.length})', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ...children.map((child) => _ChildCard( + binding: child, + theme: theme, + colorScheme: colorScheme, + onUnbind: () { + _showUnbindConfirmDialog(context, child.childId); + }, + )), + ], + ); + } + + void _showUnbindConfirmDialog(BuildContext context, String childId) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), + title: const Text('确认解绑'), + content: Text( + '解绑后将无法查看该孩子的日记数据。', + style: theme.textTheme.bodyMedium, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + context.read().add(ParentUnbindChild(childId)); + }, + child: const Text('确认解绑'), + ), + ], + ), + ); + } +} + +/// 单个孩子卡片 +class _ChildCard extends StatelessWidget { + const _ChildCard({ + required this.binding, + required this.theme, + required this.colorScheme, + required this.onUnbind, + }); + + final ChildBinding binding; + final ThemeData theme; + final ColorScheme colorScheme; + final VoidCallback onUnbind; + + @override + Widget build(BuildContext context) { + final verifiedStr = binding.verifiedAt != null + ? DateFormat('yyyy-MM-dd').format(binding.verifiedAt!) + : '未验证'; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.mdBorder, + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: AppColors.secondary.withValues(alpha: 0.15), + child: const Text('👧', style: TextStyle(fontSize: 20)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '孩子 ${binding.childId.substring(0, binding.childId.length > 8 ? 8 : binding.childId.length)}', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '绑定时间: $verifiedStr', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + IconButton( + icon: Icon( + Icons.link_off, + size: 20, + color: colorScheme.onSurface.withValues(alpha: 0.4), + ), + onPressed: onUnbind, + tooltip: '解绑', + ), + ], + ), + ), + ); + } +} + +/// 功能操作网格 — 4 个功能按钮 +class _ActionGrid extends StatelessWidget { + const _ActionGrid({ + required this.children, + required this.onViewJournals, + required this.onExport, + required this.onDelete, + required this.onMoodStats, + }); + + final List children; + final void Function(String childId) onViewJournals; + final void Function(String childId) onExport; + final void Function(String childId) onDelete; + final void Function(String childId) onMoodStats; + + /// 取第一个绑定的孩子 ID(Phase 1 简化逻辑) + String get _firstChildId => + children.isNotEmpty ? children.first.childId : ''; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '功能', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + _ActionCard( + icon: Icons.auto_stories_outlined, + iconColor: AppColors.accent, + iconBgColor: AppColors.accent.withValues(alpha: 0.12), + title: '日记查看', + subtitle: '只读查看孩子的日记和评语', + onTap: () => onViewJournals(_firstChildId), + ), + const SizedBox(height: 12), + _ActionCard( + icon: Icons.bar_chart_outlined, + iconColor: AppColors.secondary, + iconBgColor: AppColors.secondary.withValues(alpha: 0.12), + title: '心情统计', + subtitle: '查看孩子的写作频率和心情趋势', + onTap: () => onMoodStats(_firstChildId), + ), + const SizedBox(height: 12), + _ActionCard( + icon: Icons.download_outlined, + iconColor: AppColors.tertiary, + iconBgColor: AppColors.tertiary.withValues(alpha: 0.12), + title: '数据导出', + subtitle: '导出孩子的所有日记数据', + onTap: () => onExport(_firstChildId), + ), + const SizedBox(height: 12), + _ActionCard( + icon: Icons.delete_outline, + iconColor: AppColors.error, + iconBgColor: AppColors.error.withValues(alpha: 0.12), + title: '数据删除', + subtitle: '永久删除孩子的日记数据', + onTap: () => onDelete(_firstChildId), + ), + ], + ); + } +} + +/// 功能操作卡片 +class _ActionCard extends StatelessWidget { + const _ActionCard({ required this.icon, required this.iconColor, + required this.iconBgColor, required this.title, required this.subtitle, required this.onTap, @@ -134,6 +619,7 @@ class _ParentActionCard extends StatelessWidget { final IconData icon; final Color iconColor; + final Color iconBgColor; final String title; final String subtitle; final VoidCallback onTap; @@ -146,12 +632,12 @@ class _ParentActionCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, child: Padding( padding: const EdgeInsets.all(16), child: Row( @@ -160,7 +646,7 @@ class _ParentActionCard extends StatelessWidget { width: 44, height: 44, decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), + color: iconBgColor, borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: iconColor, size: 22), @@ -170,15 +656,26 @@ class _ParentActionCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), const SizedBox(height: 2), - Text(subtitle, style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.5), - )), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), ], ), ), - Icon(Icons.chevron_right, color: colorScheme.onSurface.withValues(alpha: 0.3)), + Icon( + Icons.chevron_right, + color: colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), @@ -186,3 +683,399 @@ class _ParentActionCard extends StatelessWidget { ); } } + +/// 日记列表视图 — 只读查看孩子日记 +class _JournalListView extends StatelessWidget { + const _JournalListView({ + required this.childId, + required this.journals, + required this.onBack, + }); + + final String childId; + final List> journals; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Column( + children: [ + // 返回按钮 + 标题 + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + Expanded( + child: Text( + '孩子的日记', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Icon( + Icons.visibility_outlined, + size: 20, + color: colorScheme.onSurface.withValues(alpha: 0.4), + ), + const SizedBox(width: 4), + Text( + '只读', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ], + ), + ), + ), + // 日记列表 + Expanded( + child: journals.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('📝', style: TextStyle(fontSize: 48)), + const SizedBox(height: 16), + Text( + '暂无日记', + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: journals.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final journal = journals[index]; + return _JournalCard( + journal: journal, + theme: theme, + colorScheme: colorScheme, + ); + }, + ), + ), + ], + ); + } +} + +/// 单条日记卡片(只读) +class _JournalCard extends StatelessWidget { + const _JournalCard({ + required this.journal, + required this.theme, + required this.colorScheme, + }); + + final Map journal; + final ThemeData theme; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final title = journal['title'] as String? ?? '无标题'; + final createdAt = journal['created_at'] as String? ?? ''; + final preview = journal['preview'] as String? ?? + journal['content'] as String? ?? + ''; + final moodEmoji = journal['mood_emoji'] as String? ?? ''; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.mdBorder, + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (moodEmoji.isNotEmpty) ...[ + Text(moodEmoji, style: const TextStyle(fontSize: 18)), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (createdAt.isNotEmpty) + Text( + _formatDate(createdAt), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.4), + fontSize: 12, + ), + ), + ], + ), + if (preview.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + preview, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + height: 1.5, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ); + } + + String _formatDate(String isoStr) { + try { + final dt = DateTime.parse(isoStr); + return DateFormat('MM-dd').format(dt); + } catch (_) { + return ''; + } + } +} + +/// 导出数据视图 — 展示导出结果 +class _ExportDataView extends StatelessWidget { + const _ExportDataView({ + required this.childId, + required this.data, + required this.onBack, + }); + + final String childId; + final Map data; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final journalCount = (data['journal_count'] as num?)?.toInt() ?? 0; + final exportDate = data['export_date'] as String? ?? + DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()); + + return Column( + children: [ + // 返回按钮 + 标题 + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + Expanded( + child: Text( + '数据导出', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + // 导出结果 + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // 成功图标 + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle_outline, + color: AppColors.success, + size: 40, + ), + ), + const SizedBox(height: 20), + Text( + '导出成功', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + // 数据概要卡片 + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.mdBorder, + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _ExportInfoRow( + label: '导出时间', + value: exportDate, + theme: theme, + colorScheme: colorScheme, + ), + const SizedBox(height: 12), + _ExportInfoRow( + label: '日记数量', + value: '$journalCount 篇', + theme: theme, + colorScheme: colorScheme, + ), + const SizedBox(height: 12), + _ExportInfoRow( + label: '数据格式', + value: 'JSON', + theme: theme, + colorScheme: colorScheme, + ), + ], + ), + ), + ), + const SizedBox(height: 20), + // 提示 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.tertiarySoftLight, + borderRadius: AppRadius.smBorder, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 18, + color: AppColors.tertiary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '数据已生成。Phase 1 展示 JSON 预览,后续版本将支持文件下载。', + style: theme.textTheme.bodySmall?.copyWith( + color: AppColors.fg2Light, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// 导出信息行 +class _ExportInfoRow extends StatelessWidget { + const _ExportInfoRow({ + required this.label, + required this.value, + required this.theme, + required this.colorScheme, + }); + + final String label; + final String value; + final ThemeData theme; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// PIPL 合规提示 +class _PiplNotice extends StatelessWidget { + const _PiplNotice({ + required this.theme, + required this.colorScheme, + }); + + final ThemeData theme; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.shield_outlined, + size: 18, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '根据《个人信息保护法》,您有权查阅、更正、删除和导出孩子的数据。', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/search/bloc/search_bloc.dart b/app/lib/features/search/bloc/search_bloc.dart new file mode 100644 index 0000000..b678380 --- /dev/null +++ b/app/lib/features/search/bloc/search_bloc.dart @@ -0,0 +1,79 @@ +// 搜索 BLoC — 标签+心情筛选日记 +// +// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError +// Phase 1 使用简单的标签+心情筛选,后续可扩展全文搜索。 + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../data/models/journal_entry.dart'; +import '../../../data/repositories/journal_repository.dart'; + +part 'search_event.dart'; +part 'search_state.dart'; + +/// 搜索 BLoC — 处理标签和心情筛选日记的状态转换 +class SearchBloc extends Bloc { + final JournalRepository _journalRepo; + + SearchBloc({required JournalRepository journalRepository}) + : _journalRepo = journalRepository, + super(const SearchInitial()) { + on(_onSearchByMood); + on(_onSearchByTag); + on(_onSearchClear); + } + + /// 按心情筛选日记 + Future _onSearchByMood( + SearchByMood event, + Emitter emit, + ) async { + emit(const SearchLoading()); + try { + if (event.mood == null) { + emit(const SearchLoaded()); + return; + } + final results = await _journalRepo.getJournals( + mood: event.mood!.value, + page: 1, + pageSize: 50, + ); + emit(SearchLoaded( + results: results, + activeMood: event.mood!.value, + )); + } catch (e) { + emit(const SearchError('搜索失败,请重试')); + } + } + + /// 按标签筛选日记 + Future _onSearchByTag( + SearchByTag event, + Emitter emit, + ) async { + emit(const SearchLoading()); + try { + final results = await _journalRepo.getJournals( + tag: event.tag, + page: 1, + pageSize: 50, + ); + emit(SearchLoaded( + results: results, + activeTag: event.tag, + )); + } catch (e) { + emit(const SearchError('搜索失败,请重试')); + } + } + + /// 清除搜索结果 + void _onSearchClear( + SearchClear event, + Emitter emit, + ) { + emit(const SearchLoaded()); + } +} diff --git a/app/lib/features/search/bloc/search_event.dart b/app/lib/features/search/bloc/search_event.dart new file mode 100644 index 0000000..2e5a7ba --- /dev/null +++ b/app/lib/features/search/bloc/search_event.dart @@ -0,0 +1,25 @@ +// 搜索事件 — SearchBloc 接收的用户操作 + +part of 'search_bloc.dart'; + +/// 搜索事件基类 +sealed class SearchEvent { + const SearchEvent(); +} + +/// 按心情筛选日记 +final class SearchByMood extends SearchEvent { + final Mood? mood; + const SearchByMood(this.mood); +} + +/// 按标签筛选日记 +final class SearchByTag extends SearchEvent { + final String tag; + const SearchByTag(this.tag); +} + +/// 清除搜索结果 +final class SearchClear extends SearchEvent { + const SearchClear(); +} diff --git a/app/lib/features/search/bloc/search_state.dart b/app/lib/features/search/bloc/search_state.dart new file mode 100644 index 0000000..014d833 --- /dev/null +++ b/app/lib/features/search/bloc/search_state.dart @@ -0,0 +1,56 @@ +// 搜索状态 — SearchBloc 输出的 UI 状态 + +part of 'search_bloc.dart'; + +/// 搜索状态基类 +sealed class SearchState { + const SearchState(); +} + +/// 初始状态 — 未执行任何搜索 +final class SearchInitial extends SearchState { + const SearchInitial(); +} + +/// 加载中 — 正在查询日记 +final class SearchLoading extends SearchState { + const SearchLoading(); +} + +/// 搜索结果已加载 +final class SearchLoaded extends SearchState { + /// 搜索结果列表(空列表表示无匹配) + final List results; + + /// 当前活跃的心情筛选条件 + final String? activeMood; + + /// 当前活跃的标签筛选条件 + final String? activeTag; + + const SearchLoaded({ + this.results = const [], + this.activeMood, + this.activeTag, + }); + + /// 是否有活跃的筛选条件 + bool get hasActiveFilter => activeMood != null || activeTag != null; + + SearchLoaded copyWith({ + List? results, + String? activeMood, + String? activeTag, + }) => + SearchLoaded( + results: results ?? this.results, + activeMood: activeMood ?? this.activeMood, + activeTag: activeTag ?? this.activeTag, + ); +} + +/// 搜索出错 +final class SearchError extends SearchState { + final String message; + const SearchError(this.message); +} diff --git a/app/lib/features/search/views/search_page.dart b/app/lib/features/search/views/search_page.dart index 4abdaed..09ca6e4 100644 --- a/app/lib/features/search/views/search_page.dart +++ b/app/lib/features/search/views/search_page.dart @@ -1,10 +1,20 @@ -// 搜索页面 — 日记搜索 + 标签筛选 +// 搜索页面 — 标签+心情筛选日记 +// +// 通过 SearchBloc 驱动搜索状态: +// - 标签点击 → SearchByTag event +// - 心情选择 → SearchByMood event +// - 清除按钮 → SearchClear event +// 搜索结果由 BlocBuilder 响应式渲染。 import 'package:flutter/material.dart'; -import 'package:nuanji_app/core/theme/app_colors.dart'; -import 'package:nuanji_app/data/models/journal_entry.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; -/// 搜索页面 — 全文搜索日记(Phase 1 占位 UI) +import '../../../core/theme/app_colors.dart'; +import '../../../data/models/journal_entry.dart'; +import '../bloc/search_bloc.dart'; + +/// 搜索页面 — 标签+心情筛选日记 class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -14,9 +24,8 @@ class SearchPage extends StatefulWidget { class _SearchPageState extends State { final _searchController = TextEditingController(); - bool _hasSearched = false; - // Phase 1 占位数据 + // Phase 1 占位标签数据 final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情']; final _moodFilters = Mood.values; @@ -31,48 +40,88 @@ class _SearchPageState extends State { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - return Scaffold( - appBar: AppBar( - title: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: '搜索日记...', - hintStyle: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.4), + return BlocBuilder( + builder: (context, state) { + final hasFilter = state is SearchLoaded && state.hasActiveFilter; + + return Scaffold( + appBar: AppBar( + title: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: '搜索日记...', + hintStyle: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.4), + ), + border: InputBorder.none, + prefixIcon: const Icon(Icons.search), + suffixIcon: hasFilter + ? IconButton( + icon: const Icon(Icons.filter_alt_off), + tooltip: '清除筛选', + onPressed: _clearSearch, + ) + : (_searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _clearSearch(); + }, + ) + : null), + ), + textInputAction: TextInputAction.search, + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + context.read().add(SearchByTag(value.trim())); + } + }, ), - border: InputBorder.none, - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - setState(() => _hasSearched = false); - }, - ) - : null, ), - textInputAction: TextInputAction.search, - onSubmitted: (_) => _doSearch(), - ), - ), - body: _hasSearched - ? _buildSearchResults(context, colorScheme) - : _buildSuggestions(context, theme, colorScheme), + body: _buildBody(context, theme, colorScheme, state), + ); + }, ); } - void _doSearch() { - setState(() => _hasSearched = true); + /// 根据搜索状态构建 body + Widget _buildBody( + BuildContext context, + ThemeData theme, + ColorScheme colorScheme, + SearchState state, + ) { + return switch (state) { + SearchInitial() => _buildSuggestions(context, theme, colorScheme), + SearchLoading() => const Center(child: CircularProgressIndicator()), + SearchLoaded(:final results, :final activeMood, :final activeTag) => + _hasActiveFilter(activeMood, activeTag) + ? _buildResults(context, theme, colorScheme, results) + : _buildSuggestions(context, theme, colorScheme), + SearchError(:final message) => _buildError(colorScheme, message), + }; } - Widget _buildSuggestions(BuildContext context, ThemeData theme, ColorScheme colorScheme) { + bool _hasActiveFilter(String? mood, String? tag) => + mood != null || tag != null; + + /// 建议区域 — 标签云 + 心情选择 + Widget _buildSuggestions( + BuildContext context, + ThemeData theme, + ColorScheme colorScheme, + ) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('常用标签', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + Text( + '常用标签', + style: + theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), const SizedBox(height: 8), Wrap( spacing: 8, @@ -82,23 +131,28 @@ class _SearchPageState extends State { label: Text(tag), onPressed: () { _searchController.text = tag; - _doSearch(); + context.read().add(SearchByTag(tag)); }, ); }).toList(), ), const SizedBox(height: 24), - Text('按心情筛选', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + Text( + '按心情筛选', + style: + theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 12, children: _moodFilters.map((mood) { - final color = AppColors.moodColors[mood.value] ?? colorScheme.primary; + final color = + AppColors.moodColors[mood.value] ?? colorScheme.primary; return GestureDetector( onTap: () { _searchController.text = _moodLabel(mood); - _doSearch(); + context.read().add(SearchByMood(mood)); }, child: Column( children: [ @@ -110,7 +164,8 @@ class _SearchPageState extends State { color: color.withValues(alpha: 0.15), ), alignment: Alignment.center, - child: Text(_moodEmoji(mood), style: const TextStyle(fontSize: 24)), + child: Text(_moodEmoji(mood), + style: const TextStyle(fontSize: 24)), ), const SizedBox(height: 4), Text(_moodLabel(mood), style: theme.textTheme.labelSmall), @@ -124,24 +179,81 @@ class _SearchPageState extends State { ); } - Widget _buildSearchResults(BuildContext context, ColorScheme colorScheme) { + /// 搜索结果列表 + Widget _buildResults( + BuildContext context, + ThemeData theme, + ColorScheme colorScheme, + List results, + ) { + if (results.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off_rounded, + size: 48, + color: colorScheme.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text( + '没有找到匹配的日记', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: _clearSearch, + child: const Text('清除筛选'), + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: results.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final entry = results[index]; + return _JournalCard(entry: entry); + }, + ); + } + + /// 错误提示 + Widget _buildError(ColorScheme colorScheme, String message) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.search_off_rounded, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)), + Icon(Icons.error_outline_rounded, + size: 48, + color: colorScheme.error.withValues(alpha: 0.6)), const SizedBox(height: 12), Text( - 'Phase 1: 搜索功能待 Isar FTS 集成', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.5), + message, + style: TextStyle( + color: colorScheme.error, + fontSize: 14, ), ), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: _clearSearch, + child: const Text('重试'), + ), ], ), ); } + void _clearSearch() { + _searchController.clear(); + context.read().add(const SearchClear()); + } + String _moodEmoji(Mood mood) => switch (mood) { Mood.happy => '😊', Mood.calm => '😌', @@ -158,3 +270,101 @@ class _SearchPageState extends State { Mood.thinking => '思考', }; } + +/// 日记卡片 — 在搜索结果中展示单条日记摘要 +class _JournalCard extends StatelessWidget { + final JournalEntry entry; + + const _JournalCard({required this.entry}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final moodColor = + AppColors.moodColors[entry.mood.value] ?? colorScheme.primary; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + // TODO: 导航到日记详情页 + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:心情 emoji + 标题 + 日期 + Row( + children: [ + Text( + _moodEmoji(entry.mood), + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + entry.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + DateFormat('MM/dd').format(entry.date), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + // 第二行:标签 + if (entry.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 4, + children: entry.tags.take(4).map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: moodColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + tag, + style: theme.textTheme.labelSmall?.copyWith( + color: moodColor, + fontSize: 11, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ), + ); + } + + String _moodEmoji(Mood mood) => switch (mood) { + Mood.happy => '😊', + Mood.calm => '😌', + Mood.sad => '😢', + Mood.angry => '😠', + Mood.thinking => '🤔', + }; +} diff --git a/app/test/features/calendar/bloc/calendar_bloc_test.dart b/app/test/features/calendar/bloc/calendar_bloc_test.dart index f82938c..7c41816 100644 --- a/app/test/features/calendar/bloc/calendar_bloc_test.dart +++ b/app/test/features/calendar/bloc/calendar_bloc_test.dart @@ -200,6 +200,8 @@ class _FailingJournalRepository implements JournalRepository { DateTime? dateTo, int? page, int? pageSize, + String? mood, + String? tag, }) async { throw Exception('模拟网络错误'); } diff --git a/app/test/features/home/bloc/home_bloc_test.dart b/app/test/features/home/bloc/home_bloc_test.dart index 02b928b..1e56b15 100644 --- a/app/test/features/home/bloc/home_bloc_test.dart +++ b/app/test/features/home/bloc/home_bloc_test.dart @@ -220,6 +220,8 @@ class _FailingJournalRepository implements JournalRepository { DateTime? dateTo, int? page, int? pageSize, + String? mood, + String? tag, }) async { throw Exception('网络不可用'); } diff --git a/crates/erp-diary/src/handler/mod.rs b/crates/erp-diary/src/handler/mod.rs index 9aa9a6f..362f180 100644 --- a/crates/erp-diary/src/handler/mod.rs +++ b/crates/erp-diary/src/handler/mod.rs @@ -8,3 +8,4 @@ pub mod comment_handler; pub mod sticker_handler; pub mod achievement_handler; pub mod stats_handler; +pub mod parent_handler; diff --git a/crates/erp-diary/src/handler/parent_handler.rs b/crates/erp-diary/src/handler/parent_handler.rs new file mode 100644 index 0000000..cb9ef39 --- /dev/null +++ b/crates/erp-diary/src/handler/parent_handler.rs @@ -0,0 +1,340 @@ +// 家长中心 API 处理器 — PIPL 合规: 绑定/查阅/导出/删除 + +use axum::extract::{Extension, FromRef, Query, State}; +use axum::response::Json; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::dto::JournalResp; +use crate::service::parent_service::ParentService; +use crate::state::DiaryState; + +// ---- 请求/响应 DTO ---- + +/// 绑定孩子请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct BindChildReq { + /// 孩子的用户 ID + pub child_id: Uuid, +} + +/// 查看孩子日记查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct ChildJournalsQuery { + /// 孩子的用户 ID + pub child_id: Uuid, + /// 页码(默认 1) + pub page: Option, + /// 每页条数(默认 20,最大 100) + pub page_size: Option, +} + +/// 导出数据查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct ExportQuery { + /// 孩子的用户 ID + pub child_id: Uuid, +} + +/// 删除孩子数据请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct DeleteChildDataReq { + /// 孩子的用户 ID + pub child_id: Uuid, +} + +/// 绑定信息响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct BindingResp { + pub binding_id: Uuid, + pub child_id: Uuid, + pub verified_at: Option>, +} + +/// 删除结果响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct DeleteResultResp { + pub deleted_count: usize, + pub message: String, +} + +// ---- Handler 函数 ---- + +#[utoipa::path( + post, + path = "/api/v1/diary/parent/bind", + request_body = BindChildReq, + responses( + (status = 200, description = "绑定成功", body = ApiResponse), + (status = 400, description = "已绑定该孩子"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "家长中心" +)] +/// POST /api/v1/diary/parent/bind +/// +/// 家长绑定孩子账号。需要 `diary.parent.bind` 权限。 +pub async fn bind_child( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.parent.bind")?; + + let binding = ParentService::bind_child( + ctx.tenant_id, + ctx.user_id, + req.child_id, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(BindingResp { + binding_id: binding.id, + child_id: binding.child_id, + verified_at: binding.verified_at, + }))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/parent/children", + responses( + (status = 200, description = "孩子列表", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "家长中心" +)] +/// GET /api/v1/diary/parent/children +/// +/// 获取家长绑定的孩子列表。需要 `diary.parent.bind` 权限。 +pub async fn list_children( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.parent.bind")?; + + let bindings = ParentService::list_children(ctx.tenant_id, ctx.user_id, &state.db).await?; + + let resp: Vec = bindings + .into_iter() + .map(|b| BindingResp { + binding_id: b.id, + child_id: b.child_id, + verified_at: b.verified_at, + }) + .collect(); + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/parent/journals", + params(ChildJournalsQuery), + responses( + (status = 200, description = "孩子日记列表", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足或未绑定该孩子"), + ), + security(("bearer_auth" = [])), + tag = "家长中心" +)] +/// GET /api/v1/diary/parent/journals +/// +/// 查看已绑定孩子的日记列表(只读)。需要 `diary.journal.read` 权限。 +pub async fn get_child_journals( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20).min(100); + + let (models, total) = ParentService::get_child_journals( + ctx.tenant_id, + ctx.user_id, + params.child_id, + page, + page_size, + &state.db, + ) + .await?; + + let items: Vec = models.into_iter().map(journal_model_to_resp).collect(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: items, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/parent/export", + params(ExportQuery), + responses( + (status = 200, description = "导出数据"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足或未绑定该孩子"), + ), + security(("bearer_auth" = [])), + tag = "家长中心" +)] +/// GET /api/v1/diary/parent/export +/// +/// 导出孩子所有日记数据(PIPL 数据可携带权)。需要 `diary.journal.read` 权限。 +pub async fn export_child_data( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let data = + ParentService::export_child_data(ctx.tenant_id, ctx.user_id, params.child_id, &state.db) + .await?; + + Ok(Json(data)) +} + +#[utoipa::path( + delete, + path = "/api/v1/diary/parent/data", + request_body = DeleteChildDataReq, + responses( + (status = 200, description = "删除成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足或未绑定该孩子"), + ), + security(("bearer_auth" = [])), + tag = "家长中心" +)] +/// DELETE /api/v1/diary/parent/data +/// +/// 软删除孩子所有日记数据(PIPL 删除权)。需要 `diary.parent.bind` 权限。 +/// 数据将在 30 天内完成清理。 +pub async fn delete_child_data( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.parent.bind")?; + + let count = ParentService::delete_child_data( + ctx.tenant_id, + ctx.user_id, + req.child_id, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(DeleteResultResp { + deleted_count: count, + message: "数据删除请求已提交,将在 30 天内完成删除".to_string(), + }))) +} + +#[utoipa::path( + delete, + path = "/api/v1/diary/parent/unbind", + request_body = BindChildReq, + responses( + (status = 200, description = "解绑成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "绑定关系不存在"), + ), + security(("bearer_auth" = [])), + tag = "家长中心" +)] +/// DELETE /api/v1/diary/parent/unbind +/// +/// 解绑孩子。需要 `diary.parent.bind` 权限。 +pub async fn unbind_child( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.parent.bind")?; + + ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("已解绑".to_string()), + })) +} + +/// journal_entry::Model -> JournalResp DTO 转换 +/// +/// 与 journal_service 中的 model_to_resp 逻辑一致, +/// 但这里直接从 Model 转换避免循环依赖。 +fn journal_model_to_resp(model: crate::entity::journal_entry::Model) -> JournalResp { + use crate::dto::{Mood, Weather}; + + let mood: Mood = serde_json::from_str(&model.mood).unwrap_or(Mood::Happy); + let weather: Weather = serde_json::from_str(&model.weather).unwrap_or(Weather::Sunny); + let tags: Vec = model + .tags + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + + JournalResp { + id: model.id, + author_id: model.author_id, + class_id: model.class_id, + title: model.title, + date: model.date, + mood, + weather, + tags, + is_private: model.is_private, + shared_to_class: model.shared_to_class, + version: model.version, + created_at: model.created_at, + updated_at: model.updated_at, + } +} diff --git a/crates/erp-diary/src/lib.rs b/crates/erp-diary/src/lib.rs index 232bf1a..22cec3d 100644 --- a/crates/erp-diary/src/lib.rs +++ b/crates/erp-diary/src/lib.rs @@ -12,7 +12,7 @@ use erp_core::module::ErpModule; use crate::handler::{ journal_handler, sync_handler, class_handler, topic_handler, comment_handler, - sticker_handler, achievement_handler, stats_handler, + sticker_handler, achievement_handler, stats_handler, parent_handler, }; /// 暖记日记业务模块 @@ -202,5 +202,30 @@ impl DiaryModule { "/diary/stats/mood", axum::routing::get(stats_handler::get_mood_stats), ) + // 家长中心 — PIPL 合规 + .route( + "/diary/parent/bind", + axum::routing::post(parent_handler::bind_child), + ) + .route( + "/diary/parent/children", + axum::routing::get(parent_handler::list_children), + ) + .route( + "/diary/parent/journals", + axum::routing::get(parent_handler::get_child_journals), + ) + .route( + "/diary/parent/export", + axum::routing::get(parent_handler::export_child_data), + ) + .route( + "/diary/parent/data", + axum::routing::delete(parent_handler::delete_child_data), + ) + .route( + "/diary/parent/unbind", + axum::routing::delete(parent_handler::unbind_child), + ) } } diff --git a/crates/erp-diary/src/service/mod.rs b/crates/erp-diary/src/service/mod.rs index ec5b0af..b506e1e 100644 --- a/crates/erp-diary/src/service/mod.rs +++ b/crates/erp-diary/src/service/mod.rs @@ -10,3 +10,4 @@ pub mod sticker_service; pub mod achievement_service; pub mod mood_stats_service; pub mod content_safety_service; +pub mod parent_service; diff --git a/crates/erp-diary/src/service/parent_service.rs b/crates/erp-diary/src/service/parent_service.rs new file mode 100644 index 0000000..60d98ed --- /dev/null +++ b/crates/erp-diary/src/service/parent_service.rs @@ -0,0 +1,295 @@ +// 家长-孩子绑定服务 — PIPL 合规: 数据查阅/导出/删除 + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, + QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::entity::journal_entry; +use crate::entity::parent_child_binding; +use crate::error::{DiaryError, DiaryResult}; +use erp_core::events::{DomainEvent, EventBus}; + +/// 家长中心服务 — 绑定管理、数据查阅、导出、删除 +pub struct ParentService; + +impl ParentService { + /// 绑定孩子 — 家长通过孩子用户 ID 建立绑定关系 + /// + /// 检查是否已存在有效绑定,避免重复绑定。 + /// 插入后发布 `diary.parent.child_bound` 事件。 + pub async fn bind_child( + tenant_id: Uuid, + parent_id: Uuid, + child_id: Uuid, + db: &DatabaseConnection, + event_bus: &EventBus, + ) -> DiaryResult { + // 检查是否已绑定 + let existing = parent_child_binding::Entity::find() + .filter(parent_child_binding::Column::ParentId.eq(parent_id)) + .filter(parent_child_binding::Column::ChildId.eq(child_id)) + .filter(parent_child_binding::Column::TenantId.eq(tenant_id)) + .filter(parent_child_binding::Column::Status.ne("revoked")) + .one(db) + .await?; + + if existing.is_some() { + return Err(DiaryError::BadRequest("已绑定该孩子".to_string())); + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + + let model = parent_child_binding::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + parent_id: Set(parent_id), + child_id: Set(child_id), + verification_method: Set("manual".to_string()), + verified_at: Set(Some(now)), + status: Set("verified".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(parent_id), + updated_by: Set(parent_id), + deleted_at: Set(None), + version: Set(1), + }; + + let inserted = model.insert(db).await?; + + event_bus + .publish( + DomainEvent::new( + "diary.parent.child_bound", + tenant_id, + serde_json::json!({ + "parent_id": parent_id, + "child_id": child_id, + }), + ), + db, + ) + .await; + + Ok(inserted) + } + + /// 获取家长绑定的孩子列表 + /// + /// 只返回 status=verified 且未软删除的绑定。 + pub async fn list_children( + tenant_id: Uuid, + parent_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + let bindings = parent_child_binding::Entity::find() + .filter(parent_child_binding::Column::TenantId.eq(tenant_id)) + .filter(parent_child_binding::Column::ParentId.eq(parent_id)) + .filter(parent_child_binding::Column::Status.eq("verified")) + .filter(parent_child_binding::Column::DeletedAt.is_null()) + .all(db) + .await?; + + Ok(bindings) + } + + /// 查看孩子日记 — 只读,家长只能查看已绑定的孩子的日记 + /// + /// 先验证绑定关系,再分页查询孩子的日记列表。 + pub async fn get_child_journals( + tenant_id: Uuid, + parent_id: Uuid, + child_id: Uuid, + page: u64, + page_size: u64, + db: &DatabaseConnection, + ) -> DiaryResult<(Vec, u64)> { + // 验证绑定关系 + Self::verify_binding(tenant_id, parent_id, child_id, db).await?; + + let page_size = page_size.min(100).max(1); + let page = page.max(1); + + let paginator = journal_entry::Entity::find() + .filter(journal_entry::Column::TenantId.eq(tenant_id)) + .filter(journal_entry::Column::AuthorId.eq(child_id)) + .filter(journal_entry::Column::DeletedAt.is_null()) + .order_by_desc(journal_entry::Column::Date) + .order_by_desc(journal_entry::Column::CreatedAt) + .paginate(db, page_size); + + let total = paginator.num_items().await?; + let journals = paginator.fetch_page(page.saturating_sub(1)).await?; + + Ok((journals, total)) + } + + /// 导出孩子数据 — 返回所有日记的 JSON(PIPL 数据可携带权) + /// + /// 先验证绑定关系,再查询孩子全部未删除的日记。 + pub async fn export_child_data( + tenant_id: Uuid, + parent_id: Uuid, + child_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult { + Self::verify_binding(tenant_id, parent_id, child_id, db).await?; + + let journals = journal_entry::Entity::find() + .filter(journal_entry::Column::TenantId.eq(tenant_id)) + .filter(journal_entry::Column::AuthorId.eq(child_id)) + .filter(journal_entry::Column::DeletedAt.is_null()) + .all(db) + .await?; + + Ok(serde_json::json!({ + "child_id": child_id, + "exported_at": Utc::now(), + "journals": journals, + })) + } + + /// 删除孩子数据 — 软删除所有日记(PIPL 删除权) + /// + /// 软删除孩子全部未删除的日记,逐条设置 deleted_at。 + /// 发布 `diary.parent.data_deleted` 事件记录操作。 + pub async fn delete_child_data( + tenant_id: Uuid, + parent_id: Uuid, + child_id: Uuid, + db: &DatabaseConnection, + event_bus: &EventBus, + ) -> DiaryResult { + Self::verify_binding(tenant_id, parent_id, child_id, db).await?; + + let journals = journal_entry::Entity::find() + .filter(journal_entry::Column::TenantId.eq(tenant_id)) + .filter(journal_entry::Column::AuthorId.eq(child_id)) + .filter(journal_entry::Column::DeletedAt.is_null()) + .all(db) + .await?; + + let count = journals.len(); + let now = Utc::now(); + + for journal in journals { + let current_version = journal.version; + let mut active: journal_entry::ActiveModel = journal.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(parent_id); + active.version = Set(current_version + 1); + active.update(db).await?; + } + + event_bus + .publish( + DomainEvent::new( + "diary.parent.data_deleted", + tenant_id, + serde_json::json!({ + "parent_id": parent_id, + "child_id": child_id, + "deleted_count": count, + }), + ), + db, + ) + .await; + + Ok(count) + } + + /// 解绑孩子 — 将绑定状态设为 revoked + pub async fn unbind_child( + tenant_id: Uuid, + parent_id: Uuid, + child_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult<()> { + let binding = parent_child_binding::Entity::find() + .filter(parent_child_binding::Column::TenantId.eq(tenant_id)) + .filter(parent_child_binding::Column::ParentId.eq(parent_id)) + .filter(parent_child_binding::Column::ChildId.eq(child_id)) + .filter(parent_child_binding::Column::Status.eq("verified")) + .filter(parent_child_binding::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound("绑定关系不存在".to_string()))?; + + let current_version = binding.version; + let now = Utc::now(); + let mut active: parent_child_binding::ActiveModel = binding.into(); + active.status = Set("revoked".to_string()); + active.updated_at = Set(now); + active.updated_by = Set(parent_id); + active.version = Set(current_version + 1); + active.update(db).await?; + + Ok(()) + } + + /// 验证家长-孩子绑定关系 + /// + /// 查询是否存在 status=verified 且未删除的绑定记录, + /// 不存在则返回 Forbidden 错误。 + async fn verify_binding( + tenant_id: Uuid, + parent_id: Uuid, + child_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult<()> { + let exists = parent_child_binding::Entity::find() + .filter(parent_child_binding::Column::TenantId.eq(tenant_id)) + .filter(parent_child_binding::Column::ParentId.eq(parent_id)) + .filter(parent_child_binding::Column::ChildId.eq(child_id)) + .filter(parent_child_binding::Column::Status.eq("verified")) + .filter(parent_child_binding::Column::DeletedAt.is_null()) + .one(db) + .await?; + + if exists.is_none() { + return Err(DiaryError::Forbidden); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 验证 verify_binding 在无匹配记录时的错误类型 + /// 由于没有数据库连接,这里只测试错误类型枚举匹配 + #[test] + fn forbidden_error_is_correct_variant() { + let err = DiaryError::Forbidden; + match err { + DiaryError::Forbidden => {} + other => panic!("Expected Forbidden, got {:?}", other), + } + } + + #[test] + fn bad_request_error_carries_message() { + let err = DiaryError::BadRequest("已绑定该孩子".to_string()); + match err { + DiaryError::BadRequest(msg) => assert_eq!(msg, "已绑定该孩子"), + other => panic!("Expected BadRequest, got {:?}", other), + } + } + + #[test] + fn not_found_error_carries_message() { + let err = DiaryError::NotFound("绑定关系不存在".to_string()); + match err { + DiaryError::NotFound(msg) => assert_eq!(msg, "绑定关系不存在"), + other => panic!("Expected NotFound, got {:?}", other), + } + } +} diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml index d8e5bf4..7b2589e 100644 --- a/crates/erp-server/Cargo.toml +++ b/crates/erp-server/Cargo.toml @@ -3,6 +3,10 @@ name = "erp-server" version.workspace = true edition.workspace = true +[features] +default = ["diary"] +diary = ["dep:erp-diary"] + [[bin]] name = "erp-server" path = "src/main.rs" @@ -28,7 +32,7 @@ erp-config.workspace = true erp-workflow.workspace = true erp-message.workspace = true erp-plugin.workspace = true -erp-diary.workspace = true +erp-diary = { workspace = true, optional = true } anyhow.workspace = true uuid.workspace = true chrono.workspace = true diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index f352114..2f20f2b 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -326,7 +326,9 @@ async fn main() -> anyhow::Result<()> { ); // Initialize diary module (暖记业务) + #[cfg(feature = "diary")] let diary_module = erp_diary::DiaryModule; + #[cfg(feature = "diary")] tracing::info!( module = diary_module.name(), version = diary_module.version(), @@ -338,8 +340,10 @@ async fn main() -> anyhow::Result<()> { .register(auth_module) .register(config_module) .register(workflow_module) - .register(message_module) - .register(diary_module); + .register(message_module); + + #[cfg(feature = "diary")] + let registry = registry.register(diary_module); tracing::info!( module_count = registry.modules().len(), "Modules registered" @@ -501,8 +505,12 @@ async fn main() -> anyhow::Result<()> { .merge(erp_config::ConfigModule::protected_routes()) .merge(erp_workflow::WorkflowModule::protected_routes()) .merge(erp_message::MessageModule::protected_routes()) - .merge(erp_plugin::module::PluginModule::protected_routes()) - .merge(erp_diary::DiaryModule::protected_routes()) + .merge(erp_plugin::module::PluginModule::protected_routes()); + + #[cfg(feature = "diary")] + let protected_routes = protected_routes.merge(erp_diary::DiaryModule::protected_routes()); + + let protected_routes = protected_routes .merge(handlers::audit_log::audit_log_router()) .route( "/upload", diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index 690d330..4dc09fa 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -110,6 +110,7 @@ impl FromRef for erp_plugin::state::PluginState { } /// Allow erp-diary handlers to extract their required state. +#[cfg(feature = "diary")] impl FromRef for erp_diary::DiaryState { fn from_ref(state: &AppState) -> Self { Self { diff --git a/docker/verify.sh b/docker/verify.sh new file mode 100755 index 0000000..cb73843 --- /dev/null +++ b/docker/verify.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# verify.sh — 暖记部署健康检查脚本 +# +# 用法: ./verify.sh [--host HOST] [--timeout SECONDS] +# 默认: host=localhost, timeout=60 + +set -euo pipefail + +# 配置 +HOST="${VERIFY_HOST:-localhost}" +TIMEOUT="${VERIFY_TIMEOUT:-60}" +PASS=0 +FAIL=0 + +# 颜色输出 +green() { echo -e "\033[32m✓ $1\033[0m"; } +red() { echo -e "\033[31m✗ $1\033[0m"; } +info() { echo -e "\033[36m→ $1\033[0m"; } + +# 解析参数 +while [[ $# -gt 0 ]]; do + case $1 in + --host) HOST="$2"; shift 2 ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + *) echo "未知参数: $1"; exit 1 ;; + esac +done + +info "暖记部署健康检查 (host=$HOST, timeout=${TIMEOUT}s)" +echo "---" + +# 等待服务启动 +info "等待服务启动..." +elapsed=0 +until [ $elapsed -ge $TIMEOUT ]; do + if curl -sf "http://$HOST:3000/api/v1/health" > /dev/null 2>&1; then + break + fi + sleep 2 + elapsed=$((elapsed + 2)) +done + +if [ $elapsed -ge $TIMEOUT ]; then + red "服务在 ${TIMEOUT}s 内未启动" + exit 1 +fi + +# 1. 检查 Axum 后端健康 +echo "" +info "检查后端服务..." +health_response=$(curl -sf "http://$HOST:3000/api/v1/health" 2>/dev/null || echo "FAILED") +if [[ "$health_response" != "FAILED" ]]; then + green "后端 Axum (port 3000): 正常" + PASS=$((PASS + 1)) +else + red "后端 Axum (port 3000): 不可达" + FAIL=$((FAIL + 1)) +fi + +# 2. 检查 PostgreSQL +info "检查 PostgreSQL..." +if command -v psql &> /dev/null; then + if PGPASSWORD=123123 psql -h "$HOST" -p 5432 -U postgres -d nuanji -c "SELECT 1" > /dev/null 2>&1; then + green "PostgreSQL (port 5432): 正常" + PASS=$((PASS + 1)) + else + red "PostgreSQL (port 5432): 连接失败" + FAIL=$((FAIL + 1)) + fi +else + # 使用 docker exec 检查 + if docker exec $(docker ps -q -f name=postgres) pg_isready -U postgres > /dev/null 2>&1; then + green "PostgreSQL: 正常 (docker exec)" + PASS=$((PASS + 1)) + else + red "PostgreSQL: 未就绪" + FAIL=$((FAIL + 1)) + fi +fi + +# 3. 检查 Redis +info "检查 Redis..." +if command -v redis-cli &> /dev/null; then + if redis-cli -h "$HOST" -p 6379 ping > /dev/null 2>&1; then + green "Redis (port 6379): 正常" + PASS=$((PASS + 1)) + else + red "Redis (port 6379): 连接失败" + FAIL=$((FAIL + 1)) + fi +else + if docker exec $(docker ps -q -f name=redis) redis-cli ping > /dev/null 2>&1; then + green "Redis: 正常 (docker exec)" + PASS=$((PASS + 1)) + else + red "Redis: 未就绪" + FAIL=$((FAIL + 1)) + fi +fi + +# 4. 检查 API 文档 +info "检查 OpenAPI 文档..." +swagger_status=$(curl -sf -o /dev/null -w "%{http_code}" "http://$HOST:3000/api-docs/openapi.json" 2>/dev/null || echo "000") +if [[ "$swagger_status" == "200" ]]; then + green "OpenAPI 文档: 可访问" + PASS=$((PASS + 1)) +else + red "OpenAPI 文档: 不可达 (status=$swagger_status)" + FAIL=$((FAIL + 1)) +fi + +# 汇总 +echo "" +echo "========================================" +if [ $FAIL -eq 0 ]; then + green "全部检查通过 ($PASS/$PASS)" + echo "========================================" + exit 0 +else + red "部分检查失败 (通过: $PASS, 失败: $FAIL)" + echo "========================================" + exit 1 +fi