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 客户端连接后端
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<void> _changeController =
StreamController<void>.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<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
@@ -96,12 +104,14 @@ class RemoteJournalRepository implements JournalRepository {
},
);
final body = response.data as Map<String, dynamic>;
_changeController.add(null); // 通知 UI 刷新列表
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
}
@override
Future<void> 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<void> get onJournalChanged => const Stream<void>.empty();
Stream<void> get onJournalChanged => _changeController.stream;
}
/// API 异常封装 — 后端返回非 2xx 状态码时抛出

View File

@@ -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) {

View File

@@ -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;

View File

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

View File

@@ -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 {
@@ -658,11 +662,24 @@ class _JournalCard extends StatelessWidget {
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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(
journal.title,
@@ -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 {

View File

@@ -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,

View File

@@ -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<chrono::NaiveDate>,
date_to: Option<chrono::NaiveDate>,
class_id: Option<Uuid>,
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);