feat(app): pnpm 一键启动 + Flutter Web 编译修复
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

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:
iven
2026-06-03 09:50:19 +08:00
parent b81a972245
commit 11d0971a67
23 changed files with 2034 additions and 888 deletions

View File

@@ -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

View File

@@ -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';

View 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();
});
}
}

View 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 {}
}

View 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);
}

View 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();
}

View 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);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -1,362 +1,7 @@
// Isar 本地日记仓库 — 本地优先数据存储
// Isar 本地日记仓库 — 条件导出
//
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
//
// 转换层:
// - JournalEntry ↔ JournalEntryCollection通过 toCollection/fromCollection
// - JournalElement ↔ JournalElementCollection通过 toCollection/fromCollection
// 根据平台选择实现:
// - 原生平台 → isar_journal_repository_native.dartIsar 本地数据库)
// - 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';

View 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),
);
}
}

View 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 平台');
}

View File

@@ -1,365 +1,7 @@
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
// 同步引擎 — 条件导出
//
// 设计思路
// - 所有本地修改先入队 [PendingOperation]
// - 网络恢复时自动批量同步
// - 版本号冲突检测Phase 1 使用"本地优先"策略
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
// - 队列持久化到 Isar应用退出后不丢失
//
// Phase 1 策略:本地优先
// - 离线时正常使用,操作入队等待
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
// 根据平台选择实现
// - 原生平台 → sync_engine_native.dartIsar 持久化队列)
// - 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';

View 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 {};
}
}
}

View 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 {};
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "nuanji",
"private": true,
"type": "module",
"scripts": {
"start:dev": "node scripts/dev.mjs",
"start:dev:backend": "node scripts/dev.mjs backend",
"start:dev:admin": "node scripts/dev.mjs admin",
"start:dev:app": "node scripts/dev.mjs app",
"start:stop": "node scripts/stop.mjs"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}

206
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,206 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
concurrently:
specifier: ^9.1.2
version: 9.2.1
packages:
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
hasBin: true
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
snapshots:
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.8.3
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
emoji-regex@8.0.0: {}
escalade@3.2.0: {}
get-caller-file@2.0.5: {}
has-flag@4.0.0: {}
is-fullwidth-code-point@3.0.0: {}
require-directory@2.1.1: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
shell-quote@1.8.3: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
tree-kill@1.2.2: {}
tslib@2.8.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1

442
scripts/dev.mjs Normal file
View File

