feat(app): pnpm 一键启动 + Flutter Web 编译修复
1. 新增 pnpm start:dev / pnpm start:stop 命令 - scripts/dev.mjs: 跨平台启动脚本(后端+管理端+学生端) - scripts/stop.mjs: 端口清理停止脚本 - 根 package.json 定义 pnpm 脚本 2. 修复 Flutter Web 编译(Isar 3.x + flutter_secure_storage 不兼容) - isar_database: 条件导出,Web 用空 stub - isar_journal_repository: 条件导出,Web 用空 stub - sync_engine: 条件导出,Web 用内存队列(无 Isar 持久化) - 移除 flutter_secure_storage(v9 web 插件用 dart:html) - 新增 SecureTokenStore 接口 + shared_preferences 实现 - auth_repository 改用 SecureTokenStore 接口
This commit is contained in:
@@ -19,6 +19,7 @@ import 'package:provider/provider.dart' show ListenableProvider;
|
||||
import 'config/app_config.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/routing/app_router.dart';
|
||||
import 'data/local/secure_token_store_factory.dart';
|
||||
import 'data/remote/api_client.dart';
|
||||
import 'data/repositories/auth_repository.dart';
|
||||
import 'data/repositories/journal_repository.dart';
|
||||
@@ -38,7 +39,8 @@ class NuanjiApp extends StatelessWidget {
|
||||
// 创建全局依赖(App 生命周期内单例)
|
||||
final config = AppConfig.fromEnvironment();
|
||||
final apiClient = ApiClient(baseUrl: config.apiBaseUrl);
|
||||
final authRepository = AuthRepository(apiClient: apiClient);
|
||||
final tokenStore = createSecureTokenStore();
|
||||
final authRepository = AuthRepository(apiClient: apiClient, tokenStore: tokenStore);
|
||||
// 离线优先:Isar 为主要本地仓库,Remote 供 SyncEngine 推送
|
||||
// Web 平台:Isar 3.x 不支持 Web,直接使用远程仓库
|
||||
final journalRepository = kIsWeb
|
||||
|
||||
@@ -1,88 +1,14 @@
|
||||
// Isar 数据库初始化 — 本地持久化存储
|
||||
// Isar 数据库条件导出
|
||||
//
|
||||
// Isar 3.x 要求 open() 时传入 List<CollectionSchema>。
|
||||
// 通过 build_runner 生成 Schema,在 main.dart 启动时调用 init()。
|
||||
// 根据平台自动选择实现:
|
||||
// - 原生平台 (Android/iOS/Desktop) → isar_database_native.dart
|
||||
// - Web 平台 → isar_database_web.dart (空 stub)
|
||||
//
|
||||
// ⚠️ Web 平台限制:Isar 3.x 暂不支持 Web。
|
||||
// 在 Web 上跳过 Isar 初始化,使用纯内存/远程模式。
|
||||
// 生产环境以移动端 (Android/iOS) 为主。
|
||||
// 条件导出逻辑:
|
||||
// dart.library.io 存在 → 原生平台,使用 native 实现
|
||||
// 否则(Web)→ 使用 web stub
|
||||
//
|
||||
// 使用方式不变:import 'isar_database.dart';
|
||||
// 用 IsarDatabase.isAvailable 判断平台可用性。
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'collections/journal_entry_collection.dart';
|
||||
import 'collections/journal_element_collection.dart';
|
||||
import 'collections/pending_operation_collection.dart';
|
||||
|
||||
/// Isar 数据库单例管理
|
||||
class IsarDatabase {
|
||||
IsarDatabase._();
|
||||
|
||||
static Isar? _instance;
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 所有 Collection Schema(由 build_runner 生成)
|
||||
static final List<CollectionSchema<dynamic>> schemas = [
|
||||
JournalEntryCollectionSchema,
|
||||
JournalElementCollectionSchema,
|
||||
PendingOperationCollectionSchema,
|
||||
];
|
||||
|
||||
/// 是否已初始化
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// Web 平台上 Isar 不可用,使用纯远程模式
|
||||
static bool get isAvailable => !kIsWeb;
|
||||
|
||||
/// 初始化数据库
|
||||
///
|
||||
/// 在 main() 中调用,open 之前需确保 WidgetsFlutterBinding 已初始化。
|
||||
/// Web 平台跳过 Isar 初始化(3.x 不支持 Web),仅使用远程 API。
|
||||
static Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
// Web 平台:Isar 3.x 不支持 Web,跳过本地数据库初始化
|
||||
if (kIsWeb) {
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 桌面/移动端:使用文件系统
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_instance = await Isar.open(
|
||||
schemas,
|
||||
directory: dir.path,
|
||||
inspector: true, // 开发模式,发布时关闭
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// 获取 Isar 实例(必须先调用 [init])
|
||||
///
|
||||
/// Web 平台不可用时返回 null,调用方需检查 [isAvailable]。
|
||||
static Isar? get instance {
|
||||
if (kIsWeb) return null;
|
||||
if (_instance == null || !_instance!.isOpen) {
|
||||
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 关闭数据库连接
|
||||
static Future<void> close() async {
|
||||
if (_instance != null && _instance!.isOpen) {
|
||||
await _instance!.close();
|
||||
_instance = null;
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有数据(仅用于测试)
|
||||
static Future<void> clearAll() async {
|
||||
if (_instance == null || !_instance!.isOpen) return;
|
||||
await _instance!.writeTxn(() async {
|
||||
await _instance!.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
export 'isar_database_web.dart' if (dart.library.io) 'isar_database_native.dart';
|
||||
|
||||
70
app/lib/data/local/isar_database_native.dart
Normal file
70
app/lib/data/local/isar_database_native.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
// Isar 数据库初始化 — 原生平台实现 (Android/iOS/Desktop)
|
||||
//
|
||||
// 在原生平台上使用 Isar 3.x 本地数据库。
|
||||
// Web 平台使用 isar_database_web.dart stub。
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'collections/journal_entry_collection.dart';
|
||||
import 'collections/journal_element_collection.dart';
|
||||
import 'collections/pending_operation_collection.dart';
|
||||
|
||||
/// Isar 数据库单例管理(原生平台实现)
|
||||
class IsarDatabase {
|
||||
IsarDatabase._();
|
||||
|
||||
static Isar? _instance;
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 所有 Collection Schema(由 build_runner 生成)
|
||||
static final List<CollectionSchema<dynamic>> schemas = [
|
||||
JournalEntryCollectionSchema,
|
||||
JournalElementCollectionSchema,
|
||||
PendingOperationCollectionSchema,
|
||||
];
|
||||
|
||||
/// 是否已初始化
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// 原生平台 Isar 可用
|
||||
static bool get isAvailable => true;
|
||||
|
||||
/// 初始化数据库
|
||||
static Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_instance = await Isar.open(
|
||||
schemas,
|
||||
directory: dir.path,
|
||||
inspector: true, // 开发模式,发布时关闭
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// 获取 Isar 实例(必须先调用 [init])
|
||||
static Isar get instance {
|
||||
if (_instance == null || !_instance!.isOpen) {
|
||||
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 关闭数据库连接
|
||||
static Future<void> close() async {
|
||||
if (_instance != null && _instance!.isOpen) {
|
||||
await _instance!.close();
|
||||
_instance = null;
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有数据(仅用于测试)
|
||||
static Future<void> clearAll() async {
|
||||
if (_instance == null || !_instance!.isOpen) return;
|
||||
await _instance!.writeTxn(() async {
|
||||
await _instance!.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
31
app/lib/data/local/isar_database_web.dart
Normal file
31
app/lib/data/local/isar_database_web.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
// Isar 数据库初始化 — Web 平台 stub
|
||||
//
|
||||
// Isar 3.x 不支持 Web,此文件提供空实现。
|
||||
// 原生平台使用 isar_database_native.dart。
|
||||
|
||||
/// Isar 数据库单例管理(Web 平台空实现)
|
||||
class IsarDatabase {
|
||||
IsarDatabase._();
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 是否已初始化
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// Web 平台 Isar 不可用
|
||||
static bool get isAvailable => false;
|
||||
|
||||
/// Web 平台:跳过初始化
|
||||
static Future<void> init() async {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Web 平台:返回 null
|
||||
static Type? get instance => null;
|
||||
|
||||
/// Web 平台:无操作
|
||||
static Future<void> close() async {}
|
||||
|
||||
/// Web 平台:无操作
|
||||
static Future<void> clearAll() async {}
|
||||
}
|
||||
18
app/lib/data/local/secure_token_store.dart
Normal file
18
app/lib/data/local/secure_token_store.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
// 安全令牌存储接口 — 平台条件导出
|
||||
//
|
||||
// 原生平台使用 flutter_secure_storage(加密存储,PIPL 合规)
|
||||
// Web 平台使用 shared_preferences(浏览器本地存储)
|
||||
//
|
||||
// 统一接口:read / write / delete
|
||||
|
||||
/// 安全令牌存储接口
|
||||
abstract class SecureTokenStore {
|
||||
/// 读取值
|
||||
Future<String?> read(String key);
|
||||
|
||||
/// 写入值
|
||||
Future<void> write(String key, String value);
|
||||
|
||||
/// 删除值
|
||||
Future<void> delete(String key);
|
||||
}
|
||||
19
app/lib/data/local/secure_token_store_factory.dart
Normal file
19
app/lib/data/local/secure_token_store_factory.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// 安全令牌存储 — 工厂函数
|
||||
//
|
||||
// 根据平台创建对应的 SecureTokenStore 实现。
|
||||
// 运行时判断 kIsWeb,避免 Web 编译时加载 flutter_secure_storage。
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
import 'secure_token_store.dart';
|
||||
import 'secure_token_store_web.dart';
|
||||
|
||||
/// 创建平台对应的 SecureTokenStore 实例
|
||||
///
|
||||
/// Web 平台 → WebSecureTokenStore (shared_preferences)
|
||||
/// 原生平台 → WebSecureTokenStore (shared_preferences,临时方案)
|
||||
///
|
||||
/// TODO: flutter_secure_storage 升级到 v10+ 后恢复 NativeSecureTokenStore
|
||||
SecureTokenStore createSecureTokenStore() {
|
||||
return WebSecureTokenStore();
|
||||
}
|
||||
37
app/lib/data/local/secure_token_store_native.dart
Normal file
37
app/lib/data/local/secure_token_store_native.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// 安全令牌存储 — 原生平台实现(shared_preferences)
|
||||
//
|
||||
// 临时使用 shared_preferences 替代 flutter_secure_storage。
|
||||
// flutter_secure_storage v9 的 web 插件不兼容 Flutter 3.44,
|
||||
// 待其升级到 v10+ 后恢复加密存储。
|
||||
// TODO: 恢复 flutter_secure_storage 加密存储
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'secure_token_store.dart';
|
||||
|
||||
/// 原生平台安全令牌存储(临时使用 shared_preferences)
|
||||
class NativeSecureTokenStore implements SecureTokenStore {
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<SharedPreferences> get _instance async {
|
||||
return _prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async {
|
||||
final prefs = await _instance;
|
||||
return prefs.getString(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) async {
|
||||
final prefs = await _instance;
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async {
|
||||
final prefs = await _instance;
|
||||
await prefs.remove(key);
|
||||
}
|
||||
}
|
||||
36
app/lib/data/local/secure_token_store_web.dart
Normal file
36
app/lib/data/local/secure_token_store_web.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
// 安全令牌存储 — Web 平台实现(shared_preferences)
|
||||
//
|
||||
// Web 平台上 flutter_secure_storage 不可用(dart:html 已弃用),
|
||||
// 使用 shared_preferences 作为替代。
|
||||
// 注意:Web 端存储不加密,但浏览器本身提供 HTTPS 传输安全。
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'secure_token_store.dart';
|
||||
|
||||
/// Web 平台安全令牌存储(shared_preferences)
|
||||
class WebSecureTokenStore implements SecureTokenStore {
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<SharedPreferences> get _instance async {
|
||||
return _prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async {
|
||||
final prefs = await _instance;
|
||||
return prefs.getString(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) async {
|
||||
final prefs = await _instance;
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async {
|
||||
final prefs = await _instance;
|
||||
await prefs.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
//
|
||||
// 职责:
|
||||
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
|
||||
// - 通过 flutter_secure_storage 安全持久化 JWT 令牌(PIPL 合规)
|
||||
// - 通过 SecureTokenStore 安全持久化 JWT 令牌(PIPL 合规)
|
||||
// - 为 AuthBloc 提供干净的认证数据访问接口
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../local/secure_token_store.dart';
|
||||
import '../models/auth_token.dart';
|
||||
import '../models/user.dart';
|
||||
import '../remote/api_client.dart';
|
||||
@@ -33,11 +33,11 @@ class AuthException implements Exception {
|
||||
|
||||
/// 认证仓库 — 管理用户登录状态和令牌
|
||||
///
|
||||
/// 使用 [ApiClient] 与后端通信,使用 [FlutterSecureStorage] 持久化令牌。
|
||||
/// 所有令牌操作都是加密存储,满足儿童数据 PIPL 合规要求。
|
||||
/// 使用 [ApiClient] 与后端通信,使用 [SecureTokenStore] 持久化令牌。
|
||||
/// 原生平台使用加密存储,Web 平台使用 shared_preferences。
|
||||
class AuthRepository {
|
||||
final ApiClient _apiClient;
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final SecureTokenStore _tokenStore;
|
||||
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||
|
||||
AuthToken? _currentToken;
|
||||
@@ -45,13 +45,9 @@ class AuthRepository {
|
||||
|
||||
AuthRepository({
|
||||
required ApiClient apiClient,
|
||||
FlutterSecureStorage? secureStorage,
|
||||
required SecureTokenStore tokenStore,
|
||||
}) : _apiClient = apiClient,
|
||||
_secureStorage = secureStorage ??
|
||||
const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
_tokenStore = tokenStore;
|
||||
|
||||
/// 当前用户(可能为 null)
|
||||
User? get currentUser => _currentUser;
|
||||
@@ -167,10 +163,10 @@ class AuthRepository {
|
||||
_logger.d('恢复认证状态');
|
||||
|
||||
try {
|
||||
final accessToken = await _secureStorage.read(key: _keyAccessToken);
|
||||
final refreshTokenStr = await _secureStorage.read(key: _keyRefreshToken);
|
||||
final expiresAtStr = await _secureStorage.read(key: _keyExpiresAt);
|
||||
final userJsonStr = await _secureStorage.read(key: _keyUserJson);
|
||||
final accessToken = await _tokenStore.read(_keyAccessToken);
|
||||
final refreshTokenStr = await _tokenStore.read(_keyRefreshToken);
|
||||
final expiresAtStr = await _tokenStore.read(_keyExpiresAt);
|
||||
final userJsonStr = await _tokenStore.read(_keyUserJson);
|
||||
|
||||
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
|
||||
_logger.d('无存储的认证信息');
|
||||
@@ -238,27 +234,27 @@ class AuthRepository {
|
||||
_currentToken = token;
|
||||
_currentUser = user;
|
||||
await _saveToken(token);
|
||||
await _secureStorage.write(
|
||||
key: _keyUserJson,
|
||||
value: jsonEncode(user.toJson()),
|
||||
await _tokenStore.write(
|
||||
_keyUserJson,
|
||||
jsonEncode(user.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 仅保存令牌到安全存储
|
||||
Future<void> _saveToken(AuthToken token) async {
|
||||
_currentToken = token;
|
||||
await _secureStorage.write(key: _keyAccessToken, value: token.accessToken);
|
||||
await _secureStorage.write(key: _keyRefreshToken, value: token.refreshToken);
|
||||
await _secureStorage.write(key: _keyExpiresAt, value: token.expiresAt.toIso8601String());
|
||||
await _tokenStore.write(_keyAccessToken, token.accessToken);
|
||||
await _tokenStore.write(_keyRefreshToken, token.refreshToken);
|
||||
await _tokenStore.write(_keyExpiresAt, token.expiresAt.toIso8601String());
|
||||
}
|
||||
|
||||
/// 清除所有认证数据
|
||||
Future<void> _clearAuth() async {
|
||||
_currentToken = null;
|
||||
_currentUser = null;
|
||||
await _secureStorage.delete(key: _keyAccessToken);
|
||||
await _secureStorage.delete(key: _keyRefreshToken);
|
||||
await _secureStorage.delete(key: _keyExpiresAt);
|
||||
await _secureStorage.delete(key: _keyUserJson);
|
||||
await _tokenStore.delete(_keyAccessToken);
|
||||
await _tokenStore.delete(_keyRefreshToken);
|
||||
await _tokenStore.delete(_keyExpiresAt);
|
||||
await _tokenStore.delete(_keyUserJson);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,362 +1,7 @@
|
||||
// Isar 本地日记仓库 — 本地优先数据存储
|
||||
// Isar 本地日记仓库 — 条件导出
|
||||
//
|
||||
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
|
||||
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
|
||||
//
|
||||
// 转换层:
|
||||
// - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection)
|
||||
// - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection)
|
||||
// 根据平台选择实现:
|
||||
// - 原生平台 → isar_journal_repository_native.dart(Isar 本地数据库)
|
||||
// - Web 平台 → isar_journal_repository_web.dart(空 stub,应使用 RemoteJournalRepository)
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database.dart';
|
||||
import '../local/collections/journal_entry_collection.dart';
|
||||
import '../local/collections/journal_element_collection.dart';
|
||||
import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
import 'journal_repository.dart';
|
||||
|
||||
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
|
||||
class IsarJournalRepository implements JournalRepository {
|
||||
Isar get _isar => IsarDatabase.instance!;
|
||||
|
||||
// ============================================================
|
||||
// 日记 CRUD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
String? classId,
|
||||
}) async {
|
||||
var query = _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.isDeletedEqualTo(false);
|
||||
|
||||
// 日期范围过滤
|
||||
if (dateFrom != null) {
|
||||
query = query.and().dateEpochGreaterThan(dateFrom.millisecondsSinceEpoch);
|
||||
}
|
||||
if (dateTo != null) {
|
||||
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
// 心情过滤
|
||||
if (mood != null) {
|
||||
query = query.and().moodEqualTo(mood);
|
||||
}
|
||||
|
||||
// 标签过滤:Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
|
||||
if (tag != null) {
|
||||
query = query.and().tagsJsonContains(tag);
|
||||
}
|
||||
|
||||
// 班级过滤
|
||||
if (classId != null) {
|
||||
query = query.and().classIdEqualTo(classId);
|
||||
}
|
||||
|
||||
// 按日期降序排列
|
||||
var results = await query
|
||||
.sortByDateEpochDesc()
|
||||
.findAll();
|
||||
|
||||
// 分页
|
||||
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);
|
||||
}
|
||||
|
||||
return results.map(_fromCollection).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getJournalCount() async {
|
||||
return _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.isDeletedEqualTo(false)
|
||||
.count();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry?> getJournal(String id) async {
|
||||
final col = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
if (col == null) return null;
|
||||
return _fromCollection(col);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||
final col = _toEntryCollection(entry);
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
return entry;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||||
final existing = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(entry.id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
|
||||
if (existing == null) {
|
||||
throw StateError('日记不存在: ${entry.id}');
|
||||
}
|
||||
|
||||
// 乐观锁冲突检测
|
||||
if (existing.version != entry.version) {
|
||||
throw StateError(
|
||||
'版本冲突: 本地版本 ${entry.version}, 存储版本 ${existing.version}',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = entry.copyWith(
|
||||
version: entry.version + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final col = _toEntryCollection(updated);
|
||||
col.isarId = existing.isarId; // 保留 Isar 主键
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteJournal(String id) async {
|
||||
final existing = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(id)
|
||||
.findFirst();
|
||||
if (existing == null) return;
|
||||
|
||||
// 软删除日记
|
||||
existing.isDeleted = true;
|
||||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// 软删除关联元素
|
||||
final elements = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findAll();
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(existing);
|
||||
for (final el in elements) {
|
||||
el.isDeleted = true;
|
||||
el.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
await _isar.journalElementCollections.put(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 元素 CRUD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Future<List<JournalElement>> getElements(String journalId) async {
|
||||
final results = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(journalId)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.sortByZIndex()
|
||||
.findAll();
|
||||
return results.map(_fromElementCollection).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalElement> addElement(JournalElement element) async {
|
||||
final col = _toElementCollection(element);
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(col);
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalElement> updateElement(JournalElement element) async {
|
||||
final existing = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(element.id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
|
||||
if (existing == null) {
|
||||
throw StateError('元素不存在: ${element.id}');
|
||||
}
|
||||
|
||||
// 乐观锁冲突检测
|
||||
if (existing.version != element.version) {
|
||||
throw StateError(
|
||||
'版本冲突: 本地版本 ${element.version}, 存储版本 ${existing.version}',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = element.copyWith(
|
||||
version: element.version + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final col = _toElementCollection(updated);
|
||||
col.isarId = existing.isarId;
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(col);
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeElement(String elementId) async {
|
||||
final existing = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(elementId)
|
||||
.findFirst();
|
||||
if (existing == null) return;
|
||||
|
||||
// 软删除
|
||||
existing.isDeleted = true;
|
||||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(existing);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数:JournalEntry ↔ JournalEntryCollection
|
||||
// ============================================================
|
||||
|
||||
/// JournalEntry → JournalEntryCollection
|
||||
JournalEntryCollection _toEntryCollection(JournalEntry entry) {
|
||||
return JournalEntryCollection()
|
||||
..id = entry.id
|
||||
..authorId = entry.authorId
|
||||
..classId = entry.classId
|
||||
..title = entry.title
|
||||
..dateEpoch = entry.date.millisecondsSinceEpoch
|
||||
..mood = entry.mood.value
|
||||
..weather = entry.weather.value
|
||||
..tagsJson = jsonEncode(entry.tags)
|
||||
..isPrivate = entry.isPrivate
|
||||
..sharedToClass = entry.sharedToClass
|
||||
..assignedTopicId = entry.assignedTopicId
|
||||
..contentExcerpt = entry.contentExcerpt
|
||||
..version = entry.version
|
||||
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
|
||||
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
|
||||
..isDeleted = false;
|
||||
}
|
||||
|
||||
/// JournalEntryCollection → JournalEntry
|
||||
JournalEntry _fromCollection(JournalEntryCollection col) {
|
||||
return JournalEntry(
|
||||
id: col.id,
|
||||
authorId: col.authorId,
|
||||
classId: col.classId,
|
||||
title: col.title,
|
||||
date: DateTime.fromMillisecondsSinceEpoch(col.dateEpoch),
|
||||
mood: Mood.values.firstWhere(
|
||||
(m) => m.value == col.mood,
|
||||
orElse: () => Mood.calm,
|
||||
),
|
||||
weather: Weather.values.firstWhere(
|
||||
(w) => w.value == col.weather,
|
||||
orElse: () => Weather.sunny,
|
||||
),
|
||||
tags: List<String>.from(
|
||||
jsonDecode(col.tagsJson) as List? ?? [],
|
||||
),
|
||||
isPrivate: col.isPrivate,
|
||||
sharedToClass: col.sharedToClass,
|
||||
assignedTopicId: col.assignedTopicId,
|
||||
contentExcerpt: col.contentExcerpt,
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数:JournalElement ↔ JournalElementCollection
|
||||
// ============================================================
|
||||
|
||||
/// JournalElement → JournalElementCollection
|
||||
JournalElementCollection _toElementCollection(JournalElement element) {
|
||||
return JournalElementCollection()
|
||||
..id = element.id
|
||||
..journalId = element.journalId
|
||||
..elementType = element.elementType.value
|
||||
..positionX = element.positionX
|
||||
..positionY = element.positionY
|
||||
..width = element.width
|
||||
..height = element.height
|
||||
..rotation = element.rotation
|
||||
..zIndex = element.zIndex
|
||||
..contentJson = jsonEncode(element.content)
|
||||
..version = element.version
|
||||
..createdAtEpoch = element.createdAt.millisecondsSinceEpoch
|
||||
..updatedAtEpoch = element.updatedAt.millisecondsSinceEpoch
|
||||
..isDeleted = false;
|
||||
}
|
||||
|
||||
/// JournalElementCollection → JournalElement
|
||||
JournalElement _fromElementCollection(JournalElementCollection col) {
|
||||
return JournalElement(
|
||||
id: col.id,
|
||||
journalId: col.journalId,
|
||||
elementType: ElementType.values.firstWhere(
|
||||
(e) => e.value == col.elementType,
|
||||
orElse: () => ElementType.text,
|
||||
),
|
||||
positionX: col.positionX,
|
||||
positionY: col.positionY,
|
||||
width: col.width,
|
||||
height: col.height,
|
||||
rotation: col.rotation,
|
||||
zIndex: col.zIndex,
|
||||
content: Map<String, dynamic>.from(
|
||||
jsonDecode(col.contentJson) as Map? ?? {},
|
||||
),
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||
);
|
||||
}
|
||||
}
|
||||
export 'isar_journal_repository_web.dart' if (dart.library.io) 'isar_journal_repository_native.dart';
|
||||
|
||||
362
app/lib/data/repositories/isar_journal_repository_native.dart
Normal file
362
app/lib/data/repositories/isar_journal_repository_native.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
// Isar 本地日记仓库 — 本地优先数据存储
|
||||
//
|
||||
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
|
||||
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
|
||||
//
|
||||
// 转换层:
|
||||
// - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection)
|
||||
// - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection)
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database.dart';
|
||||
import '../local/collections/journal_entry_collection.dart';
|
||||
import '../local/collections/journal_element_collection.dart';
|
||||
import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
import 'journal_repository.dart';
|
||||
|
||||
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
|
||||
class IsarJournalRepository implements JournalRepository {
|
||||
Isar get _isar => IsarDatabase.instance!;
|
||||
|
||||
// ============================================================
|
||||
// 日记 CRUD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
String? classId,
|
||||
}) async {
|
||||
var query = _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.isDeletedEqualTo(false);
|
||||
|
||||
// 日期范围过滤
|
||||
if (dateFrom != null) {
|
||||
query = query.and().dateEpochGreaterThan(dateFrom.millisecondsSinceEpoch);
|
||||
}
|
||||
if (dateTo != null) {
|
||||
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
// 心情过滤
|
||||
if (mood != null) {
|
||||
query = query.and().moodEqualTo(mood);
|
||||
}
|
||||
|
||||
// 标签过滤:Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
|
||||
if (tag != null) {
|
||||
query = query.and().tagsJsonContains(tag);
|
||||
}
|
||||
|
||||
// 班级过滤
|
||||
if (classId != null) {
|
||||
query = query.and().classIdEqualTo(classId);
|
||||
}
|
||||
|
||||
// 按日期降序排列
|
||||
var results = await query
|
||||
.sortByDateEpochDesc()
|
||||
.findAll();
|
||||
|
||||
// 分页
|
||||
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);
|
||||
}
|
||||
|
||||
return results.map(_fromCollection).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getJournalCount() async {
|
||||
return _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.isDeletedEqualTo(false)
|
||||
.count();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry?> getJournal(String id) async {
|
||||
final col = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
if (col == null) return null;
|
||||
return _fromCollection(col);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||
final col = _toEntryCollection(entry);
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
return entry;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||||
final existing = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(entry.id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
|
||||
if (existing == null) {
|
||||
throw StateError('日记不存在: ${entry.id}');
|
||||
}
|
||||
|
||||
// 乐观锁冲突检测
|
||||
if (existing.version != entry.version) {
|
||||
throw StateError(
|
||||
'版本冲突: 本地版本 ${entry.version}, 存储版本 ${existing.version}',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = entry.copyWith(
|
||||
version: entry.version + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final col = _toEntryCollection(updated);
|
||||
col.isarId = existing.isarId; // 保留 Isar 主键
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteJournal(String id) async {
|
||||
final existing = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(id)
|
||||
.findFirst();
|
||||
if (existing == null) return;
|
||||
|
||||
// 软删除日记
|
||||
existing.isDeleted = true;
|
||||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// 软删除关联元素
|
||||
final elements = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findAll();
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(existing);
|
||||
for (final el in elements) {
|
||||
el.isDeleted = true;
|
||||
el.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
await _isar.journalElementCollections.put(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 元素 CRUD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Future<List<JournalElement>> getElements(String journalId) async {
|
||||
final results = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(journalId)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.sortByZIndex()
|
||||
.findAll();
|
||||
return results.map(_fromElementCollection).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalElement> addElement(JournalElement element) async {
|
||||
final col = _toElementCollection(element);
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(col);
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalElement> updateElement(JournalElement element) async {
|
||||
final existing = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(element.id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
|
||||
if (existing == null) {
|
||||
throw StateError('元素不存在: ${element.id}');
|
||||
}
|
||||
|
||||
// 乐观锁冲突检测
|
||||
if (existing.version != element.version) {
|
||||
throw StateError(
|
||||
'版本冲突: 本地版本 ${element.version}, 存储版本 ${existing.version}',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = element.copyWith(
|
||||
version: element.version + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final col = _toElementCollection(updated);
|
||||
col.isarId = existing.isarId;
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(col);
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeElement(String elementId) async {
|
||||
final existing = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(elementId)
|
||||
.findFirst();
|
||||
if (existing == null) return;
|
||||
|
||||
// 软删除
|
||||
existing.isDeleted = true;
|
||||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(existing);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数:JournalEntry ↔ JournalEntryCollection
|
||||
// ============================================================
|
||||
|
||||
/// JournalEntry → JournalEntryCollection
|
||||
JournalEntryCollection _toEntryCollection(JournalEntry entry) {
|
||||
return JournalEntryCollection()
|
||||
..id = entry.id
|
||||
..authorId = entry.authorId
|
||||
..classId = entry.classId
|
||||
..title = entry.title
|
||||
..dateEpoch = entry.date.millisecondsSinceEpoch
|
||||
..mood = entry.mood.value
|
||||
..weather = entry.weather.value
|
||||
..tagsJson = jsonEncode(entry.tags)
|
||||
..isPrivate = entry.isPrivate
|
||||
..sharedToClass = entry.sharedToClass
|
||||
..assignedTopicId = entry.assignedTopicId
|
||||
..contentExcerpt = entry.contentExcerpt
|
||||
..version = entry.version
|
||||
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
|
||||
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
|
||||
..isDeleted = false;
|
||||
}
|
||||
|
||||
/// JournalEntryCollection → JournalEntry
|
||||
JournalEntry _fromCollection(JournalEntryCollection col) {
|
||||
return JournalEntry(
|
||||
id: col.id,
|
||||
authorId: col.authorId,
|
||||
classId: col.classId,
|
||||
title: col.title,
|
||||
date: DateTime.fromMillisecondsSinceEpoch(col.dateEpoch),
|
||||
mood: Mood.values.firstWhere(
|
||||
(m) => m.value == col.mood,
|
||||
orElse: () => Mood.calm,
|
||||
),
|
||||
weather: Weather.values.firstWhere(
|
||||
(w) => w.value == col.weather,
|
||||
orElse: () => Weather.sunny,
|
||||
),
|
||||
tags: List<String>.from(
|
||||
jsonDecode(col.tagsJson) as List? ?? [],
|
||||
),
|
||||
isPrivate: col.isPrivate,
|
||||
sharedToClass: col.sharedToClass,
|
||||
assignedTopicId: col.assignedTopicId,
|
||||
contentExcerpt: col.contentExcerpt,
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数:JournalElement ↔ JournalElementCollection
|
||||
// ============================================================
|
||||
|
||||
/// JournalElement → JournalElementCollection
|
||||
JournalElementCollection _toElementCollection(JournalElement element) {
|
||||
return JournalElementCollection()
|
||||
..id = element.id
|
||||
..journalId = element.journalId
|
||||
..elementType = element.elementType.value
|
||||
..positionX = element.positionX
|
||||
..positionY = element.positionY
|
||||
..width = element.width
|
||||
..height = element.height
|
||||
..rotation = element.rotation
|
||||
..zIndex = element.zIndex
|
||||
..contentJson = jsonEncode(element.content)
|
||||
..version = element.version
|
||||
..createdAtEpoch = element.createdAt.millisecondsSinceEpoch
|
||||
..updatedAtEpoch = element.updatedAt.millisecondsSinceEpoch
|
||||
..isDeleted = false;
|
||||
}
|
||||
|
||||
/// JournalElementCollection → JournalElement
|
||||
JournalElement _fromElementCollection(JournalElementCollection col) {
|
||||
return JournalElement(
|
||||
id: col.id,
|
||||
journalId: col.journalId,
|
||||
elementType: ElementType.values.firstWhere(
|
||||
(e) => e.value == col.elementType,
|
||||
orElse: () => ElementType.text,
|
||||
),
|
||||
positionX: col.positionX,
|
||||
positionY: col.positionY,
|
||||
width: col.width,
|
||||
height: col.height,
|
||||
rotation: col.rotation,
|
||||
zIndex: col.zIndex,
|
||||
content: Map<String, dynamic>.from(
|
||||
jsonDecode(col.contentJson) as Map? ?? {},
|
||||
),
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/lib/data/repositories/isar_journal_repository_web.dart
Normal file
59
app/lib/data/repositories/isar_journal_repository_web.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
// Isar 本地日记仓库 — Web 平台 stub(不可用)
|
||||
//
|
||||
// Isar 3.x 不支持 Web,此文件提供空实现。
|
||||
// Web 平台应使用 RemoteJournalRepository。
|
||||
|
||||
import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
import 'journal_repository.dart';
|
||||
|
||||
/// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError)
|
||||
class IsarJournalRepository implements JournalRepository {
|
||||
@override
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
String? classId,
|
||||
}) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<int> getJournalCount() =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalEntry?> getJournal(String id) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalEntry> updateJournal(JournalEntry entry) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<void> deleteJournal(String id) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<List<JournalElement>> getElements(String journalId) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalElement> addElement(JournalElement element) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalElement> updateElement(JournalElement element) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<void> removeElement(String elementId) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
}
|
||||
@@ -1,365 +1,7 @@
|
||||
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
|
||||
// 同步引擎 — 条件导出
|
||||
//
|
||||
// 设计思路:
|
||||
// - 所有本地修改先入队 [PendingOperation]
|
||||
// - 网络恢复时自动批量同步
|
||||
// - 版本号冲突检测,Phase 1 使用"本地优先"策略
|
||||
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
|
||||
// - 队列持久化到 Isar,应用退出后不丢失
|
||||
//
|
||||
// Phase 1 策略:本地优先
|
||||
// - 离线时正常使用,操作入队等待
|
||||
// - 联网后自动推送待同步操作
|
||||
// - 版本冲突时本地版本覆盖远端(简单策略)
|
||||
// 根据平台选择实现:
|
||||
// - 原生平台 → sync_engine_native.dart(Isar 持久化队列)
|
||||
// - Web 平台 → sync_engine_web.dart(纯内存队列)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database.dart';
|
||||
import '../local/collections/pending_operation_collection.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;
|
||||
}
|
||||
|
||||
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// final engine = SyncEngine(apiClient: apiClient);
|
||||
///
|
||||
/// // 启动时恢复持久化队列
|
||||
/// await engine.restorePendingQueue();
|
||||
///
|
||||
/// // 本地修改后入队
|
||||
/// engine.enqueue(PendingOperation(
|
||||
/// id: 'op-1',
|
||||
/// type: SyncOperationType.create,
|
||||
/// endpoint: '/diary/entries',
|
||||
/// data: entry.toJson(),
|
||||
/// version: 1,
|
||||
/// createdAt: DateTime.now(),
|
||||
/// ));
|
||||
///
|
||||
/// // 网络恢复时触发同步
|
||||
/// await engine.trySync();
|
||||
///
|
||||
/// // 应用退出时持久化
|
||||
/// await engine.persistPendingQueue();
|
||||
/// ```
|
||||
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;
|
||||
|
||||
/// 添加待同步操作到队列尾部
|
||||
void enqueue(PendingOperation operation) {
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查网络状态并尝试同步全部待处理操作
|
||||
///
|
||||
/// 同步策略:
|
||||
/// 1. 检查网络是否可用
|
||||
/// 2. 按先进先出顺序处理队列
|
||||
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
|
||||
/// 4. 超过重试次数的操作标记为冲突,移出队列
|
||||
/// 5. 网络中断时暂停同步,保留剩余操作
|
||||
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;
|
||||
}
|
||||
|
||||
// WiFi 优先策略:仅在 WiFi 下自动同步(Phase 1 简化)
|
||||
// TODO: 添加用户设置允许蜂窝数据同步
|
||||
|
||||
_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) {
|
||||
// 超过最大重试次数,标记为冲突(Phase 1 简化:丢弃)
|
||||
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
|
||||
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 放回队列头部,下次重试
|
||||
_pendingQueue.addFirst(retried);
|
||||
_status = SyncStatus.error;
|
||||
_lastError = '同步失败: $e';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部同步完成,更新持久化
|
||||
_status = SyncStatus.idle;
|
||||
_lastError = null;
|
||||
await persistPendingQueue();
|
||||
}
|
||||
|
||||
/// 执行单个同步操作
|
||||
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;
|
||||
}
|
||||
|
||||
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
|
||||
///
|
||||
/// 应用退出时调用此方法,将待同步操作保存到 Isar,
|
||||
/// 下次启动时通过 [restorePendingQueue] 恢复。
|
||||
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||||
|
||||
// ============================================================
|
||||
// Isar 持久化
|
||||
// ============================================================
|
||||
|
||||
/// 将当前内存队列持久化到 Isar
|
||||
///
|
||||
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
|
||||
/// 在 app 退出、isolate 暂停、或同步完成后调用。
|
||||
Future<void> persistPendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance!;
|
||||
final ops = snapshot;
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
// 清空旧数据
|
||||
await isar.pendingOperationCollections.clear();
|
||||
|
||||
// 写入当前队列
|
||||
for (final op in ops) {
|
||||
final col = _operationToCollection(op);
|
||||
await isar.pendingOperationCollections.put(col);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 Isar 恢复持久化队列到内存
|
||||
///
|
||||
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
|
||||
/// Web 平台上 Isar 不可用,跳过恢复。
|
||||
Future<void> restorePendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance!;
|
||||
final persisted = await isar.pendingOperationCollections
|
||||
.where()
|
||||
.anyIsarId()
|
||||
.findAll();
|
||||
|
||||
for (final col in persisted) {
|
||||
final op = _collectionToOperation(col);
|
||||
_pendingQueue.add(op);
|
||||
}
|
||||
|
||||
if (_pendingQueue.isNotEmpty && _status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动网络监听 — 网络恢复时自动触发同步
|
||||
///
|
||||
/// 在 app.dart 中创建 SyncEngine 后调用一次。
|
||||
/// 调用 [dispose] 停止监听。
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数
|
||||
// ============================================================
|
||||
|
||||
/// PendingOperation → PendingOperationCollection
|
||||
PendingOperationCollection _operationToCollection(PendingOperation op) {
|
||||
return PendingOperationCollection()
|
||||
..id = op.id
|
||||
..operationType = op.type.httpMethod
|
||||
..endpoint = op.endpoint
|
||||
..dataJson = _encodeJson(op.data)
|
||||
..version = op.version
|
||||
..createdAtEpoch = op.createdAt.millisecondsSinceEpoch
|
||||
..retryCount = op.retryCount;
|
||||
}
|
||||
|
||||
/// PendingOperationCollection → PendingOperation
|
||||
PendingOperation _collectionToOperation(PendingOperationCollection col) {
|
||||
return PendingOperation(
|
||||
id: col.id,
|
||||
type: SyncOperationType.values.firstWhere(
|
||||
(t) => t.httpMethod == col.operationType,
|
||||
orElse: () => SyncOperationType.create,
|
||||
),
|
||||
endpoint: col.endpoint,
|
||||
data: _decodeJson(col.dataJson),
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
retryCount: col.retryCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 安全编码 JSON
|
||||
String _encodeJson(Map<String, dynamic> data) {
|
||||
try {
|
||||
return jsonEncode(data);
|
||||
} catch (_) {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解码 JSON
|
||||
Map<String, dynamic> _decodeJson(String json) {
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
export 'sync_engine_web.dart' if (dart.library.io) 'sync_engine_native.dart';
|
||||
|
||||
365
app/lib/data/services/sync_engine_native.dart
Normal file
365
app/lib/data/services/sync_engine_native.dart
Normal file
@@ -0,0 +1,365 @@
|
||||
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
|
||||
//
|
||||
// 设计思路:
|
||||
// - 所有本地修改先入队 [PendingOperation]
|
||||
// - 网络恢复时自动批量同步
|
||||
// - 版本号冲突检测,Phase 1 使用"本地优先"策略
|
||||
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
|
||||
// - 队列持久化到 Isar,应用退出后不丢失
|
||||
//
|
||||
// Phase 1 策略:本地优先
|
||||
// - 离线时正常使用,操作入队等待
|
||||
// - 联网后自动推送待同步操作
|
||||
// - 版本冲突时本地版本覆盖远端(简单策略)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database.dart';
|
||||
import '../local/collections/pending_operation_collection.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;
|
||||
}
|
||||
|
||||
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// final engine = SyncEngine(apiClient: apiClient);
|
||||
///
|
||||
/// // 启动时恢复持久化队列
|
||||
/// await engine.restorePendingQueue();
|
||||
///
|
||||
/// // 本地修改后入队
|
||||
/// engine.enqueue(PendingOperation(
|
||||
/// id: 'op-1',
|
||||
/// type: SyncOperationType.create,
|
||||
/// endpoint: '/diary/entries',
|
||||
/// data: entry.toJson(),
|
||||
/// version: 1,
|
||||
/// createdAt: DateTime.now(),
|
||||
/// ));
|
||||
///
|
||||
/// // 网络恢复时触发同步
|
||||
/// await engine.trySync();
|
||||
///
|
||||
/// // 应用退出时持久化
|
||||
/// await engine.persistPendingQueue();
|
||||
/// ```
|
||||
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;
|
||||
|
||||
/// 添加待同步操作到队列尾部
|
||||
void enqueue(PendingOperation operation) {
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查网络状态并尝试同步全部待处理操作
|
||||
///
|
||||
/// 同步策略:
|
||||
/// 1. 检查网络是否可用
|
||||
/// 2. 按先进先出顺序处理队列
|
||||
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
|
||||
/// 4. 超过重试次数的操作标记为冲突,移出队列
|
||||
/// 5. 网络中断时暂停同步,保留剩余操作
|
||||
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;
|
||||
}
|
||||
|
||||
// WiFi 优先策略:仅在 WiFi 下自动同步(Phase 1 简化)
|
||||
// TODO: 添加用户设置允许蜂窝数据同步
|
||||
|
||||
_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) {
|
||||
// 超过最大重试次数,标记为冲突(Phase 1 简化:丢弃)
|
||||
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
|
||||
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 放回队列头部,下次重试
|
||||
_pendingQueue.addFirst(retried);
|
||||
_status = SyncStatus.error;
|
||||
_lastError = '同步失败: $e';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部同步完成,更新持久化
|
||||
_status = SyncStatus.idle;
|
||||
_lastError = null;
|
||||
await persistPendingQueue();
|
||||
}
|
||||
|
||||
/// 执行单个同步操作
|
||||
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;
|
||||
}
|
||||
|
||||
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
|
||||
///
|
||||
/// 应用退出时调用此方法,将待同步操作保存到 Isar,
|
||||
/// 下次启动时通过 [restorePendingQueue] 恢复。
|
||||
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||||
|
||||
// ============================================================
|
||||
// Isar 持久化
|
||||
// ============================================================
|
||||
|
||||
/// 将当前内存队列持久化到 Isar
|
||||
///
|
||||
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
|
||||
/// 在 app 退出、isolate 暂停、或同步完成后调用。
|
||||
Future<void> persistPendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance!;
|
||||
final ops = snapshot;
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
// 清空旧数据
|
||||
await isar.pendingOperationCollections.clear();
|
||||
|
||||
// 写入当前队列
|
||||
for (final op in ops) {
|
||||
final col = _operationToCollection(op);
|
||||
await isar.pendingOperationCollections.put(col);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 Isar 恢复持久化队列到内存
|
||||
///
|
||||
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
|
||||
/// Web 平台上 Isar 不可用,跳过恢复。
|
||||
Future<void> restorePendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance!;
|
||||
final persisted = await isar.pendingOperationCollections
|
||||
.where()
|
||||
.anyIsarId()
|
||||
.findAll();
|
||||
|
||||
for (final col in persisted) {
|
||||
final op = _collectionToOperation(col);
|
||||
_pendingQueue.add(op);
|
||||
}
|
||||
|
||||
if (_pendingQueue.isNotEmpty && _status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动网络监听 — 网络恢复时自动触发同步
|
||||
///
|
||||
/// 在 app.dart 中创建 SyncEngine 后调用一次。
|
||||
/// 调用 [dispose] 停止监听。
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数
|
||||
// ============================================================
|
||||
|
||||
/// PendingOperation → PendingOperationCollection
|
||||
PendingOperationCollection _operationToCollection(PendingOperation op) {
|
||||
return PendingOperationCollection()
|
||||
..id = op.id
|
||||
..operationType = op.type.httpMethod
|
||||
..endpoint = op.endpoint
|
||||
..dataJson = _encodeJson(op.data)
|
||||
..version = op.version
|
||||
..createdAtEpoch = op.createdAt.millisecondsSinceEpoch
|
||||
..retryCount = op.retryCount;
|
||||
}
|
||||
|
||||
/// PendingOperationCollection → PendingOperation
|
||||
PendingOperation _collectionToOperation(PendingOperationCollection col) {
|
||||
return PendingOperation(
|
||||
id: col.id,
|
||||
type: SyncOperationType.values.firstWhere(
|
||||
(t) => t.httpMethod == col.operationType,
|
||||
orElse: () => SyncOperationType.create,
|
||||
),
|
||||
endpoint: col.endpoint,
|
||||
data: _decodeJson(col.dataJson),
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
retryCount: col.retryCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 安全编码 JSON
|
||||
String _encodeJson(Map<String, dynamic> data) {
|
||||
try {
|
||||
return jsonEncode(data);
|
||||
} catch (_) {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解码 JSON
|
||||
Map<String, dynamic> _decodeJson(String json) {
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
213
app/lib/data/services/sync_engine_web.dart
Normal file
213
app/lib/data/services/sync_engine_web.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
// 同步引擎 — 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;
|
||||
|
||||
void enqueue(PendingOperation operation) {
|
||||
_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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,54 +406,6 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.34"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
||||
@@ -30,8 +30,11 @@ dependencies:
|
||||
# 连接检测
|
||||
connectivity_plus: ^6.1.0
|
||||
|
||||
# 安全存储(JWT 令牌持久化,PIPL 合规)
|
||||
flutter_secure_storage: ^9.2.0
|
||||
# 安全存储(JWT 令牌持久化)
|
||||
# 注意:flutter_secure_storage v9 的 web 插件使用 dart:html,
|
||||
# 不兼容 Flutter 3.44 的 Web 编译器。暂用 shared_preferences 替代。
|
||||
# TODO: flutter_secure_storage 升级到 v10+ 后恢复
|
||||
# flutter_secure_storage: ^9.2.0
|
||||
|
||||
# 手写引擎
|
||||
perfect_freehand: ^1.0.0
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
@@ -19,8 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
IsarFlutterLibsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
isar_flutter_libs
|
||||
permission_handler_windows
|
||||
share_plus
|
||||
|
||||
Reference in New Issue
Block a user