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:
@@ -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 平台');
|
||||
}
|
||||
Reference in New Issue
Block a user