@@ -0,0 +1,442 @@
#!/usr/bin/env node
/**
* 暖记开发环境启动脚本 — 跨平台 (Windows/macOS/Linux)
*
* 用法:
* pnpm start:dev # 启动全部 (后端+管理端+学生端)
* pnpm start:dev:backend # 只启动后端
* pnpm start:dev:admin # 只启动管理端前端
* pnpm start:dev:app # 只启动学生端 Flutter
* pnpm start:stop # 停止所有服务
*/
import { spawn, execSync } from "node:child_process";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const IS_WIN = process.platform === "win32";
// ===== 配置 =====
const PORTS = {
backend: 3000,
admin: 5174,
app: 8080,
};
const PG = {
host: "localhost",
port: 5432,
user: "postgres",
pass: "123123",
db: "nuanji",
};
// ===== 颜色输出 =====
const color = {
red: (s) => `\x1b[31m${s}\x1b[0m`,
green: (s) => `\x1b[32m${s}\x1b[0m`,
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
blue: (s) => `\x1b[34m${s}\x1b[0m`,
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
bold: (s) => `\x1b[1m${s}\x1b[0m`,
gray: (s) => `\x1b[90m${s}\x1b[0m`,
};
const log = {
info: (msg) => console.log(color.blue("[INFO]") + " " + msg),
ok: (msg) => console.log(color.green("[OK]") + " " + msg),
warn: (msg) => console.log(color.yellow("[WARN]") + " " + msg),
err: (msg) => console.log(color.red("[ERROR]") + " " + msg),
};
// ===== 工具函数 =====
/** 查找占用指定端口的进程 PID */
function findPidsOnPort(port) {
try {
if (IS_WIN) {
const out = execSync(
`netstat -ano | findstr :${port} | findstr LISTENING`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
);
return [
...new Set(
out
.trim()
.split("\n")
.map((l) => l.trim().split(/\s+/).pop())
.filter((p) => p && p !== "0")
),
];
}
// macOS / Linux
const out = execSync(`lsof -ti :${port}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return out
.trim()
.split("\n")
.filter(Boolean);
} catch {
return [];
}
}
/** 杀死指定 PID */
function killPid(pid) {
try {
if (IS_WIN) {
execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
} else {
execSync(`kill -9 ${pid}`, { stdio: "pipe" });
}
return true;
} catch {
return false;
}
}
/** 清理端口上所有进程 */
function killPort(port, name) {
const pids = findPidsOnPort(port);
if (pids.length === 0) {
log.info(`${name} 端口 ${port} 空闲`);
return;
}
for (const pid of pids) {
if (killPid(pid)) {
log.ok(`已停止 ${name} (PID: ${pid}, 端口: ${port})`);
} else {
log.warn(`无法停止 ${name} (PID: ${pid})`);
}
}
}
/** 检查端口是否已被占用 */
function isPortInUse(port) {
return findPidsOnPort(port).length > 0;
}
/** 等待端口可达 */
function waitForPort(port, name, maxAttempts = 30, intervalMs = 2000) {
return new Promise((resolve) => {
let attempts = 0;
const check = () => {
attempts++;
if (isPortInUse(port)) {
log.ok(`${name} 已就绪 → http://localhost:${port}`);
resolve(true);
return;
}
if (attempts >= maxAttempts) {
log.err(`${name} 启动超时 (等待了 ${maxAttempts * intervalMs / 1000}s)`);
resolve(false);
return;
}
setTimeout(check, intervalMs);
};
check();
});
}
// ===== 前置检查 =====
function checkPostgreSQL() {
try {
const psqlPath = IS_WIN ? "D:\\postgreSQL\\bin\\psql.exe" : "psql";
execSync(
`"${psqlPath}" -U ${PG.user} -h ${PG.host} -p ${PG.port} -d ${PG.db} -c "SELECT 1"`,
{ stdio: "pipe", env: { ...process.env, PGPASSWORD: PG.pass } }
);
log.ok(`PostgreSQL 已连接 (${PG.db}@${PG.host}:${PG.port})`);
return true;
} catch {
log.err(`无法连接 PostgreSQL (${PG.db}@${PG.host}:${PG.port})`);
log.err("请确保 PostgreSQL 已启动且数据库存在");
return false;
}
}
function checkRedis() {
try {
execSync("redis-cli ping", { stdio: "pipe" });
log.ok("Redis 已连接");
return true;
} catch {
log.err("无法连接 Redis (localhost:6379)");
return false;
}
}
function checkFlutter() {
const flutterBin = IS_WIN ? "D:\\flutter\\bin\\flutter.bat" : "flutter";
try {
execSync(`${flutterBin} --version`, { stdio: "pipe" });
log.ok("Flutter SDK 可用");
return true;
} catch {
log.err("Flutter SDK 不可用");
return false;
}
}
function runChecks(services) {
const needPg = services.includes("backend");
const needRedis = services.includes("backend");
const needFlutter = services.includes("app");
if (needPg && !checkPostgreSQL()) return false;
if (needRedis && !checkRedis()) return false;
if (needFlutter && !checkFlutter()) return false;
return true;
}
// ===== 启动服务 =====
/** 启动后端 (Rust/Axum) */
async function startBackend() {
log.info("清理旧后端进程...");
killPort(PORTS.backend, "后端");
// Windows: 额外清理 erp-server.exe
if (IS_WIN) {
try {
execSync("taskkill /F /IM erp-server.exe", { stdio: "pipe" });
} catch {
// 忽略,可能没有运行
}
}
log.info("编译并启动后端 (diary feature)...");
const env = {
...process.env,
ERP__DATABASE__URL: `postgres://${PG.user}:${PG.pass}@${PG.host}:${PG.port}/${PG.db}`,
ERP__REDIS__URL: "redis://localhost:6379",
ERP__JWT__SECRET: "nuanji-dev-jwt-secret-2024-warm-notes-hmac-key-32b",
ERP__AUTH__SUPER_ADMIN_PASSWORD: "admin123",
ERP__WECHAT__APPID: "wx_dev_placeholder",
ERP__WECHAT__SECRET: "wx_dev_secret_placeholder",
ERP__WECHAT__DEV_MODE: "true",
ERP__CRYPTO__KEK: "0000000000000000000000000000000000000000000000000000000000000000",
};
const child = spawn("cargo run -p erp-server --features diary", {
cwd: ROOT,
env,
shell: true,
stdio: "pipe",
});
child.stdout?.on("data", (data) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
if (line.trim()) console.log(color.cyan("[backend]") + " " + line);
}
});
child.stderr?.on("data", (data) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
if (line.trim()) console.log(color.cyan("[backend]") + " " + line);
}
});
child.on("error", (err) => {
log.err(`后端启动失败: ${err.message}`);
});
child.on("exit", (code) => {
if (code !== 0 && code !== null) {
log.warn(`后端进程退出 (code: ${code})`);
}
});
return waitForPort(PORTS.backend, "后端", 60, 3000);
}
/** 启动管理端前端 (React + Vite) */
async function startAdmin() {
log.info("清理旧管理端进程...");
killPort(PORTS.admin, "管理端前端");
log.info("启动管理端前端 (React + Vite)...");
const adminDir = resolve(ROOT, "apps", "web");
// 先检查依赖是否安装
if (!existsSync(resolve(adminDir, "node_modules"))) {
log.info("管理端依赖未安装,正在安装...");
execSync("pnpm install", { cwd: adminDir, stdio: "inherit" });
}
const child = spawn("pnpm dev", {
cwd: adminDir,
shell: true,
stdio: "pipe",
});
child.stdout?.on("data", (data) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
if (line.trim()) console.log(color.green("[admin]") + " " + line);
}
});
child.stderr?.on("data", (data) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
if (line.trim()) console.log(color.green("[admin]") + " " + line);
}
});
child.on("error", (err) => {
log.err(`管理端启动失败: ${err.message}`);
});
return waitForPort(PORTS.admin, "管理端", 20, 2000);
}
/** 启动学生端 Flutter Web */
async function startApp() {
log.info("清理旧学生端进程...");
killPort(PORTS.app, "学生端前端");
log.info("编译并启动 Flutter Web...");
const appDir = resolve(ROOT, "app");
const flutterBin = IS_WIN ? "D:\\flutter\\bin\\flutter.bat" : "flutter";
const child = spawn(`${flutterBin} run -d chrome --web-port=${PORTS.app}`, {
cwd: appDir,
shell: true,
stdio: "pipe",
});
child.stdout?.on("data", (data) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
if (line.trim()) console.log(color.yellow("[app]") + " " + line);
}
});
child.stderr?.on("data", (data) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
if (line.trim()) console.log(color.yellow("[app]") + " " + line);
}
});
child.on("error", (err) => {
log.err(`学生端启动失败: ${err.message}`);
});
return waitForPort(PORTS.app, "学生端", 40, 3000);
}
// ===== 主流程 =====
function printBanner(services) {
console.log();
console.log(
color.bold(color.cyan(" ╔══════════════════════════════════════════╗"))
);
console.log(
color.bold(color.cyan(" ║ 暖记 — 开发环境启动 ║"))
);
console.log(
color.bold(color.cyan(" ╚══════════════════════════════════════════╝"))
);
console.log();
}
function printSummary() {
console.log();
console.log(color.bold(color.green(" ═══ 暖记开发环境已启动 ═══")));
console.log();
console.log(` 后端 API: http://localhost:${PORTS.backend}`);
console.log(` 管理端: http://localhost:${PORTS.admin} (admin/admin123)`);
console.log(` 学生端: http://localhost:${PORTS.app}`);
console.log();
console.log(color.gray(" 停止所有服务: pnpm start:stop"));
console.log();
}
async function main() {
const arg = process.argv[2] || "all";
const services =
arg === "all"
? ["backend", "admin", "app"]
: [arg];
const validServices = ["backend", "admin", "app", "all"];
if (!validServices.includes(arg)) {
log.err(`未知参数: ${arg}`);
console.log("用法: pnpm start:dev [all|backend|admin|app]");
process.exit(1);
}
printBanner(services);
// 前置检查
log.info("检查依赖...");
if (!runChecks(services)) {
log.err("前置检查失败,请修复后重试");
process.exit(1);
}
// 按顺序启动(后端先启动,前端依赖 API
if (services.includes("backend")) {
const ok = await startBackend();
if (!ok) {
log.err("后端启动失败,终止");
process.exit(1);
}
}
// 管理端和学生端可以并行启动
const frontendPromises = [];
if (services.includes("admin")) {
frontendPromises.push(startAdmin());
}
if (services.includes("app")) {
frontendPromises.push(startApp());
}
if (frontendPromises.length > 0) {
const results = await Promise.all(frontendPromises);
const failed = results.filter((r) => !r).length;
if (failed > 0) {
log.warn(`${failed} 个前端服务启动失败`);
}
}
// 打印汇总
if (arg === "all") {
printSummary();
}
// 保持进程存活(不退出 Node让子进程继续运行
// Ctrl+C 会触发 SIGINT我们在下面处理清理
process.on("SIGINT", () => {
console.log();
log.info("收到 SIGINT正在停止所有服务...");
killPort(PORTS.backend, "后端");
killPort(PORTS.admin, "管理端前端");
killPort(PORTS.app, "学生端前端");
if (IS_WIN) {
try { execSync("taskkill /F /IM erp-server.exe", { stdio: "pipe" }); } catch {}
}
log.ok("所有服务已停止");
process.exit(0);
});
// 保持 Node 进程不退出
setInterval(() => {}, 60_000);
}
main().catch((err) => {
log.err(`启动失败: ${err.message}`);
process.exit(1);
});

