fix(app): 日记可见性修复 — 私密日记仅本地 + Web 端 ID 修复 + 分享按钮
问题修复: 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:
@@ -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 状态码时抛出
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.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<EditorPage> {
|
||||
);
|
||||
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<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');
|
||||
|
||||
@@ -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<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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user