fix(app): Phase 1.1 紧急修复 — SyncEngine 接入 + authorId + catch 异常处理
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

- feat(sync): SyncEngine 接入 EditorPage, 保存时 enqueue + 网络恢复自动 trySync
- fix(editor): authorId 从 AuthBloc 获取, 替代硬编码 'local'
- fix(bloc): class_bloc/calendar/profile/parent catch(_).全部改为 debugPrint
- feat(editor): 编辑器工具栏拆分 (brush_panel/tag_panel/text_format_bar/dot_grid_painter)
- feat(editor): EditorBloc 扩展 + EditorPage 增强
- feat(search): SearchBloc 扩展搜索功能
- feat(home): HomeBloc/HomePage 增强
- feat(auth): LoginPage 增强
- feat(templates): TemplateGalleryPage 重构
- fix(web): 管理端班级/日记页面修复
- fix(server): comment_service + theme_handler 修复
- docs: 添加全链路审计报告和验证截图
This commit is contained in:
iven
2026-06-02 21:21:43 +08:00
parent 7e928ae1e1
commit 49d4aa36a7
55 changed files with 2738 additions and 677 deletions

View File

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

View File

@@ -46,6 +46,9 @@ class JournalEntryCollection {
/// 关联主题 ID可选
String? assignedTopicId;
/// 内容摘要(自动从文本元素提取)
String? contentExcerpt;
/// 版本号(乐观锁)
int version = 1;

View File

@@ -16,7 +16,7 @@ extension GetJournalEntryCollectionCollection on Isar {
const JournalEntryCollectionSchema = CollectionSchema(
name: r'JournalEntryCollection',
id: 5678901234567004,
id: -1001,
properties: {
r'assignedTopicId': PropertySchema(
id: 0,
@@ -33,63 +33,68 @@ const JournalEntryCollectionSchema = CollectionSchema(
name: r'classId',
type: IsarType.string,
),
r'createdAtEpoch': PropertySchema(
r'contentExcerpt': PropertySchema(
id: 3,
name: r'contentExcerpt',
type: IsarType.string,
),
r'createdAtEpoch': PropertySchema(
id: 4,
name: r'createdAtEpoch',
type: IsarType.long,
),
r'dateEpoch': PropertySchema(
id: 4,
id: 5,
name: r'dateEpoch',
type: IsarType.long,
),
r'id': PropertySchema(
id: 5,
id: 6,
name: r'id',
type: IsarType.string,
),
r'isDeleted': PropertySchema(
id: 6,
id: 7,
name: r'isDeleted',
type: IsarType.bool,
),
r'isPrivate': PropertySchema(
id: 7,
id: 8,
name: r'isPrivate',
type: IsarType.bool,
),
r'mood': PropertySchema(
id: 8,
id: 9,
name: r'mood',
type: IsarType.string,
),
r'sharedToClass': PropertySchema(
id: 9,
id: 10,
name: r'sharedToClass',
type: IsarType.bool,
),
r'tagsJson': PropertySchema(
id: 10,
id: 11,
name: r'tagsJson',
type: IsarType.string,
),
r'title': PropertySchema(
id: 11,
id: 12,
name: r'title',
type: IsarType.string,
),
r'updatedAtEpoch': PropertySchema(
id: 12,
id: 13,
name: r'updatedAtEpoch',
type: IsarType.long,
),
r'version': PropertySchema(
id: 13,
id: 14,
name: r'version',
type: IsarType.long,
),
r'weather': PropertySchema(
id: 14,
id: 15,
name: r'weather',
type: IsarType.string,
)
@@ -101,7 +106,7 @@ const JournalEntryCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: 5678901234567002,
id: -2001,
name: r'id',
unique: false,
replace: false,
@@ -141,6 +146,12 @@ int _journalEntryCollectionEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.contentExcerpt;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
bytesCount += 3 + object.id.length * 3;
bytesCount += 3 + object.mood.length * 3;
bytesCount += 3 + object.tagsJson.length * 3;
@@ -158,18 +169,19 @@ void _journalEntryCollectionSerialize(
writer.writeString(offsets[0], object.assignedTopicId);
writer.writeString(offsets[1], object.authorId);
writer.writeString(offsets[2], object.classId);
writer.writeLong(offsets[3], object.createdAtEpoch);
writer.writeLong(offsets[4], object.dateEpoch);
writer.writeString(offsets[5], object.id);
writer.writeBool(offsets[6], object.isDeleted);
writer.writeBool(offsets[7], object.isPrivate);
writer.writeString(offsets[8], object.mood);
writer.writeBool(offsets[9], object.sharedToClass);
writer.writeString(offsets[10], object.tagsJson);
writer.writeString(offsets[11], object.title);
writer.writeLong(offsets[12], object.updatedAtEpoch);
writer.writeLong(offsets[13], object.version);
writer.writeString(offsets[14], object.weather);
writer.writeString(offsets[3], object.contentExcerpt);
writer.writeLong(offsets[4], object.createdAtEpoch);
writer.writeLong(offsets[5], object.dateEpoch);
writer.writeString(offsets[6], object.id);
writer.writeBool(offsets[7], object.isDeleted);
writer.writeBool(offsets[8], object.isPrivate);
writer.writeString(offsets[9], object.mood);
writer.writeBool(offsets[10], object.sharedToClass);
writer.writeString(offsets[11], object.tagsJson);
writer.writeString(offsets[12], object.title);
writer.writeLong(offsets[13], object.updatedAtEpoch);
writer.writeLong(offsets[14], object.version);
writer.writeString(offsets[15], object.weather);
}
JournalEntryCollection _journalEntryCollectionDeserialize(
@@ -182,19 +194,20 @@ JournalEntryCollection _journalEntryCollectionDeserialize(
object.assignedTopicId = reader.readStringOrNull(offsets[0]);
object.authorId = reader.readString(offsets[1]);
object.classId = reader.readStringOrNull(offsets[2]);
object.createdAtEpoch = reader.readLong(offsets[3]);
object.dateEpoch = reader.readLong(offsets[4]);
object.id = reader.readString(offsets[5]);
object.isDeleted = reader.readBool(offsets[6]);
object.isPrivate = reader.readBool(offsets[7]);
object.contentExcerpt = reader.readStringOrNull(offsets[3]);
object.createdAtEpoch = reader.readLong(offsets[4]);
object.dateEpoch = reader.readLong(offsets[5]);
object.id = reader.readString(offsets[6]);
object.isDeleted = reader.readBool(offsets[7]);
object.isPrivate = reader.readBool(offsets[8]);
object.isarId = id;
object.mood = reader.readString(offsets[8]);
object.sharedToClass = reader.readBool(offsets[9]);
object.tagsJson = reader.readString(offsets[10]);
object.title = reader.readString(offsets[11]);
object.updatedAtEpoch = reader.readLong(offsets[12]);
object.version = reader.readLong(offsets[13]);
object.weather = reader.readString(offsets[14]);
object.mood = reader.readString(offsets[9]);
object.sharedToClass = reader.readBool(offsets[10]);
object.tagsJson = reader.readString(offsets[11]);
object.title = reader.readString(offsets[12]);
object.updatedAtEpoch = reader.readLong(offsets[13]);
object.version = reader.readLong(offsets[14]);
object.weather = reader.readString(offsets[15]);
return object;
}
@@ -212,28 +225,30 @@ P _journalEntryCollectionDeserializeProp<P>(
case 2:
return (reader.readStringOrNull(offset)) as P;
case 3:
return (reader.readLong(offset)) as P;
return (reader.readStringOrNull(offset)) as P;
case 4:
return (reader.readLong(offset)) as P;
case 5:
return (reader.readString(offset)) as P;
return (reader.readLong(offset)) as P;
case 6:
return (reader.readBool(offset)) as P;
return (reader.readString(offset)) as P;
case 7:
return (reader.readBool(offset)) as P;
case 8:
return (reader.readString(offset)) as P;
case 9:
return (reader.readBool(offset)) as P;
case 10:
case 9:
return (reader.readString(offset)) as P;
case 10:
return (reader.readBool(offset)) as P;
case 11:
return (reader.readString(offset)) as P;
case 12:
return (reader.readLong(offset)) as P;
return (reader.readString(offset)) as P;
case 13:
return (reader.readLong(offset)) as P;
case 14:
return (reader.readLong(offset)) as P;
case 15:
return (reader.readString(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -832,6 +847,162 @@ extension JournalEntryCollectionQueryFilter on QueryBuilder<
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'contentExcerpt',
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'contentExcerpt',
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'contentExcerpt',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition>
contentExcerptContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition>
contentExcerptMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'contentExcerpt',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'contentExcerpt',
value: '',
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'contentExcerpt',
value: '',
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> createdAtEpochEqualTo(int value) {
return QueryBuilder.apply(this, (query) {
@@ -1883,6 +2054,20 @@ extension JournalEntryCollectionQuerySortBy
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
sortByContentExcerpt() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'contentExcerpt', Sort.asc);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
sortByContentExcerptDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'contentExcerpt', Sort.desc);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
sortByCreatedAtEpoch() {
return QueryBuilder.apply(this, (query) {
@@ -2096,6 +2281,20 @@ extension JournalEntryCollectionQuerySortThenBy on QueryBuilder<
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
thenByContentExcerpt() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'contentExcerpt', Sort.asc);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
thenByContentExcerptDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'contentExcerpt', Sort.desc);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
thenByCreatedAtEpoch() {
return QueryBuilder.apply(this, (query) {
@@ -2303,6 +2502,14 @@ extension JournalEntryCollectionQueryWhereDistinct
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QDistinct>
distinctByContentExcerpt({bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'contentExcerpt',
caseSensitive: caseSensitive);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QDistinct>
distinctByCreatedAtEpoch() {
return QueryBuilder.apply(this, (query) {
@@ -2417,6 +2624,13 @@ extension JournalEntryCollectionQueryProperty on QueryBuilder<
});
}
QueryBuilder<JournalEntryCollection, String?, QQueryOperations>
contentExcerptProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'contentExcerpt');
});
}
QueryBuilder<JournalEntryCollection, int, QQueryOperations>
createdAtEpochProperty() {
return QueryBuilder.apply(this, (query) {

View File

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

View File

@@ -42,6 +42,10 @@ class JournalEntry {
final bool isPrivate;
final bool sharedToClass;
final String? assignedTopicId;
/// 内容摘要 — 自动从文本元素提取,用于列表预览
final String? contentExcerpt;
final int version;
final DateTime createdAt;
final DateTime updatedAt;
@@ -58,6 +62,7 @@ class JournalEntry {
this.isPrivate = true,
this.sharedToClass = false,
this.assignedTopicId,
this.contentExcerpt,
this.version = 1,
required this.createdAt,
required this.updatedAt,
@@ -77,6 +82,8 @@ class JournalEntry {
bool? sharedToClass,
String? assignedTopicId,
bool clearAssignedTopicId = false,
String? contentExcerpt,
bool clearContentExcerpt = false,
int? version,
DateTime? createdAt,
DateTime? updatedAt,
@@ -94,6 +101,9 @@ class JournalEntry {
sharedToClass: sharedToClass ?? this.sharedToClass,
assignedTopicId:
clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId),
contentExcerpt: clearContentExcerpt
? null
: (contentExcerpt ?? this.contentExcerpt),
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
@@ -111,6 +121,7 @@ class JournalEntry {
'is_private': isPrivate,
'shared_to_class': sharedToClass,
'assigned_topic_id': assignedTopicId,
'content_excerpt': contentExcerpt,
'version': version,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
@@ -134,6 +145,7 @@ class JournalEntry {
isPrivate: (json['is_private'] as bool?) ?? true,
sharedToClass: (json['shared_to_class'] as bool?) ?? false,
assignedTopicId: json['assigned_topic_id'] as String?,
contentExcerpt: json['content_excerpt'] as String?,
version: (json['version'] as int?) ?? 1,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),

View File

@@ -39,7 +39,7 @@ class SseNotificationService {
SseNotificationService({
required String token,
String baseUrl = 'http://localhost:3000/api/v1',
required String baseUrl,
}) : _token = token,
_baseUrl = baseUrl;

View File

@@ -12,10 +12,12 @@
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
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';
@@ -114,6 +116,7 @@ class PendingOperation {
class SyncEngine {
final ApiClient _apiClient;
final Queue<PendingOperation> _pendingQueue = Queue();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
@@ -289,6 +292,26 @@ class SyncEngine {
}
}
/// 启动网络监听 — 网络恢复时自动触发同步
///
/// 在 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;
}
// ============================================================
// 转换函数
// ============================================================