111
scripts/stop.mjs Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* 暖记开发环境停止脚本 — 清理所有服务进程
*
* 用法:
* pnpm start:stop
*/
import { execSync } from "node:child_process";
const IS_WIN = process.platform === "win32";
// ===== 配置 =====
const SERVICES = [
{ port: 3000, name: "后端 (Rust/Axum)" },
{ port: 5174, name: "管理端前端 (React/Vite)" },
{ port: 8080, name: "学生端前端 (Flutter Web)" },
];
// ===== 颜色输出 =====
const log = {
info: (msg) => console.log(`\x1b[34m[INFO]\x1b[0m ${msg}`),
ok: (msg) => console.log(`\x1b[32m[OK]\x1b[0m ${msg}`),
warn: (msg) => console.log(`\x1b[33m[WARN]\x1b[0m ${msg}`),
};
// ===== 工具函数 =====
function findPidsOnPort(port) {
try {
if (IS_WIN) {
const out = execSync(
`netstat -ano | findstr :${port} | findstr LISTENING`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
);
return [
...new Set(
out
.trim()
.split("\n")
.map((l) => l.trim().split(/\s+/).pop())
.filter((p) => p && p !== "0")
),
];
}
const out = execSync(`lsof -ti :${port}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return out.trim().split("\n").filter(Boolean);
} catch {
return [];
}
}
function killPid(pid) {
try {
if (IS_WIN) {
execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
} else {
execSync(`kill -9 ${pid}`, { stdio: "pipe" });
}
return true;
} catch {
return false;
}
}
function killPort(port, name) {
const pids = findPidsOnPort(port);
if (pids.length === 0) {
log.info(`${name} — 端口 ${port} 空闲`);
return;
}
for (const pid of pids) {
if (killPid(pid)) {
log.ok(`${name} — 已停止 (PID: ${pid}, 端口: ${port})`);
} else {
log.warn(`${name} — 无法停止 PID: ${pid}`);
}
}
}
// ===== 主流程 =====
console.log();
console.log("\x1b[1m\x1b[36m ═══ 暖记 — 停止所有服务 ═══\x1b[0m");
console.log();
log.info("正在停止所有暖记服务...");
for (const { port, name } of SERVICES) {
killPort(port, name);
}
// Windows: 额外清理 erp-server.exe
if (IS_WIN) {
try {
execSync("taskkill /F /IM erp-server.exe", { stdio: "pipe" });
log.ok("已停止 erp-server.exe");
} catch {
// 没有运行中的进程,忽略
}
}
// Windows: 清理可能的残留 node 进程(仅清理 pnpm 启动的)
// 注意:不盲目杀所有 node 进程,只清理端口的即可
console.log();
log.ok("所有服务已停止");
console.log();