diff --git a/app/lib/data/repositories/remote_journal_repository.dart b/app/lib/data/repositories/remote_journal_repository.dart index 52588d5..fa9dc2e 100644 --- a/app/lib/data/repositories/remote_journal_repository.dart +++ b/app/lib/data/repositories/remote_journal_repository.dart @@ -1,5 +1,7 @@ // 远程日记仓库 — 通过 API 客户端连接后端 +import 'dart:async'; + import '../models/journal_element.dart'; import '../models/journal_entry.dart'; import '../remote/api_client.dart'; @@ -11,6 +13,10 @@ import 'journal_repository.dart'; class RemoteJournalRepository implements JournalRepository { final ApiClient _api; + /// 变更通知流控制器 — 写操作成功后通知 UI 刷新 + final StreamController _changeController = + StreamController.broadcast(); + RemoteJournalRepository({required ApiClient api}) : _api = api; @override @@ -78,7 +84,9 @@ class RemoteJournalRepository implements JournalRepository { json['date'] = entry.date.toIso8601String().substring(0, 10); final response = await _api.post('/diary/journals', data: json); final body = response.data as Map; - return JournalEntry.fromJson(body['data'] as Map); + final created = JournalEntry.fromJson(body['data'] as Map); + _changeController.add(null); // 通知 UI 刷新列表 + return created; } @override @@ -96,12 +104,14 @@ class RemoteJournalRepository implements JournalRepository { }, ); final body = response.data as Map; + _changeController.add(null); // 通知 UI 刷新列表 return JournalEntry.fromJson(body['data'] as Map); } @override Future deleteJournal(String id) async { await _api.delete('/diary/journals/$id'); + _changeController.add(null); // 通知 UI 刷新列表 } @override @@ -139,9 +149,9 @@ class RemoteJournalRepository implements JournalRepository { await _api.delete('/diary/elements/$elementId'); } - /// 远程仓库不提供本地变更通知,返回空流 + /// 变更通知流 — 写操作后广播,供 HomeBloc 自动刷新 @override - Stream get onJournalChanged => const Stream.empty(); + Stream get onJournalChanged => _changeController.stream; } /// API 异常封装 — 后端返回非 2xx 状态码时抛出 diff --git a/app/lib/data/services/sync_engine_native.dart b/app/lib/data/services/sync_engine_native.dart index 64757f5..b746417 100644 --- a/app/lib/data/services/sync_engine_native.dart +++ b/app/lib/data/services/sync_engine_native.dart @@ -143,7 +143,16 @@ class SyncEngine { /// update+update → update(使用最新数据) /// update+delete → delete(资源最终被删除) /// create+delete → 取消(资源从未存在) + /// + /// 私密日记(is_private=true)不入队 — 仅保存在本地,不上传后端。 void enqueue(PendingOperation operation) { + // 防御性检查:私密日记不入队 + final isPrivate = operation.data['is_private'] as bool? ?? false; + if (isPrivate) { + debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}'); + return; + } + // 查找队列中同一资源的最后一个操作 PendingOperation? existing; for (final op in _pendingQueue) { diff --git a/app/lib/data/services/sync_engine_web.dart b/app/lib/data/services/sync_engine_web.dart index 27a8037..659188f 100644 --- a/app/lib/data/services/sync_engine_web.dart +++ b/app/lib/data/services/sync_engine_web.dart @@ -90,7 +90,15 @@ class SyncEngine { int get pendingCount => _pendingQueue.length; bool get isSyncing => _status == SyncStatus.syncing; + /// 入队待同步操作 — 私密日记(is_private=true)不入队 void enqueue(PendingOperation operation) { + // 防御性检查:私密日记仅保存在本地,不上传后端 + final isPrivate = operation.data['is_private'] as bool? ?? false; + if (isPrivate) { + debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}'); + return; + } + _pendingQueue.add(operation); if (_status == SyncStatus.idle) { _status = SyncStatus.paused; diff --git a/app/lib/features/editor/views/editor_page.dart b/app/lib/features/editor/views/editor_page.dart index 7c0970c..d9eb7e1 100644 --- a/app/lib/features/editor/views/editor_page.dart +++ b/app/lib/features/editor/views/editor_page.dart @@ -121,28 +121,35 @@ class _EditorPageState extends State { title: '${now.month}月${now.day}日的日记', date: now, ); - await repo.createJournal(entry); - setId(entry.id); - // 保存笔画 + // 保存到仓库(Web=远程API,原生=Isar本地) + // 远程仓库返回服务端生成的 ID,必须使用返回值 + final saved = await repo.createJournal(entry); + final journalId = saved.id; + setId(journalId); + + // 保存笔画 — 使用 saved.id(与仓库一致) if (state.strokes.isNotEmpty) { - await _saveStrokesAsElement(repo, entry.id, state.strokes); + await _saveStrokesAsElement(repo, journalId, state.strokes); } // 保存其他元素 for (final element in state.elements) { - await repo.addElement(element.copyWith(journalId: entry.id)); + await repo.addElement(element.copyWith(journalId: journalId)); } - // 入队 SyncEngine 等待同步到后端 - syncEngine.enqueue(PendingOperation( - id: entry.id, - type: SyncOperationType.create, - endpoint: '/diary/journals', - data: entry.toJson(), - version: entry.version, - createdAt: now, - )); + // 仅非私密日记入队 SyncEngine 等待同步到后端 + // 私密日记(is_private=true)仅保存在本地,不上传 + if (!saved.isPrivate) { + syncEngine.enqueue(PendingOperation( + id: journalId, + type: SyncOperationType.create, + endpoint: '/diary/journals', + data: saved.toJson(), + version: saved.version, + createdAt: now, + )); + } } else { // --- 更新已有日记 --- final existing = await repo.getJournal(savedJournalId); @@ -156,15 +163,17 @@ class _EditorPageState extends State { ); await repo.updateJournal(updated); - // 入队 SyncEngine 等待同步到后端 - syncEngine.enqueue(PendingOperation( - id: existing.id, - type: SyncOperationType.update, - endpoint: '/diary/journals/${existing.id}', - data: existing.toJson(), - version: existing.version, - createdAt: now, - )); + // 仅非私密日记入队 SyncEngine + if (!updated.isPrivate) { + syncEngine.enqueue(PendingOperation( + id: updated.id, + type: SyncOperationType.update, + endpoint: '/diary/journals/${updated.id}', + data: updated.toJson(), + version: updated.version, + createdAt: now, + )); + } } // 更新笔画 @@ -215,6 +224,11 @@ class _EditorPageState extends State { } /// 显示分享面板并在用户选择后导航 + /// + /// 分享行为: + /// - 分享到班级/所有人 → is_private=false + shared_to_class=对应值 + /// - 仅自己可见 → is_private=true,不上传到后端 + /// - 首次将私密日记变为公开时,入队 SyncEngine create 操作上传 static Future _showShareSheetAndNavigate( BuildContext context, JournalRepository repo, @@ -246,14 +260,41 @@ class _EditorPageState extends State { classId: userClassId, className: userClassName, onDecision: (shareToClass) async { - // 更新日记的 sharedToClass 状态 if (savedJournalId != null) { try { final entry = await repo.getJournal(savedJournalId); if (entry != null) { - await repo.updateJournal( - entry.copyWith(sharedToClass: shareToClass), + final wasPrivate = entry.isPrivate; + // 分享到班级/所有人 → 取消私密标记 + final updated = entry.copyWith( + isPrivate: false, + sharedToClass: shareToClass, ); + await repo.updateJournal(updated); + + // 首次从私密变为公开 → 入队 SyncEngine 上传到后端 + if (wasPrivate && !updated.isPrivate) { + final syncEngine = context.read(); + syncEngine.enqueue(PendingOperation( + id: updated.id, + type: SyncOperationType.create, + endpoint: '/diary/journals', + data: updated.toJson(), + version: updated.version, + createdAt: DateTime.now(), + )); + } else if (!updated.isPrivate) { + // 已公开日记的分享状态更新 + final syncEngine = context.read(); + syncEngine.enqueue(PendingOperation( + id: updated.id, + type: SyncOperationType.update, + endpoint: '/diary/journals/${updated.id}', + data: updated.toJson(), + version: updated.version, + createdAt: DateTime.now(), + )); + } } } catch (e) { debugPrint('更新分享状态失败: $e'); diff --git a/app/lib/features/home/views/home_page.dart b/app/lib/features/home/views/home_page.dart index ddfa62d..65e049c 100644 --- a/app/lib/features/home/views/home_page.dart +++ b/app/lib/features/home/views/home_page.dart @@ -24,6 +24,10 @@ import '../../../core/theme/app_shadows.dart'; import '../../../core/theme/app_typography.dart'; import '../../../data/models/journal_entry.dart'; import '../../../data/repositories/journal_repository.dart'; +import '../../../data/repositories/class_repository.dart'; +import '../../../data/services/sync_engine.dart'; +import '../../auth/bloc/auth_bloc.dart'; +import '../../editor/widgets/share_bottom_sheet.dart'; import '../bloc/home_bloc.dart'; class HomePage extends StatelessWidget { @@ -659,9 +663,22 @@ class _JournalCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${journal.date.month}月${journal.date.day}日', - style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant), + Row( + children: [ + Text( + '${journal.date.month}月${journal.date.day}日', + style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(width: 6), + // 可见性标签 + _VisibilityBadge( + isPrivate: journal.isPrivate, + sharedToClass: journal.sharedToClass, + onTap: journal.isPrivate + ? () => _sharePrivateJournal(context, journal) + : null, + ), + ], ), const SizedBox(height: 2), Text( @@ -702,6 +719,152 @@ class _JournalCard extends StatelessWidget { ), ); } + + /// 分享私密日记 — 弹出分享面板,将日记变为公开并上传到后端 + Future _sharePrivateJournal(BuildContext context, JournalEntry entry) async { + String? userClassId; + String userClassName = '我的班级'; + + try { + final authState = context.read().state; + if (authState is Authenticated) { + try { + final classRepo = context.read(); + final classes = await classRepo.getMyClasses(); + if (classes.isNotEmpty) { + userClassId = classes.first.id; + userClassName = classes.first.name; + } + } catch (_) { + // 没有班级信息,使用默认值 + } + } + } catch (_) {} + + // ignore: use_build_context_synchronously + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => ShareBottomSheet( + classId: userClassId, + className: userClassName, + onDecision: (shareToClass) async { + try { + final repo = context.read(); + // 将私密日记变为公开 + final updated = entry.copyWith( + isPrivate: false, + sharedToClass: shareToClass, + ); + await repo.updateJournal(updated); + + // 首次从私密变为公开 → 入队 SyncEngine 上传到后端 + final syncEngine = context.read(); + syncEngine.enqueue(PendingOperation( + id: updated.id, + type: SyncOperationType.create, + endpoint: '/diary/journals', + data: updated.toJson(), + version: updated.version, + createdAt: DateTime.now(), + )); + + // 刷新首页列表 + // ignore: use_build_context_synchronously + context.read().add(const HomeRefresh()); + } catch (e) { + debugPrint('分享日记失败: $e'); + } + }, + ), + ); + } +} + +/// 可见性标签 — 显示日记的可见性状态 +/// +/// - 私密:🔒 仅自己可见(可点击分享) +/// - 分享到班级:🏫 班级可见 +/// - 公开:🌐 所有人可见 +class _VisibilityBadge extends StatelessWidget { + const _VisibilityBadge({ + required this.isPrivate, + required this.sharedToClass, + this.onTap, + }); + + final bool isPrivate; + final bool sharedToClass; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + if (isPrivate) { + // 私密日记 — 显示锁定图标,可点击分享 + return InkWell( + onTap: onTap, + customBorder: const StadiumBorder(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.tertiarySoftLight, + borderRadius: AppRadius.pillBorder, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline, size: 12, color: Color(0xFFB8860B)), + SizedBox(width: 3), + Text( + '仅自己可见', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFFB8860B)), + ), + ], + ), + ), + ); + } + + if (sharedToClass) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.15), + borderRadius: AppRadius.pillBorder, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.groups, size: 12, color: AppColors.secondary), + SizedBox(width: 3), + Text( + '班级可见', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.secondary), + ), + ], + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.12), + borderRadius: AppRadius.pillBorder, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.public, size: 12, color: AppColors.accent), + const SizedBox(width: 3), + Text( + '公开', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.accent), + ), + ], + ), + ); + } } class _EmptyJournalState extends StatelessWidget { diff --git a/crates/erp-diary/src/handler/journal_handler.rs b/crates/erp-diary/src/handler/journal_handler.rs index 21c751b..e78768f 100644 --- a/crates/erp-diary/src/handler/journal_handler.rs +++ b/crates/erp-diary/src/handler/journal_handler.rs @@ -251,7 +251,7 @@ where // IDOR 修复:非管理角色只能查看自己的日记 // - 学生:强制 author_id = ctx.user_id - // - 老师/管理员:允许查看所有日记 + // - 老师/管理员:允许查看所有日记,但排除其他用户的私密日记 // - 家长:应通过 parent_service 专用端点查看孩子日记 let author_id = if ctx.roles.iter().any(|r| r == "teacher" || r == "admin") { // 管理角色可查看任意作者的日记 @@ -261,6 +261,11 @@ where Some(ctx.user_id) }; + // 管理角色查看他人日记时,排除 is_private=true 的私密日记 + // 学生查看自己的日记时,包含私密日记(那是他们自己的) + let exclude_private = ctx.roles.iter().any(|r| r == "teacher" || r == "admin") + && author_id != Some(ctx.user_id); + let (items, total) = JournalService::list( ctx.tenant_id, author_id, @@ -268,6 +273,7 @@ where params.date_from, params.date_to, params.class_id, + exclude_private, page, page_size, &state.db, diff --git a/crates/erp-diary/src/service/journal_service.rs b/crates/erp-diary/src/service/journal_service.rs index 4b386ff..09f7d5b 100644 --- a/crates/erp-diary/src/service/journal_service.rs +++ b/crates/erp-diary/src/service/journal_service.rs @@ -217,6 +217,7 @@ impl JournalService { /// 日记列表(分页 + 筛选) /// /// 支持按作者、心情、日期范围、班级筛选。 + /// `exclude_private` 为 true 时排除 is_private=true 的日记(管理端查看他人日记场景)。 /// 返回 (items, total)。 pub async fn list( tenant_id: Uuid, @@ -225,6 +226,7 @@ impl JournalService { date_from: Option, date_to: Option, class_id: Option, + exclude_private: bool, page: u64, page_size: u64, db: &DatabaseConnection, @@ -248,6 +250,10 @@ impl JournalService { if let Some(cid) = class_id { condition = condition.add(journal_entry::Column::ClassId.eq(cid)); } + // 管理角色查看他人日记时,排除私密日记 + if exclude_private { + condition = condition.add(journal_entry::Column::IsPrivate.eq(false)); + } let page_size = page_size.min(100).max(1); let page = page.max(1);