fix(app): 日记可见性修复 — 私密日记仅本地 + Web 端 ID 修复 + 分享按钮
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

问题修复:
1. Web端保存的日记看不到:createJournal 返回值未捕获,server ID 丢失导致
   后续元素保存用错 ID。现在使用 saved.id 贯穿全部操作。
2. 管理端看不到新建日记:后端 list_journals 添加 is_private 过滤,admin/teacher
   查看他人日记时排除私密日记。
3. RemoteJournalRepository 添加 onJournalChanged 变更通知流,HomeBloc 可自动刷新。
4. SyncEngine(native + web)enqueue 添加 is_private 防御性检查,私密日记不入队。
5. 编辑器 _persistState 条件入队:仅非私密日记同步到后端。
6. 分享流程改造:首次从私密变为公开时入队 create 操作上传。
7. 日记卡片添加可见性标签(仅自己可见/班级可见/公开),私密日记可点击分享。
8. 首页 _sharePrivateJournal 弹出 ShareBottomSheet 主动分享。
This commit is contained in:
iven
2026-06-04 12:03:24 +08:00
parent c441aa4e34
commit bb388ed8ff
7 changed files with 276 additions and 33 deletions

View File

@@ -1,5 +1,7 @@
// 远程日记仓库 — 通过 API 客户端连接后端 // 远程日记仓库 — 通过 API 客户端连接后端
import 'dart:async';
import '../models/journal_element.dart'; import '../models/journal_element.dart';
import '../models/journal_entry.dart'; import '../models/journal_entry.dart';
import '../remote/api_client.dart'; import '../remote/api_client.dart';
@@ -11,6 +13,10 @@ import 'journal_repository.dart';
class RemoteJournalRepository implements JournalRepository { class RemoteJournalRepository implements JournalRepository {
final ApiClient _api; final ApiClient _api;
/// 变更通知流控制器 — 写操作成功后通知 UI 刷新
final StreamController<void> _changeController =
StreamController<void>.broadcast();
RemoteJournalRepository({required ApiClient api}) : _api = api; RemoteJournalRepository({required ApiClient api}) : _api = api;
@override @override
@@ -78,7 +84,9 @@ class RemoteJournalRepository implements JournalRepository {
json['date'] = entry.date.toIso8601String().substring(0, 10); json['date'] = entry.date.toIso8601String().substring(0, 10);
final response = await _api.post('/diary/journals', data: json); final response = await _api.post('/diary/journals', data: json);
final body = response.data as Map<String, dynamic>; final body = response.data as Map<String, dynamic>;
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>); final created = JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
_changeController.add(null); // 通知 UI 刷新列表
return created;
} }
@override @override
@@ -96,12 +104,14 @@ class RemoteJournalRepository implements JournalRepository {
}, },
); );
final body = response.data as Map<String, dynamic>; final body = response.data as Map<String, dynamic>;
_changeController.add(null); // 通知 UI 刷新列表
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>); return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
} }
@override @override
Future<void> deleteJournal(String id) async { Future<void> deleteJournal(String id) async {
await _api.delete('/diary/journals/$id'); await _api.delete('/diary/journals/$id');
_changeController.add(null); // 通知 UI 刷新列表
} }
@override @override
@@ -139,9 +149,9 @@ class RemoteJournalRepository implements JournalRepository {
await _api.delete('/diary/elements/$elementId'); await _api.delete('/diary/elements/$elementId');
} }
/// 远程仓库不提供本地变更通知,返回空流 /// 变更通知流 — 写操作后广播,供 HomeBloc 自动刷新
@override @override
Stream<void> get onJournalChanged => const Stream<void>.empty(); Stream<void> get onJournalChanged => _changeController.stream;
} }
/// API 异常封装 — 后端返回非 2xx 状态码时抛出 /// API 异常封装 — 后端返回非 2xx 状态码时抛出

View File

