fix(app): Web 平台兼容性修复 + 字体资源 + API base URL

- 添加 Web/Windows 平台支持 (flutter create --platforms)
- 下载字体资源 (NotoSansSC/Caveat Regular+Bold)
- Isar 3.x Web 不兼容:添加 kIsWeb 守卫,Web 上跳过 Isar 初始化
- IsarJournalRepository: instance 返回 nullable,Web 上使用 RemoteJournalRepository
- SyncEngine: persistPendingQueue/restorePendingQueue Web 安全
- SettingsBloc: 从 RepositoryProvider 改为 ListenableProvider
- ApiClient base URL: 8080 → 3000 匹配后端端口
- Isar .g.dart: 64 位 ID 替换为 JS 安全范围值
This commit is contained in:
iven
2026-06-01 17:30:27 +08:00
parent d1a07229e2
commit 33dc5e19e4
39 changed files with 1315 additions and 1160 deletions

View File

@@ -10,9 +10,11 @@
// └─ BlocProvider<AuthBloc>
// └─ MaterialApp.router
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart' show ListenableProvider;
import 'core/theme/app_theme.dart';
import 'core/routing/app_router.dart';
@@ -36,7 +38,10 @@ class NuanjiApp extends StatelessWidget {
final apiClient = ApiClient();
final authRepository = AuthRepository(apiClient: apiClient);
// 离线优先Isar 为主要本地仓库Remote 供 SyncEngine 推送
final journalRepository = IsarJournalRepository();
// Web 平台Isar 3.x 不支持 Web直接使用远程仓库
final journalRepository = kIsWeb
? RemoteJournalRepository(api: apiClient)
: IsarJournalRepository();
final remoteJournalRepository = RemoteJournalRepository(api: apiClient);
final syncEngine = SyncEngine(apiClient: apiClient);
final classRepository = ClassRepository(api: apiClient);
@@ -67,18 +72,22 @@ class NuanjiApp extends StatelessWidget {
RepositoryProvider<RemoteJournalRepository>.value(value: remoteJournalRepository),
RepositoryProvider<SyncEngine>.value(value: syncEngine),
RepositoryProvider<ClassRepository>.value(value: classRepository),
RepositoryProvider<SettingsBloc>.value(value: settingsBloc),
],
child: BlocProvider<AuthBloc>.value(
value: authBloc,
child: ListenableBuilder(
listenable: settingsBloc,
builder: (context, _) => _AppView(
router: createAppRouter(authBloc),
themeMode: settingsBloc.state.themeMode,
child: ListenableProvider<SettingsBloc>.value(
value: settingsBloc,
child: Builder(
builder: (context) {
final settings = context.watch<SettingsBloc>();
return BlocProvider<AuthBloc>.value(
value: authBloc,
child: _AppView(
router: createAppRouter(authBloc),
themeMode: settings.state.themeMode,
),
);
},
),
),
),
);
}
}

View File

@@ -16,7 +16,7 @@ extension GetJournalElementCollectionCollection on Isar {
const JournalElementCollectionSchema = CollectionSchema(
name: r'JournalElementCollection',
id: -3625932583395690305,
id: 5678901234567001,
properties: {
r'contentJson': PropertySchema(
id: 0,
@@ -96,7 +96,7 @@ const JournalElementCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: -3268401673993471357,
id: 5678901234567002,
name: r'id',
unique: false,
replace: false,
@@ -109,7 +109,7 @@ const JournalElementCollectionSchema = CollectionSchema(
],
),
r'journalId': IndexSchema(
id: 1745640946427815323,
id: 5678901234567003,
name: r'journalId',
unique: false,
replace: false,

View File

@@ -16,7 +16,7 @@ extension GetJournalEntryCollectionCollection on Isar {
const JournalEntryCollectionSchema = CollectionSchema(
name: r'JournalEntryCollection',
id: -6325316395299921961,
id: 5678901234567004,
properties: {
r'assignedTopicId': PropertySchema(
id: 0,
@@ -101,7 +101,7 @@ const JournalEntryCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: -3268401673993471357,
id: 5678901234567002,
name: r'id',
unique: false,
replace: false,

View File

@@ -16,7 +16,7 @@ extension GetPendingOperationCollectionCollection on Isar {
const PendingOperationCollectionSchema = CollectionSchema(
name: r'PendingOperationCollection',
id: -6885010264946527864,
id: 5678901234567005,
properties: {
r'createdAtEpoch': PropertySchema(
id: 0,
@@ -61,7 +61,7 @@ const PendingOperationCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: -3268401673993471357,
id: 5678901234567002,
name: r'id',
unique: false,
replace: false,

View File

@@ -2,7 +2,12 @@
//
// Isar 3.x 要求 open() 时传入 List<CollectionSchema>。
// 通过 build_runner 生成 Schema在 main.dart 启动时调用 init()。
//
// ⚠️ Web 平台限制Isar 3.x 暂不支持 Web。
// 在 Web 上跳过 Isar 初始化,使用纯内存/远程模式。
// 生产环境以移动端 (Android/iOS) 为主。
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
@@ -27,25 +32,37 @@ class IsarDatabase {
/// 是否已初始化
static bool get isInitialized => _initialized;
/// Web 平台上 Isar 不可用,使用纯远程模式
static bool get isAvailable => !kIsWeb;
/// 初始化数据库
///
/// 在 main() 中调用open 之前需确保 WidgetsFlutterBinding 已初始化。
static Future<Isar> init() async {
if (_instance != null && _instance!.isOpen) return _instance!;
/// 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;
return _instance!;
}
/// 获取 Isar 实例(必须先调用 [init]
static Isar get instance {
///
/// Web 平台不可用时返回 null调用方需检查 [isAvailable]。
static Isar? get instance {
if (kIsWeb) return null;
if (_instance == null || !_instance!.isOpen) {
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
}

View File

@@ -26,7 +26,7 @@ class ApiClient {
/// 基础 URL默认指向本地开发服务器
final String baseUrl;
ApiClient({this.baseUrl = 'http://localhost:8080/api/v1'}) {
ApiClient({this.baseUrl = 'http://localhost:3000/api/v1'}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),

View File

@@ -20,7 +20,7 @@ import 'journal_repository.dart';
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
class IsarJournalRepository implements JournalRepository {
Isar get _isar => IsarDatabase.instance;
Isar get _isar => IsarDatabase.instance!;
// ============================================================
// 日记 CRUD

View File

@@ -251,7 +251,8 @@ class SyncEngine {
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
/// 在 app 退出、isolate 暂停、或同步完成后调用。
Future<void> persistPendingQueue() async {
final isar = IsarDatabase.instance;
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance!;
final ops = snapshot;
await isar.writeTxn(() async {
@@ -269,8 +270,10 @@ class SyncEngine {
/// 从 Isar 恢复持久化队列到内存
///
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
/// Web 平台上 Isar 不可用,跳过恢复。
Future<void> restorePendingQueue() async {
final isar = IsarDatabase.instance;
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance!;
final persisted = await isar.pendingOperationCollections
.where()
.anyIsarId()