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