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