问题修复: 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 主动分享。
222 lines
5.9 KiB
Dart
222 lines
5.9 KiB
Dart
// 同步引擎 — Web 平台实现(无 Isar 持久化)
|
||
//
|
||
// Web 平台上 Isar 不可用,操作队列仅保存在内存中。
|
||
// 核心同步逻辑与原生版一致,仅持久化部分为空实现。
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:collection';
|
||
|
||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
|
||
import '../remote/api_client.dart';
|
||
|
||
/// 同步操作类型
|
||
enum SyncOperationType {
|
||
create('POST'),
|
||
update('PUT'),
|
||
delete('DELETE');
|
||
|
||
const SyncOperationType(this.httpMethod);
|
||
final String httpMethod;
|
||
}
|
||
|
||
/// 同步状态
|
||
enum SyncStatus {
|
||
idle,
|
||
syncing,
|
||
paused,
|
||
error,
|
||
}
|
||
|
||
/// 待同步操作
|
||
class PendingOperation {
|
||
final String id;
|
||
final SyncOperationType type;
|
||
final String endpoint;
|
||
final Map<String, dynamic> data;
|
||
final int version;
|
||
final DateTime createdAt;
|
||
final int retryCount;
|
||
|
||
static const int maxRetryCount = 5;
|
||
|
||
const PendingOperation({
|
||
required this.id,
|
||
required this.type,
|
||
required this.endpoint,
|
||
required this.data,
|
||
required this.version,
|
||
required this.createdAt,
|
||
this.retryCount = 0,
|
||
});
|
||
|
||
PendingOperation copyWith({
|
||
String? id,
|
||
SyncOperationType? type,
|
||
String? endpoint,
|
||
Map<String, dynamic>? data,
|
||
int? version,
|
||
DateTime? createdAt,
|
||
int? retryCount,
|
||
}) =>
|
||
PendingOperation(
|
||
id: id ?? this.id,
|
||
type: type ?? this.type,
|
||
endpoint: endpoint ?? this.endpoint,
|
||
data: data ?? this.data,
|
||
version: version ?? this.version,
|
||
createdAt: createdAt ?? this.createdAt,
|
||
retryCount: retryCount ?? this.retryCount,
|
||
);
|
||
|
||
bool get isExhausted => retryCount >= maxRetryCount;
|
||
}
|
||
|
||
/// 同步引擎 — Web 版(内存队列,无持久化)
|
||
class SyncEngine {
|
||
final ApiClient _apiClient;
|
||
final Queue<PendingOperation> _pendingQueue = Queue();
|
||
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||
|
||
SyncStatus _status = SyncStatus.idle;
|
||
String? _lastError;
|
||
|
||
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
|
||
|
||
SyncStatus get status => _status;
|
||
String? get lastError => _lastError;
|
||
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;
|
||
}
|
||
}
|
||
|
||
void enqueueAll(List<PendingOperation> operations) {
|
||
for (final op in operations) {
|
||
_pendingQueue.add(op);
|
||
}
|
||
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
|
||
_status = SyncStatus.paused;
|
||
}
|
||
}
|
||
|
||
Future<void> trySync() async {
|
||
if (_status == SyncStatus.syncing) return;
|
||
if (_pendingQueue.isEmpty) {
|
||
_status = SyncStatus.idle;
|
||
return;
|
||
}
|
||
|
||
final connectivity = Connectivity();
|
||
final result = await connectivity.checkConnectivity();
|
||
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||
if (!isOnline) {
|
||
_status = SyncStatus.paused;
|
||
_lastError = '网络不可用';
|
||
return;
|
||
}
|
||
|
||
_status = SyncStatus.syncing;
|
||
_lastError = null;
|
||
|
||
while (_pendingQueue.isNotEmpty) {
|
||
final operation = _pendingQueue.removeFirst();
|
||
|
||
try {
|
||
await _executeOperation(operation);
|
||
} on OfflineException {
|
||
_pendingQueue.addFirst(operation);
|
||
_status = SyncStatus.paused;
|
||
_lastError = '同步中断:网络不可用';
|
||
return;
|
||
} catch (e) {
|
||
debugPrint('SyncEngine.trySync 操作失败: $e');
|
||
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
|
||
|
||
if (retried.isExhausted) {
|
||
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
|
||
continue;
|
||
}
|
||
|
||
_pendingQueue.addFirst(retried);
|
||
_status = SyncStatus.error;
|
||
_lastError = '同步失败: $e';
|
||
return;
|
||
}
|
||
}
|
||
|
||
_status = SyncStatus.idle;
|
||
_lastError = null;
|
||
}
|
||
|
||
Future<void> _executeOperation(PendingOperation operation) async {
|
||
switch (operation.type) {
|
||
case SyncOperationType.create:
|
||
await _apiClient.post(operation.endpoint, data: operation.data);
|
||
case SyncOperationType.update:
|
||
await _apiClient.put(operation.endpoint, data: operation.data);
|
||
case SyncOperationType.delete:
|
||
await _apiClient.delete(operation.endpoint);
|
||
}
|
||
}
|
||
|
||
void clear() {
|
||
_pendingQueue.clear();
|
||
_status = SyncStatus.idle;
|
||
_lastError = null;
|
||
}
|
||
|
||
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||
|
||
/// Web 平台:持久化为空操作(队列仅保存在内存中)
|
||
Future<void> persistPendingQueue() async {}
|
||
|
||
/// Web 平台:恢复队列为空操作(无持久化数据)
|
||
Future<void> restorePendingQueue() async {}
|
||
|
||
void startAutoSync() {
|
||
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
|
||
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
|
||
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
|
||
trySync();
|
||
}
|
||
});
|
||
}
|
||
|
||
void dispose() {
|
||
_connectivitySub?.cancel();
|
||
_connectivitySub = null;
|
||
}
|
||
|
||
String _encodeJson(Map<String, dynamic> data) {
|
||
try {
|
||
return jsonEncode(data);
|
||
} catch (_) {
|
||
return '{}';
|
||
}
|
||
}
|
||
|
||
Map<String, dynamic> _decodeJson(String json) {
|
||
try {
|
||
return jsonDecode(json) as Map<String, dynamic>;
|
||
} catch (_) {
|
||
return {};
|
||
}
|
||
}
|
||
}
|