diff --git a/app/lib/data/local/collections/journal_entry_collection.dart b/app/lib/data/local/collections/journal_entry_collection.dart index 55a1c20..0f37397 100644 --- a/app/lib/data/local/collections/journal_entry_collection.dart +++ b/app/lib/data/local/collections/journal_entry_collection.dart @@ -16,7 +16,8 @@ class JournalEntryCollection { @Index() String id = ''; - /// 作者 ID + /// 作者 ID(索引 + 组合索引 authorId+dateEpoch,覆盖按作者查询并按日期排序的场景) + @Index(composite: [CompositeIndex('dateEpoch')]) String authorId = ''; /// 班级 ID(可选) @@ -25,7 +26,8 @@ class JournalEntryCollection { /// 日记标题 String title = ''; - /// 日记日期(epoch milliseconds) + /// 日记日期(epoch milliseconds)— 单独索引支持日期范围查询 + @Index() int dateEpoch = 0; /// 心情(enum → string) diff --git a/app/lib/data/repositories/isar_journal_repository_native.dart b/app/lib/data/repositories/isar_journal_repository_native.dart index 7b6a706..56e02f5 100644 --- a/app/lib/data/repositories/isar_journal_repository_native.dart +++ b/app/lib/data/repositories/isar_journal_repository_native.dart @@ -64,19 +64,18 @@ class IsarJournalRepository implements JournalRepository { query = query.and().classIdEqualTo(classId); } - // 按日期降序排列 - var results = await query - .sortByDateEpochDesc() - .findAll(); - - // 分页 + // 按日期降序排列 + DB 层分页(替代全量加载后 Dart 层 sublist) if (page != null && pageSize != null) { - final start = (page - 1) * pageSize; - if (start >= results.length) return []; - final end = (start + pageSize).clamp(0, results.length); - results = results.sublist(start, end); + final offset = (page - 1) * pageSize; + final results = await query + .sortByDateEpochDesc() + .offset(offset) + .limit(pageSize) + .findAll(); + return results.map(_fromCollection).toList(); } + final results = await query.sortByDateEpochDesc().findAll(); return results.map(_fromCollection).toList(); } diff --git a/app/lib/data/services/sync_engine_native.dart b/app/lib/data/services/sync_engine_native.dart index 14674c6..c68771d 100644 --- a/app/lib/data/services/sync_engine_native.dart +++ b/app/lib/data/services/sync_engine_native.dart @@ -136,21 +136,77 @@ class SyncEngine { bool get isSyncing => _status == SyncStatus.syncing; /// 添加待同步操作到队列尾部 + /// + /// 合并策略(8b-N01):同一资源(endpoint 相同)的连续操作只保留最新一条。 + /// create+update → create(使用最新数据) + /// update+update → update(使用最新数据) + /// update+delete → delete(资源最终被删除) + /// create+delete → 取消(资源从未存在) void enqueue(PendingOperation operation) { - _pendingQueue.add(operation); + // 查找队列中同一资源的最后一个操作 + PendingOperation? existing; + for (final op in _pendingQueue) { + if (op.endpoint == operation.endpoint) { + existing = op; + } + } + + if (existing != null) { + final merged = _mergeOperations(existing, operation); + _pendingQueue.remove(existing); + if (merged != null) { + _pendingQueue.add(merged); + } + // merged == null → create+delete 取消,不添加 + } else { + _pendingQueue.add(operation); + } + if (_status == SyncStatus.idle) { _status = SyncStatus.paused; } } - /// 批量添加待同步操作 + /// 批量添加待同步操作(每个操作独立走合并逻辑) void enqueueAll(List operations) { for (final op in operations) { - _pendingQueue.add(op); + enqueue(op); } - if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) { - _status = SyncStatus.paused; + } + + /// 合并同一资源的两个操作 + /// + /// 返回合并后的操作,或 null 表示应取消(create+delete)。 + PendingOperation? _mergeOperations( + PendingOperation existing, + PendingOperation incoming, + ) { + // create + delete → 取消(资源从未同步到服务端) + if (existing.type == SyncOperationType.create && + incoming.type == SyncOperationType.delete) { + return null; } + + // create + update → create(使用最新数据) + if (existing.type == SyncOperationType.create && + incoming.type == SyncOperationType.update) { + return existing.copyWith(data: incoming.data, version: incoming.version); + } + + // update + update → update(使用最新数据) + if (existing.type == SyncOperationType.update && + incoming.type == SyncOperationType.update) { + return incoming; + } + + // update + delete → delete + if (existing.type == SyncOperationType.update && + incoming.type == SyncOperationType.delete) { + return incoming; + } + + // 其他组合(delete+create, create+create 等)不合并 + return incoming; } /// 检查网络状态并尝试同步全部待处理操作 diff --git a/app/lib/features/editor/widgets/stroke_cache.dart b/app/lib/features/editor/widgets/stroke_cache.dart index 45ee575..9ed0148 100644 --- a/app/lib/features/editor/widgets/stroke_cache.dart +++ b/app/lib/features/editor/widgets/stroke_cache.dart @@ -20,8 +20,10 @@ import 'stroke_renderer.dart'; class _CacheEntry { final ui.Image image; final Stroke stroke; + /// BBox 偏移量 — 光栅化时裁剪的起点,合成时用于定位 + final Offset offset; - const _CacheEntry({required this.image, required this.stroke}); + const _CacheEntry({required this.image, required this.stroke, required this.offset}); } // ===== 光栅化缓存 ===== @@ -76,18 +78,22 @@ class StrokeRasterCache { /// 添加一条已完成笔画到缓存 /// - /// 光栅化该笔画为 ui.Image,然后增量合成到 compositeImage。 + /// 光栅化该笔画为 ui.Image(仅 BBox 区域),然后增量合成到 compositeImage。 Future addStroke(Stroke stroke) async { if (_canvasSize == Size.zero) return; - // 光栅化单笔画 - final image = await _rasterizeStroke(stroke); - if (image == null) return; + // 光栅化单笔画(BBox 裁剪) + final result = await _rasterizeStroke(stroke); + if (result == null) return; - _cache[stroke.id] = _CacheEntry(image: image, stroke: stroke); + _cache[stroke.id] = _CacheEntry( + image: result.image, + stroke: stroke, + offset: result.offset, + ); // 增量合成:将新笔画绘制到现有 compositeImage 之上 - await _compositeIncremental(stroke, image); + await _compositeIncremental(stroke, result.image, result.offset); } /// 同步笔画列表(用于撤销/重做后与 BLoC 状态对齐) @@ -108,9 +114,13 @@ class StrokeRasterCache { final toAdd = currentIds.difference(cachedIds); for (final stroke in strokes) { if (toAdd.contains(stroke.id)) { - final image = await _rasterizeStroke(stroke); - if (image != null) { - _cache[stroke.id] = _CacheEntry(image: image, stroke: stroke); + final result = await _rasterizeStroke(stroke); + if (result != null) { + _cache[stroke.id] = _CacheEntry( + image: result.image, + stroke: stroke, + offset: result.offset, + ); } } } @@ -155,8 +165,12 @@ class StrokeRasterCache { // ===== 光栅化 ===== - /// 将单条笔画光栅化为 ui.Image - Future _rasterizeStroke(Stroke stroke) async { + /// 将单条笔画光栅化为 ui.Image — 仅光栅化 BBox 区域(性能优化 8b-M02) + /// + /// 计算笔画的包围盒 (bounding box),仅对该区域光栅化, + /// 大幅减少 GPU 内存占用(短笔画从全画布 4096×4096 降到实际尺寸)。 + /// 返回 null 表示笔画无有效点。 + Future<({ui.Image image, Offset offset})?> _rasterizeStroke(Stroke stroke) async { final outlinePoints = pointsToOutline( stroke.points, stroke.brushType, @@ -165,16 +179,38 @@ class StrokeRasterCache { ); if (outlinePoints.isEmpty) return null; + // 计算笔画包围盒 + double minX = double.infinity, minY = double.infinity; + double maxX = double.negativeInfinity, maxY = double.negativeInfinity; + for (final p in outlinePoints) { + if (p.dx < minX) minX = p.dx; + if (p.dy < minY) minY = p.dy; + if (p.dx > maxX) maxX = p.dx; + if (p.dy > maxY) maxY = p.dy; + } + + // 添加边距(抗锯齿 + 笔触溢出) + const padding = 4.0; + final bboxLeft = (minX - padding).clamp(0.0, _canvasSize.width); + final bboxTop = (minY - padding).clamp(0.0, _canvasSize.height); + final bboxRight = (maxX + padding).clamp(0.0, _canvasSize.width); + final bboxBottom = (maxY + padding).clamp(0.0, _canvasSize.height); + final bboxWidth = (bboxRight - bboxLeft).clamp(1.0, 4096.0); + final bboxHeight = (bboxBottom - bboxTop).clamp(1.0, 4096.0); + final path = buildStrokePath(outlinePoints); final paint = createPaintForStroke(stroke); final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); + // 平移坐标系,使 BBox 左上角对齐 (0, 0) + canvas.translate(-bboxLeft, -bboxTop); + // 橡皮擦需要 saveLayer 保护,避免穿透 if (stroke.brushType == BrushType.eraser) { canvas.saveLayer( - Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height), + Rect.fromLTWH(0, 0, bboxWidth, bboxHeight), Paint(), ); } @@ -186,16 +222,20 @@ class StrokeRasterCache { } final picture = recorder.endRecording(); - return picture.toImage( - _canvasSize.width.toInt().clamp(1, 4096), - _canvasSize.height.toInt().clamp(1, 4096), + final image = await picture.toImage( + bboxWidth.toInt().clamp(1, 4096), + bboxHeight.toInt().clamp(1, 4096), ); + + return (image: image, offset: Offset(bboxLeft, bboxTop)); } // ===== 合成 ===== /// 增量合成:将新笔画图像绘制到现有 compositeImage 上 - Future _compositeIncremental(Stroke stroke, ui.Image strokeImage) async { + /// + /// strokeImage 是 BBox 裁剪后的图像,offset 是其原始位置偏移。 + Future _compositeIncremental(Stroke stroke, ui.Image strokeImage, Offset offset) async { final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); @@ -210,15 +250,15 @@ class StrokeRasterCache { canvas.drawImage(_compositeImage!, Offset.zero, Paint()); } - // 再绘制新笔画(橡皮擦用 dstOut) + // 再绘制新笔画(橡皮擦用 dstOut),使用 BBox offset 定位 if (stroke.brushType == BrushType.eraser) { canvas.drawImage( strokeImage, - Offset.zero, + offset, Paint()..blendMode = BlendMode.dstOut, ); } else { - canvas.drawImage(strokeImage, Offset.zero, Paint()); + canvas.drawImage(strokeImage, offset, Paint()); } canvas.restore(); diff --git a/app/lib/features/home/bloc/home_bloc.dart b/app/lib/features/home/bloc/home_bloc.dart index c97ffd4..b4a188a 100644 --- a/app/lib/features/home/bloc/home_bloc.dart +++ b/app/lib/features/home/bloc/home_bloc.dart @@ -116,9 +116,14 @@ class HomeBloc extends Bloc { // 推算连续天数 final streakDays = _calculateStreak(journals); - // 本月日记数(spec §3.4 quick-stats) - final monthCount = journals.where((j) => - j.date.year == today.year && j.date.month == today.month).length; + // 本月日记数 — 使用日期范围查询,不受分页限制(修复 8b-D03) + final monthStart = DateTime(today.year, today.month, 1); + final monthEnd = DateTime(today.year, today.month + 1, 1); + final monthJournals = await _journalRepo.getJournals( + dateFrom: monthStart, + dateTo: monthEnd, + ); + final monthCount = monthJournals.length; // 总日记数 — 使用仓库计数方法(不受分页限制) final totalCount = await _journalRepo.getJournalCount();