@@ -143,7 +143,16 @@ class SyncEngine {
/// update+update → update使用最新数据 /// update+update → update使用最新数据
/// update+delete → delete资源最终被删除 /// update+delete → delete资源最终被删除
/// create+delete → 取消(资源从未存在) /// create+delete → 取消(资源从未存在)
///
/// 私密日记is_private=true不入队 — 仅保存在本地,不上传后端。
void enqueue(PendingOperation operation) { void enqueue(PendingOperation operation) {
// 防御性检查:私密日记不入队
final isPrivate = operation.data['is_private'] as bool? ?? false;
if (isPrivate) {
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
return;
}
// 查找队列中同一资源的最后一个操作 // 查找队列中同一资源的最后一个操作
PendingOperation? existing; PendingOperation? existing;
for (final op in _pendingQueue) { for (final op in _pendingQueue) {

View File

@@ -90,7 +90,15 @@ class SyncEngine {
int get pendingCount => _pendingQueue.length; int get pendingCount => _pendingQueue.length;
bool get isSyncing => _status == SyncStatus.syncing; bool get isSyncing => _status == SyncStatus.syncing;
/// 入队待同步操作 — 私密日记is_private=true不入队
void enqueue(PendingOperation operation) { void enqueue(PendingOperation operation) {
// 防御性检查:私密日记仅保存在本地,不上传后端
final isPrivate = operation.data['is_private'] as bool? ?? false;
if (isPrivate) {
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
return;
}
_pendingQueue.add(operation); _pendingQueue.add(operation);
if (_status == SyncStatus.idle) { if (_status == SyncStatus.idle) {
_status = SyncStatus.paused; _status = SyncStatus.paused;

View File

@@ -121,28 +121,35 @@ class _EditorPageState extends State<EditorPage> {
title: '${now.month}${now.day}日的日记', title: '${now.month}${now.day}日的日记',
date: now, 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) { if (state.strokes.isNotEmpty) {
await _saveStrokesAsElement(repo, entry.id, state.strokes); await _saveStrokesAsElement(repo, journalId, state.strokes);
} }
// 保存其他元素 // 保存其他元素
for (final element in state.elements) { for (final element in state.elements) {
await repo.addElement(element.copyWith(journalId: entry.id)); await repo.addElement(element.copyWith(journalId: journalId));
} }
// 入队 SyncEngine 等待同步到后端 // 仅非私密日记入队 SyncEngine 等待同步到后端
syncEngine.enqueue(PendingOperation( // 私密日记is_private=true仅保存在本地不上传
id: entry.id, if (!saved.isPrivate) {
type: SyncOperationType.create, syncEngine.enqueue(PendingOperation(
endpoint: '/diary/journals', id: journalId,
data: entry.toJson(), type: SyncOperationType.create,
version: entry.version, endpoint: '/diary/journals',
createdAt: now, data: saved.toJson(),
)); version: saved.version,
createdAt: now,
));
}
} else { } else {
// --- 更新已有日记 --- // --- 更新已有日记 ---
final existing = await repo.getJournal(savedJournalId); final existing = await repo.getJournal(savedJournalId);
@@ -156,15 +163,17 @@ class _EditorPageState extends State<EditorPage> {
); );
await repo.updateJournal(updated); await repo.updateJournal(updated);
// 入队 SyncEngine 等待同步到后端 // 仅非私密日记入队 SyncEngine
syncEngine.enqueue(PendingOperation( if (!updated.isPrivate) {
id: existing.id, syncEngine.enqueue(PendingOperation(
type: SyncOperationType.update, id: updated.id,
endpoint: '/diary/journals/${existing.id}', type: SyncOperationType.update,
data: existing.toJson(), endpoint: '/diary/journals/${updated.id}',
version: existing.version, data: updated.toJson(),
createdAt: now, version: updated.version,
)); createdAt: now,
));
}
} }
// 更新笔画 // 更新笔画
@@ -215,6 +224,11 @@ class _EditorPageState extends State<EditorPage> {
} }
/// 显示分享面板并在用户选择后导航 /// 显示分享面板并在用户选择后导航
///
/// 分享行为:
/// - 分享到班级/所有人 → is_private=false + shared_to_class=对应值
/// - 仅自己可见 → is_private=true不上传到后端
/// - 首次将私密日记变为公开时,入队 SyncEngine create 操作上传
static Future<void> _showShareSheetAndNavigate( static Future<void> _showShareSheetAndNavigate(
BuildContext context, BuildContext context,
JournalRepository repo, JournalRepository repo,
@@ -246,14 +260,41 @@ class _EditorPageState extends State<EditorPage> {
classId: userClassId, classId: userClassId,
className: userClassName, className: userClassName,
onDecision: (shareToClass) async { onDecision: (shareToClass) async {
// 更新日记的 sharedToClass 状态
if (savedJournalId != null) { if (savedJournalId != null) {
try { try {
final entry = await repo.getJournal(savedJournalId); final entry = await repo.getJournal(savedJournalId);
if (entry != null) { if (entry != null) {
await repo.updateJournal( final wasPrivate = entry.isPrivate;
entry.copyWith(sharedToClass: shareToClass), // 分享到班级/所有人 → 取消私密标记
final updated = entry.copyWith(
isPrivate: false,
sharedToClass: shareToClass,
); );
await repo.updateJournal(updated);
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
if (wasPrivate && !updated.isPrivate) {
final syncEngine = context.read<SyncEngine>();
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>();
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) { } catch (e) {
debugPrint('更新分享状态失败: $e'); debugPrint('更新分享状态失败: $e');

View File

@@ -24,6 +24,10 @@ import '../../../core/theme/app_shadows.dart';
import '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_typography.dart';
import '../../../data/models/journal_entry.dart'; import '../../../data/models/journal_entry.dart';
import '../../../data/repositories/journal_repository.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'; import '../bloc/home_bloc.dart';
class HomePage extends StatelessWidget { class HomePage extends StatelessWidget {
@@ -659,9 +663,22 @@ class _JournalCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
'${journal.date.month}${journal.date.day}', children: [
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant), 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), const SizedBox(height: 2),
Text( Text(
@@ -702,6 +719,152 @@ class _JournalCard extends StatelessWidget {
), ),
); );
} }
/// 分享私密日记 — 弹出分享面板,将日记变为公开并上传到后端
Future<void> _sharePrivateJournal(BuildContext context, JournalEntry entry) async {
String? userClassId;
String userClassName = '我的班级';
try {
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
try {
final classRepo = context.read<ClassRepository>();
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<JournalRepository>();
// 将私密日记变为公开
final updated = entry.copyWith(
isPrivate: false,
sharedToClass: shareToClass,
);
await repo.updateJournal(updated);
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
final syncEngine = context.read<SyncEngine>();
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<HomeBloc>().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 { class _EmptyJournalState extends StatelessWidget {

View File

@@ -251,7 +251,7 @@ where
// IDOR 修复:非管理角色只能查看自己的日记 // IDOR 修复:非管理角色只能查看自己的日记
// - 学生:强制 author_id = ctx.user_id // - 学生:强制 author_id = ctx.user_id
// - 老师/管理员:允许查看所有日记 // - 老师/管理员:允许查看所有日记,但排除其他用户的私密日记
// - 家长:应通过 parent_service 专用端点查看孩子日记 // - 家长:应通过 parent_service 专用端点查看孩子日记
let author_id = if ctx.roles.iter().any(|r| r == "teacher" || r == "admin") { let author_id = if ctx.roles.iter().any(|r| r == "teacher" || r == "admin") {
// 管理角色可查看任意作者的日记 // 管理角色可查看任意作者的日记
@@ -261,6 +261,11 @@ where
Some(ctx.user_id) 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( let (items, total) = JournalService::list(
ctx.tenant_id, ctx.tenant_id,
author_id, author_id,
@@ -268,6 +273,7 @@ where
params.date_from, params.date_from,
params.date_to, params.date_to,
params.class_id, params.class_id,
exclude_private,
page, page,
page_size, page_size,
&state.db, &state.db,

View File

@@ -217,6 +217,7 @@ impl JournalService {
/// 日记列表(分页 + 筛选) /// 日记列表(分页 + 筛选)
/// ///
/// 支持按作者、心情、日期范围、班级筛选。 /// 支持按作者、心情、日期范围、班级筛选。
/// `exclude_private` 为 true 时排除 is_private=true 的日记(管理端查看他人日记场景)。
/// 返回 (items, total)。 /// 返回 (items, total)。
pub async fn list( pub async fn list(
tenant_id: Uuid, tenant_id: Uuid,
@@ -225,6 +226,7 @@ impl JournalService {
date_from: Option<chrono::NaiveDate>, date_from: Option<chrono::NaiveDate>,
date_to: Option<chrono::NaiveDate>, date_to: Option<chrono::NaiveDate>,
class_id: Option<Uuid>, class_id: Option<Uuid>,
exclude_private: bool,
page: u64, page: u64,
page_size: u64, page_size: u64,
db: &DatabaseConnection, db: &DatabaseConnection,
@@ -248,6 +250,10 @@ impl JournalService {
if let Some(cid) = class_id { if let Some(cid) = class_id {
condition = condition.add(journal_entry::Column::ClassId.eq(cid)); 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_size = page_size.min(100).max(1);
let page = page.max(1); let page = page.max(1);