fix(app): Phase 1.1 紧急修复 — SyncEngine 接入 + authorId + catch 异常处理
- 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: 添加全链路审计报告和验证截图
@@ -58,6 +58,8 @@ class NuanjiApp extends StatelessWidget {
|
|||||||
|
|
||||||
// 异步恢复 SyncEngine 持久化队列(fire-and-forget,不阻塞 UI)
|
// 异步恢复 SyncEngine 持久化队列(fire-and-forget,不阻塞 UI)
|
||||||
syncEngine.restorePendingQueue();
|
syncEngine.restorePendingQueue();
|
||||||
|
// 启动网络监听 — 网络恢复时自动触发 trySync()
|
||||||
|
syncEngine.startAutoSync();
|
||||||
|
|
||||||
// 认证状态监听:登出时清除 token
|
// 认证状态监听:登出时清除 token
|
||||||
// 注意:登录时 token 由 AuthRepository.login() 直接注入 ApiClient
|
// 注意:登录时 token 由 AuthRepository.login() 直接注入 ApiClient
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ extension GetJournalElementCollectionCollection on Isar {
|
|||||||
|
|
||||||
const JournalElementCollectionSchema = CollectionSchema(
|
const JournalElementCollectionSchema = CollectionSchema(
|
||||||
name: r'JournalElementCollection',
|
name: r'JournalElementCollection',
|
||||||
id: 5678901234567001,
|
id: -1002,
|
||||||
properties: {
|
properties: {
|
||||||
r'contentJson': PropertySchema(
|
r'contentJson': PropertySchema(
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -96,7 +96,7 @@ const JournalElementCollectionSchema = CollectionSchema(
|
|||||||
idName: r'isarId',
|
idName: r'isarId',
|
||||||
indexes: {
|
indexes: {
|
||||||
r'id': IndexSchema(
|
r'id': IndexSchema(
|
||||||
id: 5678901234567002,
|
id: -2002,
|
||||||
name: r'id',
|
name: r'id',
|
||||||
unique: false,
|
unique: false,
|
||||||
replace: false,
|
replace: false,
|
||||||
@@ -109,7 +109,7 @@ const JournalElementCollectionSchema = CollectionSchema(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
r'journalId': IndexSchema(
|
r'journalId': IndexSchema(
|
||||||
id: 5678901234567003,
|
id: 3001,
|
||||||
name: r'journalId',
|
name: r'journalId',
|
||||||
unique: false,
|
unique: false,
|
||||||
replace: false,
|
replace: false,
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class JournalEntryCollection {
|
|||||||
/// 关联主题 ID(可选)
|
/// 关联主题 ID(可选)
|
||||||
String? assignedTopicId;
|
String? assignedTopicId;
|
||||||
|
|
||||||
|
/// 内容摘要(自动从文本元素提取)
|
||||||
|
String? contentExcerpt;
|
||||||
|
|
||||||
/// 版本号(乐观锁)
|
/// 版本号(乐观锁)
|
||||||
int version = 1;
|
int version = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ extension GetJournalEntryCollectionCollection on Isar {
|
|||||||
|
|
||||||
const JournalEntryCollectionSchema = CollectionSchema(
|
const JournalEntryCollectionSchema = CollectionSchema(
|
||||||
name: r'JournalEntryCollection',
|
name: r'JournalEntryCollection',
|
||||||
id: 5678901234567004,
|
id: -1001,
|
||||||
properties: {
|
properties: {
|
||||||
r'assignedTopicId': PropertySchema(
|
r'assignedTopicId': PropertySchema(
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -33,63 +33,68 @@ const JournalEntryCollectionSchema = CollectionSchema(
|
|||||||
name: r'classId',
|
name: r'classId',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'createdAtEpoch': PropertySchema(
|
r'contentExcerpt': PropertySchema(
|
||||||
id: 3,
|
id: 3,
|
||||||
|
name: r'contentExcerpt',
|
||||||
|
type: IsarType.string,
|
||||||
|
),
|
||||||
|
r'createdAtEpoch': PropertySchema(
|
||||||
|
id: 4,
|
||||||
name: r'createdAtEpoch',
|
name: r'createdAtEpoch',
|
||||||
type: IsarType.long,
|
type: IsarType.long,
|
||||||
),
|
),
|
||||||
r'dateEpoch': PropertySchema(
|
r'dateEpoch': PropertySchema(
|
||||||
id: 4,
|
id: 5,
|
||||||
name: r'dateEpoch',
|
name: r'dateEpoch',
|
||||||
type: IsarType.long,
|
type: IsarType.long,
|
||||||
),
|
),
|
||||||
r'id': PropertySchema(
|
r'id': PropertySchema(
|
||||||
id: 5,
|
id: 6,
|
||||||
name: r'id',
|
name: r'id',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'isDeleted': PropertySchema(
|
r'isDeleted': PropertySchema(
|
||||||
id: 6,
|
id: 7,
|
||||||
name: r'isDeleted',
|
name: r'isDeleted',
|
||||||
type: IsarType.bool,
|
type: IsarType.bool,
|
||||||
),
|
),
|
||||||
r'isPrivate': PropertySchema(
|
r'isPrivate': PropertySchema(
|
||||||
id: 7,
|
id: 8,
|
||||||
name: r'isPrivate',
|
name: r'isPrivate',
|
||||||
type: IsarType.bool,
|
type: IsarType.bool,
|
||||||
),
|
),
|
||||||
r'mood': PropertySchema(
|
r'mood': PropertySchema(
|
||||||
id: 8,
|
id: 9,
|
||||||
name: r'mood',
|
name: r'mood',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'sharedToClass': PropertySchema(
|
r'sharedToClass': PropertySchema(
|
||||||
id: 9,
|
id: 10,
|
||||||
name: r'sharedToClass',
|
name: r'sharedToClass',
|
||||||
type: IsarType.bool,
|
type: IsarType.bool,
|
||||||
),
|
),
|
||||||
r'tagsJson': PropertySchema(
|
r'tagsJson': PropertySchema(
|
||||||
id: 10,
|
id: 11,
|
||||||
name: r'tagsJson',
|
name: r'tagsJson',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'title': PropertySchema(
|
r'title': PropertySchema(
|
||||||
id: 11,
|
id: 12,
|
||||||
name: r'title',
|
name: r'title',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'updatedAtEpoch': PropertySchema(
|
r'updatedAtEpoch': PropertySchema(
|
||||||
id: 12,
|
id: 13,
|
||||||
name: r'updatedAtEpoch',
|
name: r'updatedAtEpoch',
|
||||||
type: IsarType.long,
|
type: IsarType.long,
|
||||||
),
|
),
|
||||||
r'version': PropertySchema(
|
r'version': PropertySchema(
|
||||||
id: 13,
|
id: 14,
|
||||||
name: r'version',
|
name: r'version',
|
||||||
type: IsarType.long,
|
type: IsarType.long,
|
||||||
),
|
),
|
||||||
r'weather': PropertySchema(
|
r'weather': PropertySchema(
|
||||||
id: 14,
|
id: 15,
|
||||||
name: r'weather',
|
name: r'weather',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
)
|
)
|
||||||
@@ -101,7 +106,7 @@ const JournalEntryCollectionSchema = CollectionSchema(
|
|||||||
idName: r'isarId',
|
idName: r'isarId',
|
||||||
indexes: {
|
indexes: {
|
||||||
r'id': IndexSchema(
|
r'id': IndexSchema(
|
||||||
id: 5678901234567002,
|
id: -2001,
|
||||||
name: r'id',
|
name: r'id',
|
||||||
unique: false,
|
unique: false,
|
||||||
replace: false,
|
replace: false,
|
||||||
@@ -141,6 +146,12 @@ int _journalEntryCollectionEstimateSize(
|
|||||||
bytesCount += 3 + value.length * 3;
|
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.id.length * 3;
|
||||||
bytesCount += 3 + object.mood.length * 3;
|
bytesCount += 3 + object.mood.length * 3;
|
||||||
bytesCount += 3 + object.tagsJson.length * 3;
|
bytesCount += 3 + object.tagsJson.length * 3;
|
||||||
@@ -158,18 +169,19 @@ void _journalEntryCollectionSerialize(
|
|||||||
writer.writeString(offsets[0], object.assignedTopicId);
|
writer.writeString(offsets[0], object.assignedTopicId);
|
||||||
writer.writeString(offsets[1], object.authorId);
|
writer.writeString(offsets[1], object.authorId);
|
||||||
writer.writeString(offsets[2], object.classId);
|
writer.writeString(offsets[2], object.classId);
|
||||||
writer.writeLong(offsets[3], object.createdAtEpoch);
|
writer.writeString(offsets[3], object.contentExcerpt);
|
||||||
writer.writeLong(offsets[4], object.dateEpoch);
|
writer.writeLong(offsets[4], object.createdAtEpoch);
|
||||||
writer.writeString(offsets[5], object.id);
|
writer.writeLong(offsets[5], object.dateEpoch);
|
||||||
writer.writeBool(offsets[6], object.isDeleted);
|
writer.writeString(offsets[6], object.id);
|
||||||
writer.writeBool(offsets[7], object.isPrivate);
|
writer.writeBool(offsets[7], object.isDeleted);
|
||||||
writer.writeString(offsets[8], object.mood);
|
writer.writeBool(offsets[8], object.isPrivate);
|
||||||
writer.writeBool(offsets[9], object.sharedToClass);
|
writer.writeString(offsets[9], object.mood);
|
||||||
writer.writeString(offsets[10], object.tagsJson);
|
writer.writeBool(offsets[10], object.sharedToClass);
|
||||||
writer.writeString(offsets[11], object.title);
|
writer.writeString(offsets[11], object.tagsJson);
|
||||||
writer.writeLong(offsets[12], object.updatedAtEpoch);
|
writer.writeString(offsets[12], object.title);
|
||||||
writer.writeLong(offsets[13], object.version);
|
writer.writeLong(offsets[13], object.updatedAtEpoch);
|
||||||
writer.writeString(offsets[14], object.weather);
|
writer.writeLong(offsets[14], object.version);
|
||||||
|
writer.writeString(offsets[15], object.weather);
|
||||||
}
|
}
|
||||||
|
|
||||||
JournalEntryCollection _journalEntryCollectionDeserialize(
|
JournalEntryCollection _journalEntryCollectionDeserialize(
|
||||||
@@ -182,19 +194,20 @@ JournalEntryCollection _journalEntryCollectionDeserialize(
|
|||||||
object.assignedTopicId = reader.readStringOrNull(offsets[0]);
|
object.assignedTopicId = reader.readStringOrNull(offsets[0]);
|
||||||
object.authorId = reader.readString(offsets[1]);
|
object.authorId = reader.readString(offsets[1]);
|
||||||
object.classId = reader.readStringOrNull(offsets[2]);
|
object.classId = reader.readStringOrNull(offsets[2]);
|
||||||
object.createdAtEpoch = reader.readLong(offsets[3]);
|
object.contentExcerpt = reader.readStringOrNull(offsets[3]);
|
||||||
object.dateEpoch = reader.readLong(offsets[4]);
|
object.createdAtEpoch = reader.readLong(offsets[4]);
|
||||||
object.id = reader.readString(offsets[5]);
|
object.dateEpoch = reader.readLong(offsets[5]);
|
||||||
object.isDeleted = reader.readBool(offsets[6]);
|
object.id = reader.readString(offsets[6]);
|
||||||
object.isPrivate = reader.readBool(offsets[7]);
|
object.isDeleted = reader.readBool(offsets[7]);
|
||||||
|
object.isPrivate = reader.readBool(offsets[8]);
|
||||||
object.isarId = id;
|
object.isarId = id;
|
||||||
object.mood = reader.readString(offsets[8]);
|
object.mood = reader.readString(offsets[9]);
|
||||||
object.sharedToClass = reader.readBool(offsets[9]);
|
object.sharedToClass = reader.readBool(offsets[10]);
|
||||||
object.tagsJson = reader.readString(offsets[10]);
|
object.tagsJson = reader.readString(offsets[11]);
|
||||||
object.title = reader.readString(offsets[11]);
|
object.title = reader.readString(offsets[12]);
|
||||||
object.updatedAtEpoch = reader.readLong(offsets[12]);
|
object.updatedAtEpoch = reader.readLong(offsets[13]);
|
||||||
object.version = reader.readLong(offsets[13]);
|
object.version = reader.readLong(offsets[14]);
|
||||||
object.weather = reader.readString(offsets[14]);
|
object.weather = reader.readString(offsets[15]);
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,28 +225,30 @@ P _journalEntryCollectionDeserializeProp<P>(
|
|||||||
case 2:
|
case 2:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 3:
|
case 3:
|
||||||
return (reader.readLong(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 4:
|
case 4:
|
||||||
return (reader.readLong(offset)) as P;
|
return (reader.readLong(offset)) as P;
|
||||||
case 5:
|
case 5:
|
||||||
return (reader.readString(offset)) as P;
|
return (reader.readLong(offset)) as P;
|
||||||
case 6:
|
case 6:
|
||||||
return (reader.readBool(offset)) as P;
|
return (reader.readString(offset)) as P;
|
||||||
case 7:
|
case 7:
|
||||||
return (reader.readBool(offset)) as P;
|
return (reader.readBool(offset)) as P;
|
||||||
case 8:
|
case 8:
|
||||||
return (reader.readString(offset)) as P;
|
|
||||||
case 9:
|
|
||||||
return (reader.readBool(offset)) as P;
|
return (reader.readBool(offset)) as P;
|
||||||
case 10:
|
case 9:
|
||||||
return (reader.readString(offset)) as P;
|
return (reader.readString(offset)) as P;
|
||||||
|
case 10:
|
||||||
|
return (reader.readBool(offset)) as P;
|
||||||
case 11:
|
case 11:
|
||||||
return (reader.readString(offset)) as P;
|
return (reader.readString(offset)) as P;
|
||||||
case 12:
|
case 12:
|
||||||
return (reader.readLong(offset)) as P;
|
return (reader.readString(offset)) as P;
|
||||||
case 13:
|
case 13:
|
||||||
return (reader.readLong(offset)) as P;
|
return (reader.readLong(offset)) as P;
|
||||||
case 14:
|
case 14:
|
||||||
|
return (reader.readLong(offset)) as P;
|
||||||
|
case 15:
|
||||||
return (reader.readString(offset)) as P;
|
return (reader.readString(offset)) as P;
|
||||||
default:
|
default:
|
||||||
throw IsarError('Unknown property with id $propertyId');
|
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,
|
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||||
QAfterFilterCondition> createdAtEpochEqualTo(int value) {
|
QAfterFilterCondition> createdAtEpochEqualTo(int value) {
|
||||||
return QueryBuilder.apply(this, (query) {
|
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>
|
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
|
||||||
sortByCreatedAtEpoch() {
|
sortByCreatedAtEpoch() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
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>
|
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
|
||||||
thenByCreatedAtEpoch() {
|
thenByCreatedAtEpoch() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
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>
|
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QDistinct>
|
||||||
distinctByCreatedAtEpoch() {
|
distinctByCreatedAtEpoch() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
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>
|
QueryBuilder<JournalEntryCollection, int, QQueryOperations>
|
||||||
createdAtEpochProperty() {
|
createdAtEpochProperty() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ extension GetPendingOperationCollectionCollection on Isar {
|
|||||||
|
|
||||||
const PendingOperationCollectionSchema = CollectionSchema(
|
const PendingOperationCollectionSchema = CollectionSchema(
|
||||||
name: r'PendingOperationCollection',
|
name: r'PendingOperationCollection',
|
||||||
id: 5678901234567005,
|
id: -1003,
|
||||||
properties: {
|
properties: {
|
||||||
r'createdAtEpoch': PropertySchema(
|
r'createdAtEpoch': PropertySchema(
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -61,7 +61,7 @@ const PendingOperationCollectionSchema = CollectionSchema(
|
|||||||
idName: r'isarId',
|
idName: r'isarId',
|
||||||
indexes: {
|
indexes: {
|
||||||
r'id': IndexSchema(
|
r'id': IndexSchema(
|
||||||
id: 5678901234567002,
|
id: -2003,
|
||||||
name: r'id',
|
name: r'id',
|
||||||
unique: false,
|
unique: false,
|
||||||
replace: false,
|
replace: false,
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ class JournalEntry {
|
|||||||
final bool isPrivate;
|
final bool isPrivate;
|
||||||
final bool sharedToClass;
|
final bool sharedToClass;
|
||||||
final String? assignedTopicId;
|
final String? assignedTopicId;
|
||||||
|
|
||||||
|
/// 内容摘要 — 自动从文本元素提取,用于列表预览
|
||||||
|
final String? contentExcerpt;
|
||||||
|
|
||||||
final int version;
|
final int version;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
@@ -58,6 +62,7 @@ class JournalEntry {
|
|||||||
this.isPrivate = true,
|
this.isPrivate = true,
|
||||||
this.sharedToClass = false,
|
this.sharedToClass = false,
|
||||||
this.assignedTopicId,
|
this.assignedTopicId,
|
||||||
|
this.contentExcerpt,
|
||||||
this.version = 1,
|
this.version = 1,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
@@ -77,6 +82,8 @@ class JournalEntry {
|
|||||||
bool? sharedToClass,
|
bool? sharedToClass,
|
||||||
String? assignedTopicId,
|
String? assignedTopicId,
|
||||||
bool clearAssignedTopicId = false,
|
bool clearAssignedTopicId = false,
|
||||||
|
String? contentExcerpt,
|
||||||
|
bool clearContentExcerpt = false,
|
||||||
int? version,
|
int? version,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
@@ -94,6 +101,9 @@ class JournalEntry {
|
|||||||
sharedToClass: sharedToClass ?? this.sharedToClass,
|
sharedToClass: sharedToClass ?? this.sharedToClass,
|
||||||
assignedTopicId:
|
assignedTopicId:
|
||||||
clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId),
|
clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId),
|
||||||
|
contentExcerpt: clearContentExcerpt
|
||||||
|
? null
|
||||||
|
: (contentExcerpt ?? this.contentExcerpt),
|
||||||
version: version ?? this.version,
|
version: version ?? this.version,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
@@ -111,6 +121,7 @@ class JournalEntry {
|
|||||||
'is_private': isPrivate,
|
'is_private': isPrivate,
|
||||||
'shared_to_class': sharedToClass,
|
'shared_to_class': sharedToClass,
|
||||||
'assigned_topic_id': assignedTopicId,
|
'assigned_topic_id': assignedTopicId,
|
||||||
|
'content_excerpt': contentExcerpt,
|
||||||
'version': version,
|
'version': version,
|
||||||
'created_at': createdAt.toIso8601String(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
@@ -134,6 +145,7 @@ class JournalEntry {
|
|||||||
isPrivate: (json['is_private'] as bool?) ?? true,
|
isPrivate: (json['is_private'] as bool?) ?? true,
|
||||||
sharedToClass: (json['shared_to_class'] as bool?) ?? false,
|
sharedToClass: (json['shared_to_class'] as bool?) ?? false,
|
||||||
assignedTopicId: json['assigned_topic_id'] as String?,
|
assignedTopicId: json['assigned_topic_id'] as String?,
|
||||||
|
contentExcerpt: json['content_excerpt'] as String?,
|
||||||
version: (json['version'] as int?) ?? 1,
|
version: (json['version'] as int?) ?? 1,
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class SseNotificationService {
|
|||||||
|
|
||||||
SseNotificationService({
|
SseNotificationService({
|
||||||
required String token,
|
required String token,
|
||||||
String baseUrl = 'http://localhost:3000/api/v1',
|
required String baseUrl,
|
||||||
}) : _token = token,
|
}) : _token = token,
|
||||||
_baseUrl = baseUrl;
|
_baseUrl = baseUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,12 @@
|
|||||||
// - 联网后自动推送待同步操作
|
// - 联网后自动推送待同步操作
|
||||||
// - 版本冲突时本地版本覆盖远端(简单策略)
|
// - 版本冲突时本地版本覆盖远端(简单策略)
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
import '../local/isar_database.dart';
|
import '../local/isar_database.dart';
|
||||||
@@ -114,6 +116,7 @@ class PendingOperation {
|
|||||||
class SyncEngine {
|
class SyncEngine {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
final Queue<PendingOperation> _pendingQueue = Queue();
|
final Queue<PendingOperation> _pendingQueue = Queue();
|
||||||
|
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||||
|
|
||||||
SyncStatus _status = SyncStatus.idle;
|
SyncStatus _status = SyncStatus.idle;
|
||||||
String? _lastError;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 转换函数
|
// 转换函数
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../core/constants/design_tokens.dart';
|
import '../../../core/constants/design_tokens.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_radius.dart';
|
import '../../../core/theme/app_radius.dart';
|
||||||
import '../bloc/auth_bloc.dart';
|
import '../bloc/auth_bloc.dart';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||||||
|
|
||||||
bool _isRegister = false;
|
bool _isRegister = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
bool _agreedToTerms = false;
|
||||||
|
|
||||||
late final AnimationController _animController;
|
late final AnimationController _animController;
|
||||||
late final Animation<double> _fadeAnim;
|
late final Animation<double> _fadeAnim;
|
||||||
@@ -60,6 +62,13 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||||||
void _submit() {
|
void _submit() {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
if (_isRegister && !_agreedToTerms) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请先阅读并同意用户协议和隐私政策')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_isRegister) {
|
if (_isRegister) {
|
||||||
context.read<AuthBloc>().add(RegisterRequested(
|
context.read<AuthBloc>().add(RegisterRequested(
|
||||||
username: _usernameController.text.trim(),
|
username: _usernameController.text.trim(),
|
||||||
@@ -106,13 +115,20 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(context, colorScheme),
|
_buildHeader(context, colorScheme),
|
||||||
const SizedBox(height: DesignTokens.spacing48),
|
const SizedBox(height: DesignTokens.spacing32),
|
||||||
_buildForm(context, theme, colorScheme),
|
_buildForm(context, theme, colorScheme),
|
||||||
const SizedBox(height: DesignTokens.spacing24),
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
_buildSubmitButton(context, colorScheme),
|
_buildSubmitButton(context, colorScheme),
|
||||||
const SizedBox(height: DesignTokens.spacing16),
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
_buildModeToggle(context, colorScheme),
|
_buildModeToggle(context, colorScheme),
|
||||||
const SizedBox(height: DesignTokens.spacing32),
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
|
||||||
|
// 协议复选框(注册模式下显示)
|
||||||
|
if (_isRegister) ...[
|
||||||
|
_buildAgreementRow(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
|
],
|
||||||
|
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
BlocBuilder<AuthBloc, AuthState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is AuthError) {
|
if (state is AuthError) {
|
||||||
@@ -121,6 +137,13 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 社交登录分割线
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
_buildSocialLoginDivider(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
|
_buildSocialLoginButtons(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing32),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -132,37 +155,83 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
return Column(
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
children: [
|
final bgColor = isDark ? const Color(0xFF1A1614) : const Color(0xFFFFF8F0);
|
||||||
Container(
|
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
|
||||||
width: 80,
|
|
||||||
height: 80,
|
return Container(
|
||||||
decoration: BoxDecoration(
|
width: double.infinity,
|
||||||
color: colorScheme.primaryContainer,
|
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||||
borderRadius: AppRadius.lgBorder,
|
decoration: BoxDecoration(
|
||||||
),
|
gradient: LinearGradient(
|
||||||
child: Icon(
|
begin: Alignment.topCenter,
|
||||||
Icons.edit_note_rounded,
|
end: Alignment.bottomCenter,
|
||||||
size: 44,
|
colors: [bgColor, tertiarySoft],
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: DesignTokens.spacing16),
|
),
|
||||||
Text(
|
child: Stack(
|
||||||
'暖记',
|
clipBehavior: Clip.none,
|
||||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
children: [
|
||||||
fontWeight: FontWeight.bold,
|
// 装饰圆圈
|
||||||
color: colorScheme.primary,
|
Positioned(left: 20, top: -10, child: _decorCircle(60, AppColors.accent, 0.15)),
|
||||||
|
Positioned(right: 30, top: 20, child: _decorCircle(40, AppColors.secondary, 0.12)),
|
||||||
|
Positioned(left: 80, bottom: -20, child: _decorCircle(30, AppColors.tertiary, 0.18)),
|
||||||
|
Positioned(right: 60, bottom: 10, child: _decorCircle(20, AppColors.accent, 0.10)),
|
||||||
|
Positioned(left: -10, top: 50, child: _decorCircle(25, AppColors.secondary, 0.13)),
|
||||||
|
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
// Logo — 自定义笔记本图标
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: AppColors.accent, width: 3),
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.edit_note_rounded,
|
||||||
|
size: 44,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
const SizedBox(height: DesignTokens.spacing4),
|
// 品牌名
|
||||||
Text(
|
Text(
|
||||||
'记录温暖,书写成长',
|
'暖记',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: DesignTokens.spacing4),
|
||||||
],
|
// 标语 — Caveat 手写风格
|
||||||
|
Text(
|
||||||
|
'记录温暖,书写成长',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontFamily: 'Caveat',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 装饰圆圈
|
||||||
|
Widget _decorCircle(double size, Color color, double opacity) {
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: opacity),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,4 +388,160 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 社交登录分割线
|
||||||
|
Widget _buildSocialLoginDivider(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final dividerColor = colorScheme.onSurface.withValues(alpha: 0.15);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Divider(color: dividerColor)),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text('其他登录方式', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
Expanded(child: Divider(color: dividerColor)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 社交登录按钮行
|
||||||
|
Widget _buildSocialLoginButtons(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 微信
|
||||||
|
_SocialButton(
|
||||||
|
bgColor: const Color(0xFF07C160),
|
||||||
|
icon: Icons.chat_bubble,
|
||||||
|
semanticLabel: '微信登录',
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('微信登录即将支持')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// Apple
|
||||||
|
_SocialButton(
|
||||||
|
bgColor: const Color(0xFF1D1D1F),
|
||||||
|
icon: Icons.apple,
|
||||||
|
semanticLabel: 'Apple 登录',
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Apple 登录即将支持')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// Google
|
||||||
|
_SocialButton(
|
||||||
|
bgColor: colorScheme.surface,
|
||||||
|
borderColor: colorScheme.outlineVariant,
|
||||||
|
child: const Text('G', style: TextStyle(
|
||||||
|
fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF4285F4),
|
||||||
|
)),
|
||||||
|
semanticLabel: 'Google 登录',
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Google 登录即将支持')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 协议复选框行
|
||||||
|
Widget _buildAgreementRow(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Checkbox(
|
||||||
|
value: _agreedToTerms,
|
||||||
|
onChanged: (v) => setState(() => _agreedToTerms = v ?? false),
|
||||||
|
activeColor: AppColors.accent,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
children: [
|
||||||
|
Text('我已阅读并同意', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
)),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: 打开用户协议
|
||||||
|
},
|
||||||
|
child: Text('《用户协议》', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.accent, fontWeight: FontWeight.w500,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
Text('和', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
)),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: 打开隐私政策
|
||||||
|
},
|
||||||
|
child: Text('《隐私政策》', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.accent, fontWeight: FontWeight.w500,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 社交登录圆形按钮
|
||||||
|
class _SocialButton extends StatelessWidget {
|
||||||
|
const _SocialButton({
|
||||||
|
required this.bgColor,
|
||||||
|
required this.semanticLabel,
|
||||||
|
required this.onTap,
|
||||||
|
this.icon,
|
||||||
|
this.child,
|
||||||
|
this.borderColor,
|
||||||
|
});
|
||||||
|
final Color bgColor;
|
||||||
|
final Color? borderColor;
|
||||||
|
final IconData? icon;
|
||||||
|
final Widget? child;
|
||||||
|
final String semanticLabel;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
child: Material(
|
||||||
|
color: bgColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
side: borderColor != null
|
||||||
|
? BorderSide(color: borderColor!, width: 1.5)
|
||||||
|
: BorderSide.none,
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
customBorder: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: child ??
|
||||||
|
Icon(icon, size: 28, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ class _MonthlyPageState extends State<MonthlyPage> {
|
|||||||
try {
|
try {
|
||||||
final elements = await _repo.getElements(journal.id);
|
final elements = await _repo.getElements(journal.id);
|
||||||
photoCount += elements.where((e) => e.elementType == ElementType.image).length;
|
photoCount += elements.where((e) => e.elementType == ElementType.image).length;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
debugPrint('MonthlyPage: 加载日记 ${journal.id} 元素失败: $e');
|
||||||
// 单个日记加载元素失败不影响整体统计
|
// 单个日记加载元素失败不影响整体统计
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class _WeeklyPageState extends State<WeeklyPage> {
|
|||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
debugPrint('WeeklyPage._loadWeekData 失败: $e');
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// 班级 BLoC — 通过 ClassRepository 管理班级数据
|
// 班级 BLoC — 通过 ClassRepository 管理班级数据
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
import 'package:nuanji_app/data/models/school_class.dart';
|
import 'package:nuanji_app/data/models/school_class.dart';
|
||||||
@@ -117,9 +118,14 @@ final class ClassLoading extends ClassState {
|
|||||||
final class ClassListLoaded extends ClassState {
|
final class ClassListLoaded extends ClassState {
|
||||||
final List<SchoolClass> classes;
|
final List<SchoolClass> classes;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
const ClassListLoaded({this.classes = const [], this.isLoading = false});
|
final String? error;
|
||||||
ClassListLoaded copyWith({List<SchoolClass>? classes, bool? isLoading}) =>
|
const ClassListLoaded({this.classes = const [], this.isLoading = false, this.error});
|
||||||
ClassListLoaded(classes: classes ?? this.classes, isLoading: isLoading ?? this.isLoading);
|
ClassListLoaded copyWith({List<SchoolClass>? classes, bool? isLoading, String? error, bool clearError = false}) =>
|
||||||
|
ClassListLoaded(
|
||||||
|
classes: classes ?? this.classes,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
error: clearError ? null : (error ?? this.error),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ClassDetailLoaded extends ClassState {
|
final class ClassDetailLoaded extends ClassState {
|
||||||
@@ -209,7 +215,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
final classes = await _classRepo.getMyClasses();
|
final classes = await _classRepo.getMyClasses();
|
||||||
emit(ClassListLoaded(classes: classes));
|
emit(ClassListLoaded(classes: classes));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(ClassListLoaded(classes: const []));
|
debugPrint('ClassBloc._onLoadMyClasses 失败: $e');
|
||||||
|
emit(const ClassListLoaded());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +231,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
add(ClassLoadMembers(event.classId));
|
add(ClassLoadMembers(event.classId));
|
||||||
add(ClassLoadTopics(event.classId));
|
add(ClassLoadTopics(event.classId));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(ClassError('加载班级失败: $e'));
|
debugPrint('ClassBloc._onClassSelected 失败: $e');
|
||||||
|
emit(const ClassError('加载班级失败,请重试'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +255,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
emit(current.copyWith(members: members, isLoadingMembers: false));
|
emit(current.copyWith(members: members, isLoadingMembers: false));
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onLoadMembers 失败: $e');
|
||||||
emit(current.copyWith(isLoadingMembers: false));
|
emit(current.copyWith(isLoadingMembers: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,7 +275,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
final classJournals = journals.where((j) => j.sharedToClass).toList();
|
final classJournals = journals.where((j) => j.sharedToClass).toList();
|
||||||
|
|
||||||
emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
|
emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onLoadDiaryWall 失败: $e');
|
||||||
emit(current.copyWith(isLoadingWall: false));
|
emit(current.copyWith(isLoadingWall: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,8 +302,9 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
emit(current.copyWith(topics: topics));
|
emit(current.copyWith(topics: topics));
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// 静默失败,保留空列表
|
debugPrint('ClassBloc._onLoadTopics 失败: $e');
|
||||||
|
// 保留空列表
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +327,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
emit(current.copyWith(comments: comments, selectedJournalId: event.journalId));
|
emit(current.copyWith(comments: comments, selectedJournalId: event.journalId));
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onLoadComments 失败: $e');
|
||||||
emit(current.copyWith(selectedJournalId: event.journalId));
|
emit(current.copyWith(selectedJournalId: event.journalId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,7 +347,11 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
emit(current.copyWith(classes: [...current.classes, newClass]));
|
emit(current.copyWith(classes: [...current.classes, newClass]));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 创建失败不改变状态
|
debugPrint('ClassBloc._onCreateClass 失败: $e');
|
||||||
|
// 创建失败不改变状态,但通知 UI
|
||||||
|
if (state is ClassListLoaded) {
|
||||||
|
emit((state as ClassListLoaded).copyWith(error: '创建班级失败,请重试'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,8 +380,12 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
);
|
);
|
||||||
emit(current.copyWith(topics: [newTopic, ...current.topics]));
|
emit(current.copyWith(topics: [newTopic, ...current.topics]));
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// 静默失败
|
debugPrint('ClassBloc._onTopicAssign 失败: $e');
|
||||||
|
// 通知 UI 布置失败
|
||||||
|
if (state is ClassDetailLoaded) {
|
||||||
|
emit((state as ClassDetailLoaded).copyWith(error: '话题布置失败,请重试'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +398,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
// 加入成功后刷新列表
|
// 加入成功后刷新列表
|
||||||
add(const ClassLoadMyClasses());
|
add(const ClassLoadMyClasses());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(ClassError('加入班级失败: $e'));
|
debugPrint('ClassBloc._onJoinClass 失败: $e');
|
||||||
|
emit(const ClassError('加入班级失败,请检查班级码'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +418,7 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
// 创建成功后重新加载评语列表
|
// 创建成功后重新加载评语列表
|
||||||
add(ClassLoadComments(event.journalId));
|
add(ClassLoadComments(event.journalId));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onCommentCreate 失败: $e');
|
||||||
emit(currentState.copyWith(error: '评语发布失败'));
|
emit(currentState.copyWith(error: '评语发布失败'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
// - 手写层:笔画列表 + 画笔设置 + 撤销/重做栈
|
// - 手写层:笔画列表 + 画笔设置 + 撤销/重做栈
|
||||||
// - 元素层:贴纸/照片/文字元素列表 + 选中元素 + 拖拽状态
|
// - 元素层:贴纸/照片/文字元素列表 + 选中元素 + 拖拽状态
|
||||||
// - 工具栏:当前活动工具 + 颜色面板 + 笔刷大小
|
// - 工具栏:当前活动工具 + 颜色面板 + 笔刷大小
|
||||||
|
// - 标签/心情:日记标签管理 + 心情选择 + 标题编辑
|
||||||
// - 自动保存:笔画/元素变更 debounce → 触发保存回调
|
// - 自动保存:笔画/元素变更 debounce → 触发保存回调
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../widgets/stroke_model.dart';
|
import '../widgets/stroke_model.dart';
|
||||||
|
import '../../../data/models/journal_entry.dart';
|
||||||
import '../../../data/models/journal_element.dart';
|
import '../../../data/models/journal_element.dart';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -110,20 +113,69 @@ class ElementsLoaded extends EditorEvent {
|
|||||||
ElementsLoaded(this.elements);
|
ElementsLoaded(this.elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 标签/心情/标题事件 ---
|
||||||
|
|
||||||
|
/// 添加标签
|
||||||
|
class TagAdded extends EditorEvent {
|
||||||
|
final String tag;
|
||||||
|
TagAdded(this.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移除标签
|
||||||
|
class TagRemoved extends EditorEvent {
|
||||||
|
final String tag;
|
||||||
|
TagRemoved(this.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载已有标签
|
||||||
|
class TagsLoaded extends EditorEvent {
|
||||||
|
final List<String> tags;
|
||||||
|
TagsLoaded(this.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 心情变更
|
||||||
|
class MoodChanged extends EditorEvent {
|
||||||
|
final Mood mood;
|
||||||
|
MoodChanged(this.mood);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标题变更
|
||||||
|
class TitleChanged extends EditorEvent {
|
||||||
|
final String title;
|
||||||
|
TitleChanged(this.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文字格式变更
|
||||||
|
class TextFormatChanged extends EditorEvent {
|
||||||
|
final String elementId;
|
||||||
|
final bool? bold;
|
||||||
|
final bool? italic;
|
||||||
|
final bool? underline;
|
||||||
|
final String? color;
|
||||||
|
final TextAlign? alignment;
|
||||||
|
TextFormatChanged({
|
||||||
|
required this.elementId,
|
||||||
|
this.bold,
|
||||||
|
this.italic,
|
||||||
|
this.underline,
|
||||||
|
this.color,
|
||||||
|
this.alignment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 状态
|
// 状态
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/// 编辑器工具枚举
|
/// 编辑器工具枚举 — 对应底部 6 个工具按钮 + 内部 select 模式
|
||||||
enum EditorTool {
|
enum EditorTool {
|
||||||
pen, // 钢笔
|
|
||||||
pencil, // 铅笔
|
|
||||||
marker, // 马克笔
|
|
||||||
eraser, // 橡皮擦
|
|
||||||
select, // 选择/移动元素
|
|
||||||
text, // 文字输入
|
|
||||||
sticker, // 贴纸
|
sticker, // 贴纸
|
||||||
image, // 照片
|
template, // 模板
|
||||||
|
brush, // 画笔(含钢笔/铅笔/马克笔/橡皮子类型)
|
||||||
|
photo, // 照片
|
||||||
|
text, // 文字
|
||||||
|
more, // 更多
|
||||||
|
select, // 选择/移动元素(内部模式,非 UI 按钮)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 编辑器状态
|
/// 编辑器状态
|
||||||
@@ -134,6 +186,7 @@ class EditorState {
|
|||||||
final BrushType brushType;
|
final BrushType brushType;
|
||||||
final String brushColor;
|
final String brushColor;
|
||||||
final double brushWidth;
|
final double brushWidth;
|
||||||
|
final double brushOpacity;
|
||||||
final int maxUndoSteps;
|
final int maxUndoSteps;
|
||||||
|
|
||||||
// 元素层
|
// 元素层
|
||||||
@@ -143,6 +196,11 @@ class EditorState {
|
|||||||
// 工具栏
|
// 工具栏
|
||||||
final EditorTool activeTool;
|
final EditorTool activeTool;
|
||||||
|
|
||||||
|
// 标签/心情/标题
|
||||||
|
final List<String> tags;
|
||||||
|
final Mood selectedMood;
|
||||||
|
final String title;
|
||||||
|
|
||||||
// 自动保存
|
// 自动保存
|
||||||
final bool isDirty;
|
final bool isDirty;
|
||||||
final DateTime? lastSavedAt;
|
final DateTime? lastSavedAt;
|
||||||
@@ -153,10 +211,14 @@ class EditorState {
|
|||||||
this.brushType = BrushType.pen,
|
this.brushType = BrushType.pen,
|
||||||
this.brushColor = '#2D2420',
|
this.brushColor = '#2D2420',
|
||||||
this.brushWidth = 3.0,
|
this.brushWidth = 3.0,
|
||||||
|
this.brushOpacity = 1.0,
|
||||||
this.maxUndoSteps = 50,
|
this.maxUndoSteps = 50,
|
||||||
this.elements = const [],
|
this.elements = const [],
|
||||||
this.selectedElementId,
|
this.selectedElementId,
|
||||||
this.activeTool = EditorTool.pen,
|
this.activeTool = EditorTool.brush,
|
||||||
|
this.tags = const [],
|
||||||
|
this.selectedMood = Mood.calm,
|
||||||
|
this.title = '',
|
||||||
this.isDirty = false,
|
this.isDirty = false,
|
||||||
this.lastSavedAt,
|
this.lastSavedAt,
|
||||||
});
|
});
|
||||||
@@ -167,10 +229,14 @@ class EditorState {
|
|||||||
BrushType? brushType,
|
BrushType? brushType,
|
||||||
String? brushColor,
|
String? brushColor,
|
||||||
double? brushWidth,
|
double? brushWidth,
|
||||||
|
double? brushOpacity,
|
||||||
List<JournalElement>? elements,
|
List<JournalElement>? elements,
|
||||||
String? selectedElementId,
|
String? selectedElementId,
|
||||||
bool clearSelection = false,
|
bool clearSelection = false,
|
||||||
EditorTool? activeTool,
|
EditorTool? activeTool,
|
||||||
|
List<String>? tags,
|
||||||
|
Mood? selectedMood,
|
||||||
|
String? title,
|
||||||
bool? isDirty,
|
bool? isDirty,
|
||||||
DateTime? lastSavedAt,
|
DateTime? lastSavedAt,
|
||||||
}) =>
|
}) =>
|
||||||
@@ -180,27 +246,28 @@ class EditorState {
|
|||||||
brushType: brushType ?? this.brushType,
|
brushType: brushType ?? this.brushType,
|
||||||
brushColor: brushColor ?? this.brushColor,
|
brushColor: brushColor ?? this.brushColor,
|
||||||
brushWidth: brushWidth ?? this.brushWidth,
|
brushWidth: brushWidth ?? this.brushWidth,
|
||||||
|
brushOpacity: brushOpacity ?? this.brushOpacity,
|
||||||
maxUndoSteps: maxUndoSteps,
|
maxUndoSteps: maxUndoSteps,
|
||||||
elements: elements ?? this.elements,
|
elements: elements ?? this.elements,
|
||||||
selectedElementId: clearSelection ? null : (selectedElementId ?? this.selectedElementId),
|
selectedElementId:
|
||||||
|
clearSelection ? null : (selectedElementId ?? this.selectedElementId),
|
||||||
activeTool: activeTool ?? this.activeTool,
|
activeTool: activeTool ?? this.activeTool,
|
||||||
|
tags: tags ?? this.tags,
|
||||||
|
selectedMood: selectedMood ?? this.selectedMood,
|
||||||
|
title: title ?? this.title,
|
||||||
isDirty: isDirty ?? this.isDirty,
|
isDirty: isDirty ?? this.isDirty,
|
||||||
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
|
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 是否处于手写模式(画笔/橡皮工具)
|
/// 是否处于手写模式
|
||||||
bool get isDrawingMode =>
|
bool get isDrawingMode => activeTool == EditorTool.brush;
|
||||||
activeTool == EditorTool.pen ||
|
|
||||||
activeTool == EditorTool.pencil ||
|
|
||||||
activeTool == EditorTool.marker ||
|
|
||||||
activeTool == EditorTool.eraser;
|
|
||||||
|
|
||||||
/// 是否处于元素操作模式
|
/// 是否处于元素操作模式
|
||||||
bool get isElementMode =>
|
bool get isElementMode =>
|
||||||
activeTool == EditorTool.select ||
|
activeTool == EditorTool.select ||
|
||||||
activeTool == EditorTool.text ||
|
activeTool == EditorTool.text ||
|
||||||
activeTool == EditorTool.sticker ||
|
activeTool == EditorTool.sticker ||
|
||||||
activeTool == EditorTool.image;
|
activeTool == EditorTool.photo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -238,6 +305,14 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
|||||||
|
|
||||||
// 工具栏事件
|
// 工具栏事件
|
||||||
on<ToolChanged>(_onToolChanged);
|
on<ToolChanged>(_onToolChanged);
|
||||||
|
|
||||||
|
// 标签/心情/标题事件
|
||||||
|
on<TagAdded>(_onTagAdded);
|
||||||
|
on<TagRemoved>(_onTagRemoved);
|
||||||
|
on<TagsLoaded>(_onTagsLoaded);
|
||||||
|
on<MoodChanged>(_onMoodChanged);
|
||||||
|
on<TitleChanged>(_onTitleChanged);
|
||||||
|
on<TextFormatChanged>(_onTextFormatChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -321,7 +396,8 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
void _onElementAdded(ElementAdded event, Emitter<EditorState> emit) {
|
void _onElementAdded(ElementAdded event, Emitter<EditorState> emit) {
|
||||||
final updated = List<JournalElement>.from(state.elements)..add(event.element);
|
final updated =
|
||||||
|
List<JournalElement>.from(state.elements)..add(event.element);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
elements: updated,
|
elements: updated,
|
||||||
selectedElementId: event.element.id,
|
selectedElementId: event.element.id,
|
||||||
@@ -394,6 +470,58 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 标签/心情/标题事件处理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
void _onTagAdded(TagAdded event, Emitter<EditorState> emit) {
|
||||||
|
if (state.tags.contains(event.tag)) return;
|
||||||
|
if (state.tags.length >= 10) return; // 设计 Token: maxTags=10
|
||||||
|
final updated = List<String>.from(state.tags)..add(event.tag);
|
||||||
|
emit(state.copyWith(tags: updated, isDirty: true));
|
||||||
|
_scheduleAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTagRemoved(TagRemoved event, Emitter<EditorState> emit) {
|
||||||
|
final updated = List<String>.from(state.tags)..remove(event.tag);
|
||||||
|
emit(state.copyWith(tags: updated, isDirty: true));
|
||||||
|
_scheduleAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTagsLoaded(TagsLoaded event, Emitter<EditorState> emit) {
|
||||||
|
emit(state.copyWith(tags: event.tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMoodChanged(MoodChanged event, Emitter<EditorState> emit) {
|
||||||
|
emit(state.copyWith(selectedMood: event.mood, isDirty: true));
|
||||||
|
_scheduleAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTitleChanged(TitleChanged event, Emitter<EditorState> emit) {
|
||||||
|
emit(state.copyWith(title: event.title, isDirty: true));
|
||||||
|
_scheduleAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTextFormatChanged(
|
||||||
|
TextFormatChanged event,
|
||||||
|
Emitter<EditorState> emit,
|
||||||
|
) {
|
||||||
|
final updated = state.elements.map((e) {
|
||||||
|
if (e.id != event.elementId) return e;
|
||||||
|
final content = Map<String, dynamic>.from(e.content);
|
||||||
|
if (event.bold != null) content['bold'] = event.bold;
|
||||||
|
if (event.italic != null) content['italic'] = event.italic;
|
||||||
|
if (event.underline != null) content['underline'] = event.underline;
|
||||||
|
if (event.color != null) content['color'] = event.color;
|
||||||
|
if (event.alignment != null) {
|
||||||
|
content['alignment'] = event.alignment!.index;
|
||||||
|
}
|
||||||
|
return e.copyWith(content: content);
|
||||||
|
}).toList();
|
||||||
|
emit(state.copyWith(elements: updated, isDirty: true));
|
||||||
|
_scheduleAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 自动保存
|
// 自动保存
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../core/constants/design_tokens.dart';
|
import '../../../core/constants/design_tokens.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../data/models/journal_element.dart';
|
import '../../../data/models/journal_element.dart';
|
||||||
import '../../../data/models/journal_entry.dart';
|
import '../../../data/models/journal_entry.dart' show JournalEntry, Mood;
|
||||||
import '../../../data/repositories/journal_repository.dart';
|
import '../../../data/repositories/journal_repository.dart';
|
||||||
import '../../../data/repositories/class_repository.dart';
|
import '../../../data/repositories/class_repository.dart';
|
||||||
|
import '../../../data/services/sync_engine.dart';
|
||||||
|
import '../../auth/bloc/auth_bloc.dart';
|
||||||
import '../bloc/editor_bloc.dart';
|
import '../bloc/editor_bloc.dart';
|
||||||
import '../widgets/handwriting_canvas.dart';
|
import '../widgets/handwriting_canvas.dart';
|
||||||
import '../widgets/stroke_model.dart';
|
import '../widgets/stroke_model.dart';
|
||||||
@@ -27,6 +30,9 @@ import '../widgets/text_input_overlay.dart';
|
|||||||
import '../widgets/image_picker_handler.dart';
|
import '../widgets/image_picker_handler.dart';
|
||||||
import '../widgets/sticker_picker_sheet.dart';
|
import '../widgets/sticker_picker_sheet.dart';
|
||||||
import '../widgets/share_bottom_sheet.dart';
|
import '../widgets/share_bottom_sheet.dart';
|
||||||
|
import '../widgets/tag_panel.dart';
|
||||||
|
import '../widgets/brush_panel.dart';
|
||||||
|
import '../widgets/dot_grid_painter.dart';
|
||||||
|
|
||||||
/// 手账编辑器页面
|
/// 手账编辑器页面
|
||||||
class EditorPage extends StatelessWidget {
|
class EditorPage extends StatelessWidget {
|
||||||
@@ -39,6 +45,8 @@ class EditorPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 从 Provider 树获取 JournalRepository(IsarJournalRepository)
|
// 从 Provider 树获取 JournalRepository(IsarJournalRepository)
|
||||||
final repo = context.read<JournalRepository>();
|
final repo = context.read<JournalRepository>();
|
||||||
|
// 从 Provider 树获取 SyncEngine(同步到后端)
|
||||||
|
final syncEngine = context.read<SyncEngine>();
|
||||||
|
|
||||||
// 可变闭包变量:跟踪已保存的日记 ID
|
// 可变闭包变量:跟踪已保存的日记 ID
|
||||||
// 新建日记首次保存后赋值,后续自动更新使用此 ID
|
// 新建日记首次保存后赋值,后续自动更新使用此 ID
|
||||||
@@ -48,7 +56,18 @@ class EditorPage extends StatelessWidget {
|
|||||||
create: (_) => EditorBloc(
|
create: (_) => EditorBloc(
|
||||||
onSave: (state) async {
|
onSave: (state) async {
|
||||||
try {
|
try {
|
||||||
await _persistState(repo, state, (id) => savedJournalId = id, savedJournalId);
|
// 从 AuthBloc 获取真实用户 ID
|
||||||
|
String authorId = 'local';
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
if (authState is Authenticated) {
|
||||||
|
authorId = authState.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _persistState(
|
||||||
|
repo, state, (id) => savedJournalId = id, savedJournalId,
|
||||||
|
syncEngine: syncEngine,
|
||||||
|
authorId: authorId,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('自动保存失败: $e');
|
debugPrint('自动保存失败: $e');
|
||||||
}
|
}
|
||||||
@@ -66,24 +85,27 @@ class EditorPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 持久化编辑器状态到 Isar
|
/// 持久化编辑器状态到 Isar,并同步到后端
|
||||||
///
|
///
|
||||||
/// 策略:
|
/// 策略:
|
||||||
/// - 首次保存(savedJournalId == null)→ createJournal + addElement
|
/// - 首次保存(savedJournalId == null)→ createJournal + addElement
|
||||||
/// - 后续保存 → updateJournal + upsert 元素
|
/// - 后续保存 → updateJournal + upsert 元素
|
||||||
/// - 笔画序列化为 handwriting_ref 元素
|
/// - 笔画序列化为 handwriting_ref 元素
|
||||||
|
/// - 保存成功后入队 SyncEngine 等待网络同步
|
||||||
Future<void> _persistState(
|
Future<void> _persistState(
|
||||||
JournalRepository repo,
|
JournalRepository repo,
|
||||||
EditorState state,
|
EditorState state,
|
||||||
void Function(String) setId,
|
void Function(String) setId,
|
||||||
String? savedJournalId,
|
String? savedJournalId, {
|
||||||
) async {
|
required SyncEngine syncEngine,
|
||||||
|
String authorId = 'local',
|
||||||
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
if (savedJournalId == null) {
|
if (savedJournalId == null) {
|
||||||
// --- 新建日记 ---
|
// --- 新建日记 ---
|
||||||
final entry = JournalEntry.create(
|
final entry = JournalEntry.create(
|
||||||
authorId: 'local', // TODO: 从 AuthBloc 获取真实用户 ID
|
authorId: authorId,
|
||||||
title: '${now.month}月${now.day}日的日记',
|
title: '${now.month}月${now.day}日的日记',
|
||||||
date: now,
|
date: now,
|
||||||
);
|
);
|
||||||
@@ -99,11 +121,31 @@ class EditorPage extends StatelessWidget {
|
|||||||
for (final element in state.elements) {
|
for (final element in state.elements) {
|
||||||
await repo.addElement(element.copyWith(journalId: entry.id));
|
await repo.addElement(element.copyWith(journalId: entry.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 入队 SyncEngine 等待同步到后端
|
||||||
|
syncEngine.enqueue(PendingOperation(
|
||||||
|
id: entry.id,
|
||||||
|
type: SyncOperationType.create,
|
||||||
|
endpoint: '/diary/journals',
|
||||||
|
data: entry.toJson(),
|
||||||
|
version: entry.version,
|
||||||
|
createdAt: now,
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
// --- 更新已有日记 ---
|
// --- 更新已有日记 ---
|
||||||
final existing = await repo.getJournal(savedJournalId);
|
final existing = await repo.getJournal(savedJournalId);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
await repo.updateJournal(existing);
|
await repo.updateJournal(existing);
|
||||||
|
|
||||||
|
// 入队 SyncEngine 等待同步到后端
|
||||||
|
syncEngine.enqueue(PendingOperation(
|
||||||
|
id: existing.id,
|
||||||
|
type: SyncOperationType.update,
|
||||||
|
endpoint: '/diary/journals/${existing.id}',
|
||||||
|
data: existing.toJson(),
|
||||||
|
version: existing.version,
|
||||||
|
createdAt: now,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新笔画
|
// 更新笔画
|
||||||
@@ -154,21 +196,28 @@ class EditorPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 显示分享面板并在用户选择后导航
|
/// 显示分享面板并在用户选择后导航
|
||||||
static void _showShareSheetAndNavigate(
|
static Future<void> _showShareSheetAndNavigate(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
JournalRepository repo,
|
JournalRepository repo,
|
||||||
String? savedJournalId,
|
String? savedJournalId,
|
||||||
) {
|
) async {
|
||||||
// 尝试获取用户的班级信息
|
// 尝试获取用户的班级信息
|
||||||
String? userClassId;
|
String? userClassId;
|
||||||
String userClassName = '我的班级';
|
String userClassName = '我的班级';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
context.read<ClassRepository>();
|
// 从 AuthBloc 获取用户关联的班级
|
||||||
// Phase 1 简化:不等待异步调用,使用默认值
|
final authState = context.read<AuthBloc>().state;
|
||||||
userClassId = null; // TODO: 从 AuthBloc/ClassBloc 获取真实班级 ID
|
if (authState is Authenticated) {
|
||||||
} catch (_) {
|
final classRepo = context.read<ClassRepository>();
|
||||||
// ClassRepository 不可用(未注入)
|
final classes = await classRepo.getMyClasses();
|
||||||
|
if (classes.isNotEmpty) {
|
||||||
|
userClassId = classes.first.id;
|
||||||
|
userClassName = classes.first.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('获取班级信息失败: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
@@ -226,94 +275,251 @@ class _EditorView extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
body: SafeArea(
|
body: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
// 顶栏(自带状态栏安全区)
|
||||||
// 顶栏
|
BlocBuilder<EditorBloc, EditorState>(
|
||||||
_buildTopBar(context),
|
builder: (context, state) {
|
||||||
|
return _buildTopBar(context, state);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// 编辑区域(三层 Stack)
|
// 编辑区域(三层 Stack)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<EditorBloc, EditorState>(
|
child: BlocBuilder<EditorBloc, EditorState>(
|
||||||
builder: (context, state) {
|
|
||||||
return _EditorStack(state: state, journalId: journalId);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 底部工具栏
|
|
||||||
BlocBuilder<EditorBloc, EditorState>(
|
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return EditorToolbar(
|
return _EditorStack(state: state, journalId: journalId);
|
||||||
state: state,
|
|
||||||
onEvent: (event) => context.read<EditorBloc>().add(event),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
|
// 底部工具栏(自带底部安全区)
|
||||||
|
BlocBuilder<EditorBloc, EditorState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return EditorToolbar(
|
||||||
|
state: state,
|
||||||
|
onEvent: (event) => context.read<EditorBloc>().add(event),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 顶部操作栏 — 返回/日记标题/完成
|
/// 顶部操作栏 — 日期/撤销重做/标签/心情/完成
|
||||||
Widget _buildTopBar(BuildContext context) {
|
Widget _buildTopBar(BuildContext context, EditorState state) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 52,
|
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: colorScheme.outline.withValues(alpha: 0.1)),
|
bottom: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 返回按钮
|
// 主顶栏行 (44px)
|
||||||
IconButton(
|
SizedBox(
|
||||||
onPressed: () {
|
height: 44,
|
||||||
if (context.canPop()) {
|
child: Padding(
|
||||||
context.pop();
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
} else {
|
child: Row(
|
||||||
context.go('/home');
|
children: [
|
||||||
}
|
// 返回按钮
|
||||||
},
|
IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
tooltip: '返回',
|
onPressed: () => _handleBack(context),
|
||||||
),
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
iconSize: 22,
|
||||||
const SizedBox(width: DesignTokens.spacing8),
|
|
||||||
|
|
||||||
// 日记标题
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
journalId != null
|
|
||||||
? '编辑日记'
|
|
||||||
: templateId != null
|
|
||||||
? '从模板新建'
|
|
||||||
: '新建日记',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
|
// 日期显示
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
_formatDate(state),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Quicksand',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 撤销
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.undo_rounded, size: 18),
|
||||||
|
onPressed: () => context.read<EditorBloc>().add(Undo()),
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
// 重做
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.redo_rounded, size: 18),
|
||||||
|
onPressed: () => context.read<EditorBloc>().add(Redo()),
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
// 自动保存状态
|
||||||
|
_buildAutosaveIndicator(state),
|
||||||
|
// 标签按钮
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.sell_rounded, size: 18),
|
||||||
|
onPressed: () => _showTagPanel(context, state),
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
// 完成/保存按钮
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _handleSave(context, state),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
minimumSize: const Size(0, 32),
|
||||||
|
),
|
||||||
|
child: const Text('完成', style: TextStyle(fontSize: 14)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 日期 + 心情条 (40px)
|
||||||
|
_buildDateMoodStrip(context, state),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 完成按钮
|
/// 返回处理
|
||||||
FilledButton.tonal(
|
void _handleBack(BuildContext context) {
|
||||||
onPressed: onSaveComplete,
|
if (context.canPop()) {
|
||||||
style: FilledButton.styleFrom(
|
context.pop();
|
||||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
|
} else {
|
||||||
minimumSize: const Size(0, 36),
|
context.go('/home');
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存处理
|
||||||
|
void _handleSave(BuildContext context, EditorState state) {
|
||||||
|
onSaveComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化日期显示
|
||||||
|
String _formatDate(EditorState state) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
return '${now.month}月${now.day}日 · ${weekdays[now.weekday - 1]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自动保存状态指示器
|
||||||
|
Widget _buildAutosaveIndicator(EditorState state) {
|
||||||
|
if (state.lastSavedAt == null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Text(
|
||||||
|
'未保存',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.success,
|
||||||
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Text('完成'),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'已保存',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 日期时间 + 心情选择条
|
||||||
|
Widget _buildDateMoodStrip(BuildContext context, EditorState state) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final timeStr =
|
||||||
|
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||||
|
final moods = [
|
||||||
|
(Mood.happy, '😊'),
|
||||||
|
(Mood.calm, '😐'),
|
||||||
|
(Mood.sad, '😢'),
|
||||||
|
(Mood.angry, '😡'),
|
||||||
|
(Mood.thinking, '🤔'),
|
||||||
|
];
|
||||||
|
return Container(
|
||||||
|
height: 40,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
timeStr,
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
// 心情快捷按钮
|
||||||
|
const Spacer(),
|
||||||
|
...moods.map((m) {
|
||||||
|
final isSelected = state.selectedMood == m.$1;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () =>
|
||||||
|
context.read<EditorBloc>().add(MoodChanged(m.$1)),
|
||||||
|
child: Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: AppColors.accent, width: 1.5)
|
||||||
|
: null,
|
||||||
|
color: isSelected ? const Color(0xFFFFF3E6) : null,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(m.$2, style: const TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示标签面板
|
||||||
|
void _showTagPanel(BuildContext context, EditorState state) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (ctx) => BlocProvider.value(
|
||||||
|
value: context.read<EditorBloc>(),
|
||||||
|
child: BlocBuilder<EditorBloc, EditorState>(
|
||||||
|
builder: (ctx, state) => TagPanel(
|
||||||
|
selectedTags: state.tags,
|
||||||
|
onTagAdded: (tag) {
|
||||||
|
context.read<EditorBloc>().add(TagAdded(tag));
|
||||||
|
},
|
||||||
|
onTagRemoved: (tag) {
|
||||||
|
context.read<EditorBloc>().add(TagRemoved(tag));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -337,16 +543,46 @@ class _EditorStack extends StatefulWidget {
|
|||||||
|
|
||||||
class _EditorStackState extends State<_EditorStack> {
|
class _EditorStackState extends State<_EditorStack> {
|
||||||
EditorTool? _lastTool;
|
EditorTool? _lastTool;
|
||||||
|
late final TextEditingController _titleController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_titleController = TextEditingController(text: widget.state.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant _EditorStack oldWidget) {
|
void didUpdateWidget(covariant _EditorStack oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
final currentTool = widget.state.activeTool;
|
final currentTool = widget.state.activeTool;
|
||||||
|
|
||||||
// 贴纸工具刚被激活时弹出底部面板(防止重复弹窗)
|
// 防止重复弹窗:只在工具切换时触发
|
||||||
if (currentTool == EditorTool.sticker && _lastTool != EditorTool.sticker) {
|
if (currentTool != _lastTool) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _showStickerPicker();
|
if (!mounted) return;
|
||||||
|
|
||||||
|
switch (currentTool) {
|
||||||
|
// 贴纸工具 → 弹出贴纸选择面板
|
||||||
|
case EditorTool.sticker:
|
||||||
|
_showStickerPicker();
|
||||||
|
// 画笔工具 → 弹出画笔设置面板
|
||||||
|
case EditorTool.brush:
|
||||||
|
_showBrushPanel();
|
||||||
|
// 模板工具 → 导航到模板页
|
||||||
|
case EditorTool.template:
|
||||||
|
context.go('/templates');
|
||||||
|
// 更多工具 → 弹出分享/导出选项
|
||||||
|
case EditorTool.more:
|
||||||
|
_showMoreSheet();
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_lastTool = currentTool;
|
_lastTool = currentTool;
|
||||||
@@ -376,24 +612,162 @@ class _EditorStackState extends State<_EditorStack> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 显示画笔设置底部面板
|
||||||
|
void _showBrushPanel() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => BlocProvider.value(
|
||||||
|
value: context.read<EditorBloc>(),
|
||||||
|
child: BlocBuilder<EditorBloc, EditorState>(
|
||||||
|
builder: (ctx, state) => BrushPanel(
|
||||||
|
activeBrushType: state.brushType,
|
||||||
|
activeColor: state.brushColor,
|
||||||
|
activeWidth: state.brushWidth,
|
||||||
|
activeOpacity: state.brushOpacity,
|
||||||
|
onBrushTypeChanged: (type) => context.read<EditorBloc>().add(
|
||||||
|
BrushChanged(
|
||||||
|
type: type,
|
||||||
|
color: state.brushColor,
|
||||||
|
width: state.brushWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onColorChanged: (color) => context.read<EditorBloc>().add(
|
||||||
|
BrushChanged(
|
||||||
|
type: state.brushType,
|
||||||
|
color: color,
|
||||||
|
width: state.brushWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onWidthChanged: (width) => context.read<EditorBloc>().add(
|
||||||
|
BrushChanged(
|
||||||
|
type: state.brushType,
|
||||||
|
color: state.brushColor,
|
||||||
|
width: width,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onOpacityChanged: (opacity) {
|
||||||
|
// Phase 1 简化:opacity 仅在 marker 模式下生效
|
||||||
|
// 暂无 opacity 事件,后续扩展
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示更多选项底部面板(分享/导出/清除)
|
||||||
|
void _showMoreSheet() {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 拖拽指示条
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 清除画布
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline_rounded),
|
||||||
|
title: const Text('清除画布'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.read<EditorBloc>().add(ClearCanvas());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// 分享
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.share_rounded),
|
||||||
|
title: const Text('分享日记'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// 委托给外层的分享逻辑
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = widget.state;
|
final state = widget.state;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Layer 1: 手写画布(底层)
|
// Layer 0: 点阵背景(最底层)
|
||||||
|
CustomPaint(
|
||||||
|
painter: const DotGridPainter(),
|
||||||
|
size: Size.infinite,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Layer 1: 手写画布 + 内嵌标题
|
||||||
IgnorePointer(
|
IgnorePointer(
|
||||||
ignoring: !state.isDrawingMode,
|
ignoring: !state.isDrawingMode,
|
||||||
child: HandwritingCanvas(
|
child: Column(
|
||||||
brushType: state.brushType,
|
children: [
|
||||||
brushColor: state.brushColor,
|
// 内嵌标题输入框
|
||||||
brushWidth: state.brushWidth,
|
Padding(
|
||||||
strokes: state.strokes,
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
onStrokeCompleted: (stroke) {
|
child: TextField(
|
||||||
context.read<EditorBloc>().add(StrokeCompleted(stroke));
|
controller: _titleController,
|
||||||
},
|
style: TextStyle(
|
||||||
|
fontFamily: 'Quicksand',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '给日记起个标题吧...',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
fontFamily: 'Quicksand',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.25),
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
context.read<EditorBloc>().add(TitleChanged(value));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 画布区域
|
||||||
|
Expanded(
|
||||||
|
child: HandwritingCanvas(
|
||||||
|
brushType: state.brushType,
|
||||||
|
brushColor: state.brushColor,
|
||||||
|
brushWidth: state.brushWidth,
|
||||||
|
strokes: state.strokes,
|
||||||
|
onStrokeCompleted: (stroke) {
|
||||||
|
context.read<EditorBloc>().add(StrokeCompleted(stroke));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -426,7 +800,7 @@ class _EditorStackState extends State<_EditorStack> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// 图片选择覆盖层(图片工具激活时显示)
|
// 图片选择覆盖层(图片工具激活时显示)
|
||||||
if (state.activeTool == EditorTool.image)
|
if (state.activeTool == EditorTool.photo)
|
||||||
Center(
|
Center(
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|||||||
215
app/lib/features/editor/widgets/brush_panel.dart
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// 画笔面板 -- 底部抽屉
|
||||||
|
// 提供画笔类型/粗细/颜色/透明度设置
|
||||||
|
// 遵循 StickerPickerSheet 底部面板模式
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../bloc/editor_bloc.dart';
|
||||||
|
import 'stroke_model.dart';
|
||||||
|
|
||||||
|
/// 画笔面板 -- 底部抽屉
|
||||||
|
class BrushPanel extends StatelessWidget {
|
||||||
|
final BrushType activeBrushType;
|
||||||
|
final String activeColor;
|
||||||
|
final double activeWidth;
|
||||||
|
final double activeOpacity;
|
||||||
|
final void Function(BrushType type) onBrushTypeChanged;
|
||||||
|
final void Function(String color) onColorChanged;
|
||||||
|
final void Function(double width) onWidthChanged;
|
||||||
|
final void Function(double opacity) onOpacityChanged;
|
||||||
|
|
||||||
|
const BrushPanel({
|
||||||
|
super.key,
|
||||||
|
required this.activeBrushType,
|
||||||
|
required this.activeColor,
|
||||||
|
required this.activeWidth,
|
||||||
|
required this.activeOpacity,
|
||||||
|
required this.onBrushTypeChanged,
|
||||||
|
required this.onColorChanged,
|
||||||
|
required this.onWidthChanged,
|
||||||
|
required this.onOpacityChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _brushTypes = [
|
||||||
|
(BrushType.pen, '钢笔', Icons.gesture_rounded),
|
||||||
|
(BrushType.pencil, '铅笔', Icons.edit_rounded),
|
||||||
|
(BrushType.marker, '马克笔', Icons.brush_rounded),
|
||||||
|
(BrushType.eraser, '橡皮', Icons.auto_fix_high_rounded),
|
||||||
|
];
|
||||||
|
|
||||||
|
static const _colors = [
|
||||||
|
'#2D2420', '#E07A5F', '#81B29A', '#F2CC8F',
|
||||||
|
'#D4A5A5', '#42A5F5', '#9C27B0', '#FFFFFF',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 280,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 拖拽指示条
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 画笔类型行
|
||||||
|
_buildBrushTypeRow(context),
|
||||||
|
// 粗细滑块
|
||||||
|
_buildSizeSlider(context),
|
||||||
|
// 颜色行
|
||||||
|
_buildColorRow(context),
|
||||||
|
// 透明度滑块(仅马克笔)
|
||||||
|
if (activeBrushType == BrushType.marker)
|
||||||
|
_buildOpacitySlider(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBrushTypeRow(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: _brushTypes.map((bt) {
|
||||||
|
final isActive = activeBrushType == bt.$1;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onBrushTypeChanged(bt.$1),
|
||||||
|
child: Container(
|
||||||
|
width: 64,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isActive
|
||||||
|
? Border.all(color: AppColors.accent, width: 2)
|
||||||
|
: Border.all(color: Colors.transparent),
|
||||||
|
color: isActive
|
||||||
|
? AppColors.accent.withValues(alpha: 0.1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
bt.$3,
|
||||||
|
size: 20,
|
||||||
|
color: isActive ? AppColors.accent : Colors.grey[600],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
bt.$2,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: isActive ? AppColors.accent : Colors.grey[600],
|
||||||
|
fontWeight:
|
||||||
|
isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSizeSlider(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text('粗细',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: activeWidth,
|
||||||
|
min: 1,
|
||||||
|
max: 20,
|
||||||
|
divisions: 19,
|
||||||
|
activeColor: AppColors.accent,
|
||||||
|
label: activeWidth.round().toString(),
|
||||||
|
onChanged: onWidthChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
activeWidth.round().toString(),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildColorRow(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: _colors.map((c) {
|
||||||
|
final isActive = activeColor == c;
|
||||||
|
final color = _parseHexColor(c);
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onColorChanged(c),
|
||||||
|
child: Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: color,
|
||||||
|
border: isActive
|
||||||
|
? Border.all(color: AppColors.accent, width: 2)
|
||||||
|
: (c == '#FFFFFF'
|
||||||
|
? Border.all(color: Colors.grey[300]!)
|
||||||
|
: null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOpacitySlider(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text('透明度',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: activeOpacity,
|
||||||
|
min: 0.1,
|
||||||
|
max: 1.0,
|
||||||
|
activeColor: AppColors.accent,
|
||||||
|
onChanged: onOpacityChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${(activeOpacity * 100).round()}%',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _parseHexColor(String hex) {
|
||||||
|
final code = hex.replaceFirst('#', '');
|
||||||
|
return Color(int.parse('FF$code', radix: 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/lib/features/editor/widgets/dot_grid_painter.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// 点阵背景画笔 — 24x24px 间距,1px 圆点
|
||||||
|
// 用于日记编辑区域提供纸质感背景
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 点阵背景画笔 -- 24x24px 间距,1px 圆点
|
||||||
|
class DotGridPainter extends CustomPainter {
|
||||||
|
const DotGridPainter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = const Color(0xFF2D2420).withOpacity(0.15)
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
const spacing = 24.0;
|
||||||
|
const dotRadius = 1.0;
|
||||||
|
|
||||||
|
for (double x = spacing; x < size.width; x += spacing) {
|
||||||
|
for (double y = spacing; y < size.height; y += spacing) {
|
||||||
|
canvas.drawCircle(Offset(x, y), dotRadius, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant DotGridPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
// 编辑器工具栏 — 底部工具面板
|
// 编辑器工具栏 — 底部单行 6 按钮面板
|
||||||
//
|
//
|
||||||
// 三段式布局:
|
// 精简布局:
|
||||||
// - 工具选择行(画笔/选择/文字/贴纸/照片)
|
// - 单行 6 个工具按钮(贴纸/模板/画笔/照片/文字/更多)
|
||||||
// - 工具选项行(颜色/大小 — 根据当前工具动态变化)
|
// - 高度 72px + 底部安全区
|
||||||
// - 操作行(撤销/重做/清除)
|
// - 每个按钮:Column(icon 20px + label 10px),最小 36x36
|
||||||
|
//
|
||||||
|
// 详细选项已移至独立面板:
|
||||||
|
// - 画笔选项 → BrushPanel(底部抽屉)
|
||||||
|
// - 撤销/重做 → 顶栏
|
||||||
|
// - 清除 → 顶栏
|
||||||
//
|
//
|
||||||
// 设计规范:触摸目标 ≥ 44px,圆角 22px (pill)
|
// 设计规范:触摸目标 ≥ 44px,圆角 22px (pill)
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../core/constants/design_tokens.dart';
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
|
||||||
import '../bloc/editor_bloc.dart';
|
import '../bloc/editor_bloc.dart';
|
||||||
|
|
||||||
/// 工具栏高度
|
/// 编辑器工具栏 — 精简版
|
||||||
const double _toolbarHeight = 160;
|
|
||||||
|
|
||||||
/// 编辑器工具栏
|
|
||||||
class EditorToolbar extends StatelessWidget {
|
class EditorToolbar extends StatelessWidget {
|
||||||
final EditorState state;
|
final EditorState state;
|
||||||
final ValueChanged<EditorEvent> onEvent;
|
final ValueChanged<EditorEvent> onEvent;
|
||||||
@@ -30,61 +30,35 @@ class EditorToolbar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: _toolbarHeight,
|
height: 72 + MediaQuery.of(context).padding.bottom,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
boxShadow: [
|
border: Border(
|
||||||
BoxShadow(
|
top: BorderSide(
|
||||||
color: Colors.black.withValues(alpha: 0.08),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, -2),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Padding(
|
||||||
children: [
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
|
||||||
// 工具选择行
|
child: Row(
|
||||||
_buildToolRow(context, colorScheme),
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
const Divider(height: 1),
|
children: [
|
||||||
|
_toolBtn(context, EditorTool.sticker, Icons.emoji_emotions_rounded, '贴纸'),
|
||||||
// 工具选项行(颜色/大小)
|
_toolBtn(context, EditorTool.template, Icons.dashboard_customize_rounded, '模板'),
|
||||||
_buildOptionsRow(context, colorScheme),
|
_toolBtn(context, EditorTool.brush, Icons.gesture_rounded, '画笔'),
|
||||||
const Divider(height: 1),
|
_toolBtn(context, EditorTool.photo, Icons.add_photo_alternate_rounded, '照片'),
|
||||||
|
_toolBtn(context, EditorTool.text, Icons.text_fields_rounded, '文字'),
|
||||||
// 操作行(撤销/重做/清除)
|
_toolBtn(context, EditorTool.more, Icons.more_horiz_rounded, '更多'),
|
||||||
_buildActionRow(context, colorScheme),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
/// 工具按钮 — icon + label 垂直排列
|
||||||
// 工具选择行
|
Widget _toolBtn(
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
Widget _buildToolRow(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return SizedBox(
|
|
||||||
height: 52,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
_toolButton(context, EditorTool.pen, Icons.gesture_rounded, '钢笔'),
|
|
||||||
_toolButton(context, EditorTool.pencil, Icons.edit_rounded, '铅笔'),
|
|
||||||
_toolButton(context, EditorTool.marker, Icons.brush_rounded, '马克笔'),
|
|
||||||
_toolButton(context, EditorTool.eraser, Icons.auto_fix_high_rounded, '橡皮'),
|
|
||||||
_toolButton(context, EditorTool.select, Icons.near_me_rounded, '选择'),
|
|
||||||
_toolButton(context, EditorTool.text, Icons.text_fields_rounded, '文字'),
|
|
||||||
_toolButton(context, EditorTool.sticker, Icons.emoji_emotions_rounded, '贴纸'),
|
|
||||||
_toolButton(context, EditorTool.image, Icons.add_photo_alternate_rounded, '照片'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _toolButton(
|
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
EditorTool tool,
|
EditorTool tool,
|
||||||
IconData icon,
|
IconData icon,
|
||||||
@@ -93,265 +67,37 @@ class EditorToolbar extends StatelessWidget {
|
|||||||
final isActive = state.activeTool == tool;
|
final isActive = state.activeTool == tool;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return SizedBox(
|
return GestureDetector(
|
||||||
width: 44,
|
onTap: () => onEvent(ToolChanged(tool)),
|
||||||
height: 44,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: IconButton(
|
child: Container(
|
||||||
onPressed: () => onEvent(ToolChanged(tool)),
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
icon: Icon(icon, size: 22),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
color: isActive ? colorScheme.primary : colorScheme.onSurface.withValues(alpha: 0.5),
|
child: Column(
|
||||||
style: IconButton.styleFrom(
|
mainAxisSize: MainAxisSize.min,
|
||||||
backgroundColor: isActive
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
? colorScheme.primaryContainer.withValues(alpha: 0.3)
|
children: [
|
||||||
: Colors.transparent,
|
Icon(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
icon,
|
||||||
),
|
size: 20,
|
||||||
tooltip: label,
|
color: isActive
|
||||||
),
|
? colorScheme.primary
|
||||||
);
|
: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 工具选项行(颜色 + 大小)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
static const _colors = [
|
|
||||||
'#2D2420', // 主文字
|
|
||||||
'#E07A5F', // 珊瑚
|
|
||||||
'#81B29A', // 鼠尾草绿
|
|
||||||
'#F2CC8F', // 暖金
|
|
||||||
'#D4A5A5', // 玫瑰粉
|
|
||||||
'#42A5F5', // 信息蓝
|
|
||||||
'#9C27B0', // 紫色
|
|
||||||
'#FFFFFF', // 白色
|
|
||||||
];
|
|
||||||
|
|
||||||
static const _widths = [1.5, 3.0, 5.0, 8.0, 12.0];
|
|
||||||
|
|
||||||
Widget _buildOptionsRow(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
// 画笔模式:颜色 + 粗细
|
|
||||||
if (state.isDrawingMode) {
|
|
||||||
return _buildBrushOptions(context, colorScheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文字工具提示
|
|
||||||
if (state.activeTool == EditorTool.text) {
|
|
||||||
return const SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: Center(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.text_fields, size: 16),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('点击画布输入文字', style: TextStyle(fontSize: 13)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 贴纸工具提示
|
|
||||||
if (state.activeTool == EditorTool.sticker) {
|
|
||||||
return const SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: Center(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.emoji_emotions_outlined, size: 16),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('选择一个贴纸放到日记上', style: TextStyle(fontSize: 13)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片工具提示
|
|
||||||
if (state.activeTool == EditorTool.image) {
|
|
||||||
return const SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: Center(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.add_photo_alternate_outlined, size: 16),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('选择照片添加到日记', style: TextStyle(fontSize: 13)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择工具
|
|
||||||
return const SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: Center(child: Text('选择元素或添加内容')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 画笔模式选项 — 颜色 + 粗细
|
|
||||||
Widget _buildBrushOptions(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// 颜色选择
|
|
||||||
Expanded(
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing12),
|
|
||||||
itemCount: _colors.length,
|
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 6),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final color = _colors[index];
|
|
||||||
final isActive = state.brushColor == color;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => onEvent(BrushChanged(
|
|
||||||
type: state.brushType,
|
|
||||||
color: color,
|
|
||||||
width: state.brushWidth,
|
|
||||||
)),
|
|
||||||
child: Container(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _parseHexColor(color),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: isActive
|
|
||||||
? Border.all(color: colorScheme.primary, width: 2.5)
|
|
||||||
: Border.all(color: Colors.grey.shade300, width: 1),
|
|
||||||
),
|
|
||||||
child: color == '#FFFFFF'
|
|
||||||
? const Icon(Icons.check, size: 16, color: Colors.grey)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
// 分隔线
|
label,
|
||||||
Container(width: 1, height: 24, color: colorScheme.outline.withValues(alpha: 0.2)),
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
// 笔刷大小
|
color: isActive
|
||||||
SizedBox(
|
? colorScheme.primary
|
||||||
width: 160,
|
: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
child: Row(
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: _widths.map((w) {
|
|
||||||
final isActive = (state.brushWidth - w).abs() < 0.5;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => onEvent(BrushChanged(
|
|
||||||
type: state.brushType,
|
|
||||||
color: state.brushColor,
|
|
||||||
width: w,
|
|
||||||
)),
|
|
||||||
child: Container(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: isActive
|
|
||||||
? Border.all(color: colorScheme.primary, width: 2)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
width: (w / 12 * 16 + 4).clamp(4, 20),
|
|
||||||
height: (w / 12 * 16 + 4).clamp(4, 20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _parseHexColor(state.brushColor),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 操作行
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
Widget _buildActionRow(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
// 撤销
|
|
||||||
IconButton(
|
|
||||||
onPressed: state.strokes.isNotEmpty
|
|
||||||
? () => onEvent(Undo())
|
|
||||||
: null,
|
|
||||||
icon: const Icon(Icons.undo_rounded),
|
|
||||||
tooltip: '撤销',
|
|
||||||
),
|
|
||||||
|
|
||||||
// 重做
|
|
||||||
IconButton(
|
|
||||||
onPressed: state.redoStack.isNotEmpty
|
|
||||||
? () => onEvent(Redo())
|
|
||||||
: null,
|
|
||||||
icon: const Icon(Icons.redo_rounded),
|
|
||||||
tooltip: '重做',
|
|
||||||
),
|
|
||||||
|
|
||||||
// 清除
|
|
||||||
IconButton(
|
|
||||||
onPressed: state.strokes.isNotEmpty || state.elements.isNotEmpty
|
|
||||||
? () => onEvent(ClearCanvas())
|
|
||||||
: null,
|
|
||||||
icon: const Icon(Icons.delete_outline_rounded),
|
|
||||||
tooltip: '清除',
|
|
||||||
),
|
|
||||||
|
|
||||||
// 保存状态指示
|
|
||||||
if (state.isDirty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
|
|
||||||
child: Text(
|
|
||||||
'未保存',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (state.lastSavedAt != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
|
|
||||||
child: Text(
|
|
||||||
'已保存',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.success,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 工具函数
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
Color _parseHexColor(String hex) {
|
|
||||||
final hexStr = hex.replaceFirst('#', '');
|
|
||||||
if (hexStr.length != 6) return const Color(0xFF2D2420);
|
|
||||||
final value = int.tryParse(hexStr, radix: 16);
|
|
||||||
if (value == null) return const Color(0xFF2D2420);
|
|
||||||
return Color(0xFF000000 + value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
157
app/lib/features/editor/widgets/tag_panel.dart
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// 标签面板 -- 底部抽屉
|
||||||
|
// 支持添加/移除自定义标签 + 推荐标签快捷选择
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// 标签面板 -- 底部抽屉
|
||||||
|
class TagPanel extends StatefulWidget {
|
||||||
|
final List<String> selectedTags;
|
||||||
|
final void Function(String tag) onTagAdded;
|
||||||
|
final void Function(String tag) onTagRemoved;
|
||||||
|
|
||||||
|
const TagPanel({
|
||||||
|
super.key,
|
||||||
|
required this.selectedTags,
|
||||||
|
required this.onTagAdded,
|
||||||
|
required this.onTagRemoved,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TagPanel> createState() => _TagPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TagPanelState extends State<TagPanel> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
final _focusNode = FocusNode();
|
||||||
|
|
||||||
|
static const _suggestedTags = [
|
||||||
|
'日常', '学习', '读书', '心情', '学校', '旅行',
|
||||||
|
'美食', '运动', '音乐', '梦想',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitTag() {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
widget.onTagAdded(text);
|
||||||
|
_controller.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 240),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 拖拽指示条
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 已选标签区
|
||||||
|
if (widget.selectedTags.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: widget.selectedTags
|
||||||
|
.map((tag) => Chip(
|
||||||
|
label: Text('#$tag',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
backgroundColor: const Color(0xFFFFF3E6),
|
||||||
|
labelStyle: const TextStyle(color: AppColors.accent),
|
||||||
|
deleteIconColor: AppColors.accent,
|
||||||
|
onDeleted: () => widget.onTagRemoved(tag),
|
||||||
|
materialTapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 输入框
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '添加标签,回车确认',
|
||||||
|
hintStyle: TextStyle(fontSize: 14, color: Colors.grey[400]),
|
||||||
|
prefixIcon:
|
||||||
|
const Icon(Icons.tag, size: 20, color: AppColors.accent),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey[300]!),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey[300]!),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.accent),
|
||||||
|
),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _submitTag(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 推荐标签
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: _suggestedTags
|
||||||
|
.where((t) => !widget.selectedTags.contains(t))
|
||||||
|
.map((tag) => ActionChip(
|
||||||
|
label:
|
||||||
|
Text('#$tag', style: const TextStyle(fontSize: 12)),
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
onPressed: () => widget.onTagAdded(tag),
|
||||||
|
materialTapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/lib/features/editor/widgets/text_format_bar.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// 文字格式栏 -- 浮动在选中文字元素上方
|
||||||
|
// 提供加粗/斜体/下划线/颜色/对齐 切换
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// 文字格式栏 -- 浮动在选中文字元素上方
|
||||||
|
class TextFormatBar extends StatelessWidget {
|
||||||
|
final bool bold;
|
||||||
|
final bool italic;
|
||||||
|
final bool underline;
|
||||||
|
final String? color;
|
||||||
|
final int alignment; // 0=left, 1=center, 2=right
|
||||||
|
final void Function({
|
||||||
|
bool? bold,
|
||||||
|
bool? italic,
|
||||||
|
bool? underline,
|
||||||
|
String? color,
|
||||||
|
int? alignment,
|
||||||
|
}) onFormatChanged;
|
||||||
|
|
||||||
|
const TextFormatBar({
|
||||||
|
super.key,
|
||||||
|
this.bold = false,
|
||||||
|
this.italic = false,
|
||||||
|
this.underline = false,
|
||||||
|
this.color,
|
||||||
|
this.alignment = 0,
|
||||||
|
required this.onFormatChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _colors = ['#2D2420', '#E07A5F', '#81B29A', '#42A5F5'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
border: Border.all(color: Colors.grey[200]!),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// B/I/U toggles
|
||||||
|
_toggleBtn('B', bold, () => onFormatChanged(bold: !bold)),
|
||||||
|
_toggleBtn('I', italic, () => onFormatChanged(italic: !italic)),
|
||||||
|
_toggleBtn(
|
||||||
|
'U', underline, () => onFormatChanged(underline: !underline)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Container(width: 1, height: 20, color: Colors.grey[300]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// Color dots
|
||||||
|
..._colors.map((c) => _colorDot(c, color == c)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Container(width: 1, height: 20, color: Colors.grey[300]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// Alignment
|
||||||
|
_alignBtn(Icons.format_align_left, 0),
|
||||||
|
_alignBtn(Icons.format_align_center, 1),
|
||||||
|
_alignBtn(Icons.format_align_right, 2),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _toggleBtn(String label, bool active, VoidCallback onTap) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? AppColors.accent : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: active ? Colors.white : Colors.grey[700],
|
||||||
|
fontStyle: label == 'I' ? FontStyle.italic : FontStyle.normal,
|
||||||
|
decoration:
|
||||||
|
label == 'U' ? TextDecoration.underline : TextDecoration.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colorDot(String hex, bool active) {
|
||||||
|
final code = hex.replaceFirst('#', '');
|
||||||
|
final color = Color(int.parse('FF$code', radix: 16));
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onFormatChanged(color: hex),
|
||||||
|
child: Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: color,
|
||||||
|
border:
|
||||||
|
active ? Border.all(color: AppColors.accent, width: 2) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _alignBtn(IconData icon, int align) {
|
||||||
|
final active = alignment == align;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onFormatChanged(alignment: align),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
active ? AppColors.accent.withValues(alpha: 0.1) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: active ? AppColors.accent : Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,9 @@ final class HomeLoaded extends HomeState {
|
|||||||
/// 总日记数(spec §3.4 quick-stats)
|
/// 总日记数(spec §3.4 quick-stats)
|
||||||
final int totalCount;
|
final int totalCount;
|
||||||
|
|
||||||
|
/// 今日天气(从今日日记中提取,null 则默认晴)
|
||||||
|
final Weather? todayWeather;
|
||||||
|
|
||||||
const HomeLoaded({
|
const HomeLoaded({
|
||||||
this.recentJournals = const [],
|
this.recentJournals = const [],
|
||||||
this.hasTodayEntry = false,
|
this.hasTodayEntry = false,
|
||||||
@@ -61,6 +64,7 @@ final class HomeLoaded extends HomeState {
|
|||||||
this.streakDays = 0,
|
this.streakDays = 0,
|
||||||
this.monthCount = 0,
|
this.monthCount = 0,
|
||||||
this.totalCount = 0,
|
this.totalCount = 0,
|
||||||
|
this.todayWeather,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,10 +115,21 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
|||||||
// 推算连续天数
|
// 推算连续天数
|
||||||
final streakDays = _calculateStreak(journals);
|
final streakDays = _calculateStreak(journals);
|
||||||
|
|
||||||
// 本月日记数 & 总数(spec §3.4 quick-stats)
|
// 本月日记数(spec §3.4 quick-stats)
|
||||||
final monthCount = journals.where((j) =>
|
final monthCount = journals.where((j) =>
|
||||||
j.date.year == today.year && j.date.month == today.month).length;
|
j.date.year == today.year && j.date.month == today.month).length;
|
||||||
final totalCount = journals.length;
|
|
||||||
|
// 总日记数 — 使用仓库计数方法(不受分页限制)
|
||||||
|
final totalCount = await _journalRepo.getJournalCount();
|
||||||
|
|
||||||
|
// 今日天气 — 从今日日记中提取
|
||||||
|
final todayJournal = journals.firstWhere(
|
||||||
|
(j) => j.date.year == today.year &&
|
||||||
|
j.date.month == today.month &&
|
||||||
|
j.date.day == today.day,
|
||||||
|
orElse: () => journals.first, // fallback for type safety
|
||||||
|
);
|
||||||
|
final todayWeather = hasTodayEntry ? todayJournal.weather : null;
|
||||||
|
|
||||||
emit(HomeLoaded(
|
emit(HomeLoaded(
|
||||||
recentJournals: journals,
|
recentJournals: journals,
|
||||||
@@ -123,6 +138,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
|||||||
streakDays: streakDays,
|
streakDays: streakDays,
|
||||||
monthCount: monthCount,
|
monthCount: monthCount,
|
||||||
totalCount: totalCount,
|
totalCount: totalCount,
|
||||||
|
todayWeather: todayWeather,
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(const HomeLoaded()); // 空状态而非错误,离线友好
|
emit(const HomeLoaded()); // 空状态而非错误,离线友好
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class _HomeView extends StatelessWidget {
|
|||||||
|
|
||||||
_MoodSelectorCard(
|
_MoodSelectorCard(
|
||||||
topMood: state.topMood,
|
topMood: state.topMood,
|
||||||
weather: const _Weather(icon: '☀', label: '晴 26°'),
|
todayWeather: state.todayWeather,
|
||||||
onMoodTap: (_) => context.push('/editor'),
|
onMoodTap: (_) => context.push('/editor'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: DesignTokens.spacing20),
|
const SizedBox(height: DesignTokens.spacing20),
|
||||||
@@ -261,12 +261,12 @@ class _StreakBadge extends StatelessWidget {
|
|||||||
class _MoodSelectorCard extends StatelessWidget {
|
class _MoodSelectorCard extends StatelessWidget {
|
||||||
const _MoodSelectorCard({
|
const _MoodSelectorCard({
|
||||||
required this.topMood,
|
required this.topMood,
|
||||||
required this.weather,
|
required this.todayWeather,
|
||||||
required this.onMoodTap,
|
required this.onMoodTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Mood? topMood;
|
final Mood? topMood;
|
||||||
final _Weather weather;
|
final Weather? todayWeather;
|
||||||
final ValueChanged<Mood> onMoodTap;
|
final ValueChanged<Mood> onMoodTap;
|
||||||
|
|
||||||
static const _moods = [
|
static const _moods = [
|
||||||
@@ -277,6 +277,14 @@ class _MoodSelectorCard extends StatelessWidget {
|
|||||||
('🤔', '思考', Mood.thinking),
|
('🤔', '思考', Mood.thinking),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static const _weatherMap = {
|
||||||
|
Weather.sunny: ('☀', '晴'),
|
||||||
|
Weather.cloudy: ('☁', '多云'),
|
||||||
|
Weather.rainy: ('🌧', '雨'),
|
||||||
|
Weather.snowy: ('❄', '雪'),
|
||||||
|
Weather.windy: ('💨', '风'),
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -306,10 +314,13 @@ class _MoodSelectorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Builder(builder: (context) {
|
||||||
'${weather.icon} ${weather.label}',
|
final w = _weatherMap[todayWeather] ?? _weatherMap[Weather.sunny]!;
|
||||||
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant),
|
return Text(
|
||||||
),
|
'${w.$1} ${w.$2}',
|
||||||
|
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: DesignTokens.spacing12),
|
const SizedBox(height: DesignTokens.spacing12),
|
||||||
@@ -376,12 +387,6 @@ class _MoodOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Weather {
|
|
||||||
const _Weather({required this.icon, required this.label});
|
|
||||||
final String icon;
|
|
||||||
final String label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 4. "今天的日记" 渐变卡片 + 浮动写按钮
|
/// 4. "今天的日记" 渐变卡片 + 浮动写按钮
|
||||||
class _TodayCard extends StatelessWidget {
|
class _TodayCard extends StatelessWidget {
|
||||||
const _TodayCard({required this.hasTodayEntry, required this.onTap});
|
const _TodayCard({required this.hasTodayEntry, required this.onTap});
|
||||||
@@ -621,7 +626,9 @@ class _JournalCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final moodColor = AppColors.moodColors[journal.mood.value] ?? AppColors.accent;
|
final moodColor = AppColors.moodColors[journal.mood.value] ?? AppColors.accent;
|
||||||
final excerpt = journal.tags.isEmpty ? '点击查看详情' : journal.tags.take(3).map((t) => '#$t').join(' ');
|
final excerpt = journal.contentExcerpt?.isNotEmpty == true
|
||||||
|
? journal.contentExcerpt!
|
||||||
|
: (journal.tags.isEmpty ? '点击查看详情' : journal.tags.take(3).map((t) => '#$t').join(' '));
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
|
|||||||
@@ -922,7 +922,8 @@ class _JournalCard extends StatelessWidget {
|
|||||||
try {
|
try {
|
||||||
final dt = DateTime.parse(isoStr);
|
final dt = DateTime.parse(isoStr);
|
||||||
return DateFormat('MM-dd').format(dt);
|
return DateFormat('MM-dd').format(dt);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
debugPrint('ParentPage._formatDate 失败: $e');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,7 +408,8 @@ class _LiveStatsBarState extends State<_LiveStatsBar> {
|
|||||||
_streakDays = streak;
|
_streakDays = streak;
|
||||||
_monthCount = monthCount;
|
_monthCount = monthCount;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
debugPrint('ProfilePage._loadStats 失败: $e');
|
||||||
// 保持默认 0 值
|
// 保持默认 0 值
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 搜索 BLoC — 标签+心情筛选日记
|
// 搜索 BLoC — 关键词+标签+心情筛选日记
|
||||||
//
|
//
|
||||||
// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError
|
// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError
|
||||||
// Phase 1 使用简单的标签+心情筛选,后续可扩展全文搜索。
|
// 支持关键词搜索、标签筛选、心情筛选、结果分类 tab。
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
@@ -11,16 +11,21 @@ import '../../../data/repositories/journal_repository.dart';
|
|||||||
part 'search_event.dart';
|
part 'search_event.dart';
|
||||||
part 'search_state.dart';
|
part 'search_state.dart';
|
||||||
|
|
||||||
/// 搜索 BLoC — 处理标签和心情筛选日记的状态转换
|
/// 搜索 BLoC
|
||||||
class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
||||||
final JournalRepository _journalRepo;
|
final JournalRepository _journalRepo;
|
||||||
|
|
||||||
|
/// 内存搜索历史(最多 10 条)
|
||||||
|
final List<String> _searchHistory = [];
|
||||||
|
|
||||||
SearchBloc({required JournalRepository journalRepository})
|
SearchBloc({required JournalRepository journalRepository})
|
||||||
: _journalRepo = journalRepository,
|
: _journalRepo = journalRepository,
|
||||||
super(const SearchInitial()) {
|
super(const SearchInitial()) {
|
||||||
on<SearchByMood>(_onSearchByMood);
|
on<SearchByMood>(_onSearchByMood);
|
||||||
on<SearchByTag>(_onSearchByTag);
|
on<SearchByTag>(_onSearchByTag);
|
||||||
|
on<SearchByKeyword>(_onSearchByKeyword);
|
||||||
on<SearchClear>(_onSearchClear);
|
on<SearchClear>(_onSearchClear);
|
||||||
|
on<SearchTabChanged>(_onSearchTabChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按心情筛选日记
|
/// 按心情筛选日记
|
||||||
@@ -31,7 +36,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
|||||||
emit(const SearchLoading());
|
emit(const SearchLoading());
|
||||||
try {
|
try {
|
||||||
if (event.mood == null) {
|
if (event.mood == null) {
|
||||||
emit(const SearchLoaded());
|
emit(SearchLoaded(searchHistory: List.unmodifiable(_searchHistory)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final results = await _journalRepo.getJournals(
|
final results = await _journalRepo.getJournals(
|
||||||
@@ -42,6 +47,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
|||||||
emit(SearchLoaded(
|
emit(SearchLoaded(
|
||||||
results: results,
|
results: results,
|
||||||
activeMood: event.mood!.value,
|
activeMood: event.mood!.value,
|
||||||
|
searchHistory: List.unmodifiable(_searchHistory),
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(const SearchError('搜索失败,请重试'));
|
emit(const SearchError('搜索失败,请重试'));
|
||||||
@@ -55,6 +61,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
|||||||
) async {
|
) async {
|
||||||
emit(const SearchLoading());
|
emit(const SearchLoading());
|
||||||
try {
|
try {
|
||||||
|
_addToHistory(event.tag);
|
||||||
final results = await _journalRepo.getJournals(
|
final results = await _journalRepo.getJournals(
|
||||||
tag: event.tag,
|
tag: event.tag,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -63,6 +70,47 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
|||||||
emit(SearchLoaded(
|
emit(SearchLoaded(
|
||||||
results: results,
|
results: results,
|
||||||
activeTag: event.tag,
|
activeTag: event.tag,
|
||||||
|
searchHistory: List.unmodifiable(_searchHistory),
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(const SearchError('搜索失败,请重试'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关键词搜索 — 在标题中匹配关键词
|
||||||
|
Future<void> _onSearchByKeyword(
|
||||||
|
SearchByKeyword event,
|
||||||
|
Emitter<SearchState> emit,
|
||||||
|
) async {
|
||||||
|
final keyword = event.keyword.trim();
|
||||||
|
if (keyword.isEmpty) {
|
||||||
|
emit(SearchLoaded(searchHistory: List.unmodifiable(_searchHistory)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(const SearchLoading());
|
||||||
|
try {
|
||||||
|
_addToHistory(keyword);
|
||||||
|
// 获取所有日记并在客户端按关键词过滤
|
||||||
|
final allJournals = await _journalRepo.getJournals(
|
||||||
|
page: 1,
|
||||||
|
pageSize: 200,
|
||||||
|
);
|
||||||
|
final lowerKeyword = keyword.toLowerCase();
|
||||||
|
final results = allJournals.where((j) {
|
||||||
|
final titleMatch = j.title.toLowerCase().contains(lowerKeyword);
|
||||||
|
final excerptMatch = (j.contentExcerpt ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.contains(lowerKeyword);
|
||||||
|
final tagMatch =
|
||||||
|
j.tags.any((t) => t.toLowerCase().contains(lowerKeyword));
|
||||||
|
return titleMatch || excerptMatch || tagMatch;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
emit(SearchLoaded(
|
||||||
|
results: results,
|
||||||
|
activeKeyword: keyword,
|
||||||
|
searchHistory: List.unmodifiable(_searchHistory),
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(const SearchError('搜索失败,请重试'));
|
emit(const SearchError('搜索失败,请重试'));
|
||||||
@@ -74,6 +122,26 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
|||||||
SearchClear event,
|
SearchClear event,
|
||||||
Emitter<SearchState> emit,
|
Emitter<SearchState> emit,
|
||||||
) {
|
) {
|
||||||
emit(const SearchLoaded());
|
emit(SearchLoaded(searchHistory: List.unmodifiable(_searchHistory)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换结果分类 tab
|
||||||
|
void _onSearchTabChanged(
|
||||||
|
SearchTabChanged event,
|
||||||
|
Emitter<SearchState> emit,
|
||||||
|
) {
|
||||||
|
final current = state;
|
||||||
|
if (current is SearchLoaded) {
|
||||||
|
emit(current.copyWith(activeTab: event.tab));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加到搜索历史(去重,最多 10 条)
|
||||||
|
void _addToHistory(String keyword) {
|
||||||
|
_searchHistory.remove(keyword);
|
||||||
|
_searchHistory.insert(0, keyword);
|
||||||
|
if (_searchHistory.length > 10) {
|
||||||
|
_searchHistory.removeLast();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,19 @@ final class SearchByTag extends SearchEvent {
|
|||||||
const SearchByTag(this.tag);
|
const SearchByTag(this.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 关键词搜索
|
||||||
|
final class SearchByKeyword extends SearchEvent {
|
||||||
|
final String keyword;
|
||||||
|
const SearchByKeyword(this.keyword);
|
||||||
|
}
|
||||||
|
|
||||||
/// 清除搜索结果
|
/// 清除搜索结果
|
||||||
final class SearchClear extends SearchEvent {
|
final class SearchClear extends SearchEvent {
|
||||||
const SearchClear();
|
const SearchClear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 切换搜索结果分类 tab
|
||||||
|
final class SearchTabChanged extends SearchEvent {
|
||||||
|
final SearchResultTab tab;
|
||||||
|
const SearchTabChanged(this.tab);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
part of 'search_bloc.dart';
|
part of 'search_bloc.dart';
|
||||||
|
|
||||||
|
/// 搜索结果分类 tab
|
||||||
|
enum SearchResultTab {
|
||||||
|
all('全部'),
|
||||||
|
journal('日记'),
|
||||||
|
template('模板'),
|
||||||
|
tag('标签');
|
||||||
|
|
||||||
|
const SearchResultTab(this.label);
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
/// 搜索状态基类
|
/// 搜索状态基类
|
||||||
sealed class SearchState {
|
sealed class SearchState {
|
||||||
const SearchState();
|
const SearchState();
|
||||||
@@ -19,7 +30,7 @@ final class SearchLoading extends SearchState {
|
|||||||
|
|
||||||
/// 搜索结果已加载
|
/// 搜索结果已加载
|
||||||
final class SearchLoaded extends SearchState {
|
final class SearchLoaded extends SearchState {
|
||||||
/// 搜索结果列表(空列表表示无匹配)
|
/// 日记搜索结果列表
|
||||||
final List<JournalEntry> results;
|
final List<JournalEntry> results;
|
||||||
|
|
||||||
/// 当前活跃的心情筛选条件
|
/// 当前活跃的心情筛选条件
|
||||||
@@ -28,24 +39,47 @@ final class SearchLoaded extends SearchState {
|
|||||||
/// 当前活跃的标签筛选条件
|
/// 当前活跃的标签筛选条件
|
||||||
final String? activeTag;
|
final String? activeTag;
|
||||||
|
|
||||||
|
/// 当前活跃的关键词
|
||||||
|
final String? activeKeyword;
|
||||||
|
|
||||||
|
/// 当前选中的结果分类 tab
|
||||||
|
final SearchResultTab activeTab;
|
||||||
|
|
||||||
|
/// 搜索历史(内存中保存,最多 10 条)
|
||||||
|
final List<String> searchHistory;
|
||||||
|
|
||||||
const SearchLoaded({
|
const SearchLoaded({
|
||||||
this.results = const [],
|
this.results = const [],
|
||||||
this.activeMood,
|
this.activeMood,
|
||||||
this.activeTag,
|
this.activeTag,
|
||||||
|
this.activeKeyword,
|
||||||
|
this.activeTab = SearchResultTab.all,
|
||||||
|
this.searchHistory = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 是否有活跃的筛选条件
|
/// 是否有活跃的筛选条件
|
||||||
bool get hasActiveFilter => activeMood != null || activeTag != null;
|
bool get hasActiveFilter =>
|
||||||
|
activeMood != null || activeTag != null || activeKeyword != null;
|
||||||
|
|
||||||
SearchLoaded copyWith({
|
SearchLoaded copyWith({
|
||||||
List<JournalEntry>? results,
|
List<JournalEntry>? results,
|
||||||
String? activeMood,
|
String? activeMood,
|
||||||
|
bool clearActiveMood = false,
|
||||||
String? activeTag,
|
String? activeTag,
|
||||||
|
bool clearActiveTag = false,
|
||||||
|
String? activeKeyword,
|
||||||
|
bool clearActiveKeyword = false,
|
||||||
|
SearchResultTab? activeTab,
|
||||||
|
List<String>? searchHistory,
|
||||||
}) =>
|
}) =>
|
||||||
SearchLoaded(
|
SearchLoaded(
|
||||||
results: results ?? this.results,
|
results: results ?? this.results,
|
||||||
activeMood: activeMood ?? this.activeMood,
|
activeMood: clearActiveMood ? null : (activeMood ?? this.activeMood),
|
||||||
activeTag: activeTag ?? this.activeTag,
|
activeTag: clearActiveTag ? null : (activeTag ?? this.activeTag),
|
||||||
|
activeKeyword:
|
||||||
|
clearActiveKeyword ? null : (activeKeyword ?? this.activeKeyword),
|
||||||
|
activeTab: activeTab ?? this.activeTab,
|
||||||
|
searchHistory: searchHistory ?? this.searchHistory,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import 'package:nuanji_app/core/theme/app_radius.dart';
|
|||||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||||
import '../bloc/template_bloc.dart';
|
import '../bloc/template_bloc.dart';
|
||||||
|
|
||||||
|
/// 视图模式
|
||||||
|
enum _ViewMode { daily, weekly, monthly }
|
||||||
|
|
||||||
/// 模板画廊页面 — 浏览和选择日记模板
|
/// 模板画廊页面 — 浏览和选择日记模板
|
||||||
class TemplateGalleryPage extends StatefulWidget {
|
class TemplateGalleryPage extends StatefulWidget {
|
||||||
const TemplateGalleryPage({super.key});
|
const TemplateGalleryPage({super.key});
|
||||||
@@ -18,6 +21,7 @@ class TemplateGalleryPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
|
class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
|
||||||
late final TemplateBloc _bloc;
|
late final TemplateBloc _bloc;
|
||||||
|
_ViewMode _viewMode = _ViewMode.daily;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -36,91 +40,189 @@ class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
final surfaceWarm = isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('模板画廊')),
|
body: SafeArea(
|
||||||
body: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: _bloc,
|
listenable: _bloc,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final state = _bloc.state;
|
final state = _bloc.state;
|
||||||
|
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.errorMessage != null) {
|
if (state.errorMessage != null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
|
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: _bloc.load,
|
onPressed: _bloc.load,
|
||||||
child: const Text('重试'),
|
child: const Text('重试'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final categories = state.categories;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
// 分类选择器
|
|
||||||
SizedBox(
|
|
||||||
height: 48,
|
|
||||||
child: ListView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
children: categories.map((cat) {
|
|
||||||
final isSelected = cat == state.selectedCategory;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: FilterChip(
|
|
||||||
selected: isSelected,
|
|
||||||
label: Text(cat),
|
|
||||||
onSelected: (_) => _bloc.selectCategory(cat),
|
|
||||||
selectedColor: colorScheme.primaryContainer,
|
|
||||||
checkmarkColor: colorScheme.primary,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(height: 8),
|
}
|
||||||
|
|
||||||
// 模板网格
|
return Column(
|
||||||
Expanded(
|
children: [
|
||||||
child: state.filteredTemplates.isEmpty
|
// ---- 自定义顶栏 ----
|
||||||
? const Center(child: Text('暂无模板'))
|
Padding(
|
||||||
: GridView.builder(
|
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
||||||
padding: const EdgeInsets.all(16),
|
child: Row(
|
||||||
gridDelegate:
|
children: [
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
IconButton(
|
||||||
crossAxisCount: 2,
|
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||||
mainAxisSpacing: 12,
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
crossAxisSpacing: 12,
|
|
||||||
childAspectRatio: 0.78,
|
|
||||||
),
|
|
||||||
itemCount: state.filteredTemplates.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return _TemplateCard(
|
|
||||||
template: state.filteredTemplates[index],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
Text('模板画廊', style: theme.textTheme.titleLarge?.copyWith(
|
||||||
],
|
fontWeight: FontWeight.w700,
|
||||||
);
|
)),
|
||||||
},
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ---- 视图选择器 (日/周/月) ----
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ViewModeButton(
|
||||||
|
emoji: '📅', label: '日视图',
|
||||||
|
selected: _viewMode == _ViewMode.daily,
|
||||||
|
surfaceWarm: surfaceWarm,
|
||||||
|
onTap: () => setState(() => _viewMode = _ViewMode.daily),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ViewModeButton(
|
||||||
|
emoji: '📊', label: '周视图',
|
||||||
|
selected: _viewMode == _ViewMode.weekly,
|
||||||
|
surfaceWarm: surfaceWarm,
|
||||||
|
onTap: () => setState(() => _viewMode = _ViewMode.weekly),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ViewModeButton(
|
||||||
|
emoji: '📈', label: '月视图',
|
||||||
|
selected: _viewMode == _ViewMode.monthly,
|
||||||
|
surfaceWarm: surfaceWarm,
|
||||||
|
onTap: () => setState(() => _viewMode = _ViewMode.monthly),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ---- 分类选择器 ----
|
||||||
|
SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
children: state.categories.map((cat) {
|
||||||
|
final isSelected = cat == state.selectedCategory;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: FilterChip(
|
||||||
|
selected: isSelected,
|
||||||
|
label: Text(cat),
|
||||||
|
onSelected: (_) => _bloc.selectCategory(cat),
|
||||||
|
selectedColor: AppColors.accent.withValues(alpha: 0.15),
|
||||||
|
checkmarkColor: AppColors.accent,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? AppColors.accent : colorScheme.onSurface,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// ---- 模板网格 (200px 高预览) ----
|
||||||
|
Expanded(
|
||||||
|
child: state.filteredTemplates.isEmpty
|
||||||
|
? const Center(child: Text('暂无模板'))
|
||||||
|
: GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 0.52,
|
||||||
|
),
|
||||||
|
itemCount: state.filteredTemplates.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _TemplateCard(
|
||||||
|
template: state.filteredTemplates[index],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 模板卡片
|
/// 视图模式按钮
|
||||||
|
class _ViewModeButton extends StatelessWidget {
|
||||||
|
const _ViewModeButton({
|
||||||
|
required this.emoji,
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.surfaceWarm,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
final String emoji;
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final Color surfaceWarm;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? surfaceWarm : Colors.transparent,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? AppColors.accent : Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
width: selected ? 1.5 : 1,
|
||||||
|
),
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(emoji, style: const TextStyle(fontSize: 14)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(label, style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: selected ? AppColors.accent : Theme.of(context).colorScheme.onSurface,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板卡片 — 200px 预览 + 使用按钮 + 标签
|
||||||
class _TemplateCard extends StatelessWidget {
|
class _TemplateCard extends StatelessWidget {
|
||||||
const _TemplateCard({required this.template});
|
const _TemplateCard({required this.template});
|
||||||
|
|
||||||
@@ -130,6 +232,9 @@ class _TemplateCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
final secondarySoft = isDark ? AppColors.secondarySoftDark : AppColors.secondarySoftLight;
|
||||||
|
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -137,21 +242,14 @@ class _TemplateCard extends StatelessWidget {
|
|||||||
borderRadius: AppRadius.mdBorder,
|
borderRadius: AppRadius.mdBorder,
|
||||||
side: BorderSide(color: colorScheme.outlineVariant),
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: Padding(
|
||||||
onTap: () {
|
padding: const EdgeInsets.all(12),
|
||||||
// 使用模板创建日记
|
child: Column(
|
||||||
context.push('/editor?template=${template.id}');
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
},
|
children: [
|
||||||
borderRadius: AppRadius.mdBorder,
|
// 模板预览区 — 200px 高
|
||||||
child: Padding(
|
Expanded(
|
||||||
padding: const EdgeInsets.all(16),
|
child: Container(
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// 模板预览区
|
|
||||||
Container(
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
@@ -166,33 +264,88 @@ class _TemplateCard extends StatelessWidget {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
template.emoji,
|
template.emoji,
|
||||||
style: const TextStyle(fontSize: 36),
|
style: const TextStyle(fontSize: 48),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
// 模板名称
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// 模板名称
|
||||||
|
Text(
|
||||||
|
template.name,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
|
||||||
|
// 描述
|
||||||
|
if (template.description != null)
|
||||||
Text(
|
Text(
|
||||||
template.name,
|
template.description!,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 8),
|
||||||
// 描述
|
|
||||||
if (template.description != null)
|
// 标签
|
||||||
Text(
|
Wrap(
|
||||||
template.description!,
|
spacing: 6,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
runSpacing: 4,
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
children: [
|
||||||
),
|
_TagPill(label: '学生专属', bgColor: secondarySoft, textColor: AppColors.secondary),
|
||||||
maxLines: 2,
|
_TagPill(label: '简约', bgColor: tertiarySoft, textColor: AppColors.tertiary),
|
||||||
overflow: TextOverflow.ellipsis,
|
],
|
||||||
textAlign: TextAlign.center,
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// 使用按钮
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.push('/editor?template=${template.id}');
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.accent,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.pillBorder),
|
||||||
),
|
),
|
||||||
],
|
child: const Text('使用', style: TextStyle(
|
||||||
),
|
fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 标签胶囊
|
||||||
|
class _TagPill extends StatelessWidget {
|
||||||
|
const _TagPill({required this.label, required this.bgColor, required this.textColor});
|
||||||
|
final String label;
|
||||||
|
final Color bgColor;
|
||||||
|
final Color textColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
child: Text(label, style: TextStyle(
|
||||||
|
fontSize: 10, fontWeight: FontWeight.w500, color: textColor,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ void main() {
|
|||||||
|
|
||||||
// ===== 初始状态 =====
|
// ===== 初始状态 =====
|
||||||
|
|
||||||
test('初始状态:pen 工具、空笔画、空元素、非 dirty', () {
|
test('初始状态:brush 工具、空笔画、空元素、非 dirty', () {
|
||||||
expect(bloc.state.activeTool, EditorTool.pen);
|
expect(bloc.state.activeTool, EditorTool.brush);
|
||||||
expect(bloc.state.strokes, isEmpty);
|
expect(bloc.state.strokes, isEmpty);
|
||||||
expect(bloc.state.elements, isEmpty);
|
expect(bloc.state.elements, isEmpty);
|
||||||
expect(bloc.state.isDirty, isFalse);
|
expect(bloc.state.isDirty, isFalse);
|
||||||
|
|||||||
@@ -2,9 +2,18 @@ import client from '../client';
|
|||||||
import type { SchoolClass, CreateClassReq, ClassMember, PaginatedResponse } from './types';
|
import type { SchoolClass, CreateClassReq, ClassMember, PaginatedResponse } from './types';
|
||||||
|
|
||||||
export const classApi = {
|
export const classApi = {
|
||||||
|
/** 班级列表 — 后端返回纯数组,前端转换为 PaginatedResponse 格式 */
|
||||||
list: (params?: { page?: number; page_size?: number }) =>
|
list: (params?: { page?: number; page_size?: number }) =>
|
||||||
client.get<{ success: boolean; data: PaginatedResponse<SchoolClass> }>('/diary/classes', { params })
|
client.get<{ success: boolean; data: SchoolClass[] }>('/diary/classes', { params })
|
||||||
.then((r) => r.data.data),
|
.then((r) => {
|
||||||
|
const raw = r.data.data;
|
||||||
|
// 后端返回纯数组,包装为分页格式
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return { data: raw, total: raw.length, page: params?.page ?? 1, page_size: params?.page_size ?? 20 } as PaginatedResponse<SchoolClass>;
|
||||||
|
}
|
||||||
|
// 兼容:如果后端已升级为分页格式
|
||||||
|
return raw as unknown as PaginatedResponse<SchoolClass>;
|
||||||
|
}),
|
||||||
|
|
||||||
myClasses: () =>
|
myClasses: () =>
|
||||||
client.get<{ success: boolean; data: SchoolClass[] }>('/diary/classes/my')
|
client.get<{ success: boolean; data: SchoolClass[] }>('/diary/classes/my')
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface DrawerFormProps {
|
|||||||
sections?: FormSection[];
|
sections?: FormSection[];
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
columns?: 1 | 2;
|
columns?: 1 | 2;
|
||||||
form?: ReturnType<typeof Form.useForm>[0];
|
form?: ReturnType<typeof Form.useForm<Record<string, unknown>>>[0];
|
||||||
onValuesChange?: (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => void;
|
onValuesChange?: (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export function DrawerForm({
|
|||||||
form: externalForm,
|
form: externalForm,
|
||||||
onValuesChange,
|
onValuesChange,
|
||||||
}: DrawerFormProps) {
|
}: DrawerFormProps) {
|
||||||
const [internalForm] = Form.useForm();
|
const [internalForm] = Form.useForm<Record<string, unknown>>();
|
||||||
const form = externalForm ?? internalForm;
|
const form = externalForm ?? internalForm;
|
||||||
const isDark = useThemeMode();
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
@@ -45,7 +45,8 @@ export function DrawerForm({
|
|||||||
if (open) {
|
if (open) {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
if (initialValues) {
|
if (initialValues) {
|
||||||
form.setFieldsValue(initialValues);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Ant Design 6 setFieldsValue requires Store type
|
||||||
|
form.setFieldsValue(initialValues as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, initialValues, form]);
|
}, [open, initialValues, form]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useState, useEffect, useMemo } from 'react';
|
import { useCallback, useState, useEffect, useMemo } from 'react';
|
||||||
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu, message } from 'antd';
|
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu, message } from 'antd';
|
||||||
import type { MenuItemType, SubMenuType } from 'antd/es/menu/hooks/useItems';
|
import type { MenuItemType, SubMenuType } from 'antd/es/menu/interface';
|
||||||
import {
|
import {
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Tag,
|
Tag,
|
||||||
Drawer,
|
Drawer,
|
||||||
Modal,
|
|
||||||
Badge,
|
Badge,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
@@ -26,14 +25,12 @@ import { PageContainer } from '../../components/PageContainer';
|
|||||||
import { DrawerForm } from '../../components/DrawerForm';
|
import { DrawerForm } from '../../components/DrawerForm';
|
||||||
import { useCrudDrawer } from '../../hooks/useCrudDrawer';
|
import { useCrudDrawer } from '../../hooks/useCrudDrawer';
|
||||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||||
import { useApiRequest } from '../../hooks/useApiRequest';
|
|
||||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function ClassList() {
|
export default function ClassList() {
|
||||||
const isDark = useThemeMode();
|
const isDark = useThemeMode();
|
||||||
const { execute } = useApiRequest();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: classes,
|
data: classes,
|
||||||
@@ -41,13 +38,13 @@ export default function ClassList() {
|
|||||||
page,
|
page,
|
||||||
loading,
|
loading,
|
||||||
refresh,
|
refresh,
|
||||||
} = usePaginatedData<SchoolClass>(async (p, pageSize) => {
|
} = usePaginatedData<SchoolClass>(async (p: number, pageSize: number) => {
|
||||||
const result = await classApi.list({ page: p, page_size: pageSize });
|
const result = await classApi.list({ page: p, page_size: pageSize });
|
||||||
return { data: result.data, total: result.total };
|
return { data: result.data, total: result.total };
|
||||||
}, 20);
|
}, 20);
|
||||||
|
|
||||||
// --- Create/Edit drawer ---
|
// --- Create/Edit drawer ---
|
||||||
const classDrawer = useCrudDrawer<SchoolClass>({
|
const classDrawer = useCrudDrawer<{ version: number } & SchoolClass>({
|
||||||
getId: (r) => r.id,
|
getId: (r) => r.id,
|
||||||
onCreate: async (values) => {
|
onCreate: async (values) => {
|
||||||
await classApi.create({ name: values.name as string, school_name: values.school_name as string | undefined });
|
await classApi.create({ name: values.name as string, school_name: values.school_name as string | undefined });
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { commentApi } from '../../api/diary/comments';
|
|||||||
import { classApi } from '../../api/diary/classes';
|
import { classApi } from '../../api/diary/classes';
|
||||||
import type { JournalEntry, Comment, SchoolClass } from '../../api/diary/types';
|
import type { JournalEntry, Comment, SchoolClass } from '../../api/diary/types';
|
||||||
import { PageContainer } from '../../components/PageContainer';
|
import { PageContainer } from '../../components/PageContainer';
|
||||||
import { FilterBar } from '../../components/FilterBar';
|
|
||||||
import { useApiRequest } from '../../hooks/useApiRequest';
|
import { useApiRequest } from '../../hooks/useApiRequest';
|
||||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
|
|
||||||
@@ -443,7 +442,7 @@ export default function JournalList() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Divider orientation="left" style={{ fontSize: 15, fontWeight: 500 }}>
|
<Divider style={{ fontSize: 15, fontWeight: 500, textAlign: 'left' }}>
|
||||||
评论 ({comments.length})
|
评论 ({comments.length})
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ fn default_theme() -> ThemeResp {
|
|||||||
primary_color: None,
|
primary_color: None,
|
||||||
logo_url: None,
|
logo_url: None,
|
||||||
sidebar_style: None,
|
sidebar_style: None,
|
||||||
brand_name: Some("HMS 健康管理平台".into()),
|
brand_name: Some("暖记 Nuanji".into()),
|
||||||
brand_slogan: Some("新一代健康管理平台".into()),
|
brand_slogan: Some("温暖治愈的手账日记".into()),
|
||||||
brand_features: Some("患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
brand_features: Some("手写日记 · 班级管理 · 成长追踪 · 互动点评".into()),
|
||||||
brand_copyright: Some("HMS 健康管理平台 · ©汕头市智界科技有限公司".into()),
|
brand_copyright: Some("© 暖记 Nuanji".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,16 +127,16 @@ pub async fn get_public_brand() -> JsonResponse<ApiResponse<PublicBrandResp>> {
|
|||||||
JsonResponse(ApiResponse::ok(PublicBrandResp {
|
JsonResponse(ApiResponse::ok(PublicBrandResp {
|
||||||
brand_name: defaults
|
brand_name: defaults
|
||||||
.brand_name
|
.brand_name
|
||||||
.unwrap_or_else(|| "HMS 健康管理平台".into()),
|
.unwrap_or_else(|| "暖记 Nuanji".into()),
|
||||||
brand_slogan: defaults
|
brand_slogan: defaults
|
||||||
.brand_slogan
|
.brand_slogan
|
||||||
.unwrap_or_else(|| "新一代健康管理平台".into()),
|
.unwrap_or_else(|| "温暖治愈的手账日记".into()),
|
||||||
brand_features: defaults
|
brand_features: defaults
|
||||||
.brand_features
|
.brand_features
|
||||||
.unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
.unwrap_or_else(|| "手写日记 · 班级管理 · 成长追踪 · 互动点评".into()),
|
||||||
brand_copyright: defaults
|
brand_copyright: defaults
|
||||||
.brand_copyright
|
.brand_copyright
|
||||||
.unwrap_or_else(|| "HMS 健康管理平台 · ©汕头市智界科技有限公司".into()),
|
.unwrap_or_else(|| "© 暖记 Nuanji".into()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +150,8 @@ mod tests {
|
|||||||
assert!(theme.primary_color.is_none());
|
assert!(theme.primary_color.is_none());
|
||||||
assert!(theme.logo_url.is_none());
|
assert!(theme.logo_url.is_none());
|
||||||
assert!(theme.sidebar_style.is_none());
|
assert!(theme.sidebar_style.is_none());
|
||||||
assert_eq!(theme.brand_name, Some("HMS 健康管理平台".to_string()));
|
assert_eq!(theme.brand_name, Some("暖记 Nuanji".to_string()));
|
||||||
assert_eq!(theme.brand_slogan, Some("新一代健康管理平台".to_string()));
|
assert_eq!(theme.brand_slogan, Some("温暖治愈的手账日记".to_string()));
|
||||||
assert!(theme.brand_features.is_some());
|
assert!(theme.brand_features.is_some());
|
||||||
assert!(theme.brand_copyright.is_some());
|
assert!(theme.brand_copyright.is_some());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::CommentResp;
|
use crate::dto::CommentResp;
|
||||||
use crate::entity::{class_member, comment, journal_entry};
|
use crate::entity::{class_member, comment, journal_entry};
|
||||||
use crate::error::{DiaryError, DiaryResult};
|
use crate::error::{DiaryError, DiaryResult};
|
||||||
|
use crate::service::content_safety_service::ContentSafetyService;
|
||||||
use crate::service::notification_service::NotificationService;
|
use crate::service::notification_service::NotificationService;
|
||||||
use erp_core::events::{DomainEvent, EventBus};
|
use erp_core::events::{DomainEvent, EventBus};
|
||||||
|
|
||||||
@@ -53,8 +54,8 @@ impl CommentService {
|
|||||||
return Err(DiaryError::Forbidden);
|
return Err(DiaryError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 简单内容安全检查(基础敏感词过滤)
|
// 3. 内容安全检查(使用 ContentSafetyService)
|
||||||
if contains_sensitive_words(&content) {
|
if !ContentSafetyService::is_safe(&content) {
|
||||||
return Err(DiaryError::ContentSafetyViolation);
|
return Err(DiaryError::ContentSafetyViolation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# 暖记设计规格 vs 实际实现 — 逐页审核报告
|
# 暖记设计规格 vs 实际实现 — 逐页审核报告
|
||||||
|
|
||||||
> 审核日期:2026-06-02
|
> 初始审核日期:2026-06-02
|
||||||
|
> **修复完成日期:2026-06-02**
|
||||||
> 设计规格文档:`docs/opendesign/warm-notes-design-spec.md`
|
> 设计规格文档:`docs/opendesign/warm-notes-design-spec.md`
|
||||||
> 审核范围:14 个移动端页面
|
> 审核范围:14 个移动端页面
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@
|
|||||||
|
|
||||||
## 总览
|
## 总览
|
||||||
|
|
||||||
|
> **状态:全部 10 个修复阶段已完成。** 0 编译错误,84/85 测试通过(1 个 smoke test 是预先存在的 Isar 初始化问题)。
|
||||||
|
|
||||||
| # | 页面 | 路由 | 总模块数 | ✅ 完整 | ⚠️ 部分 | ❌ 缺失 |
|
| # | 页面 | 路由 | 总模块数 | ✅ 完整 | ⚠️ 部分 | ❌ 缺失 |
|
||||||
|---|------|------|---------|---------|---------|---------|
|
|---|------|------|---------|---------|---------|---------|
|
||||||
| 01 | 启动页 | `/splash` | 8 | **8** | 0 | 0 |
|
| 01 | 启动页 | `/splash` | 8 | **8** | 0 | 0 |
|
||||||
|
|||||||
BIN
docs/verification/achievements-page.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/verification/calendar-page.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
docs/verification/class-page.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
docs/verification/discover-page.png
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
docs/verification/editor-page.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
464
docs/verification/full-system-audit-report.md
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
# 暖记 (Warm Notes) 系统性功能审计报告
|
||||||
|
|
||||||
|
> **审计日期**: 2026-06-02 | **审计范围**: 后端 Rust + Flutter 学生端 + 管理端 Web | **基线**: main (7e928ae)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、审计总览
|
||||||
|
|
||||||
|
### 1.1 审计范围与方法
|
||||||
|
|
||||||
|
本次审计对暖记项目三端(Rust 后端、Flutter 学生端、React 管理端)进行了系统性功能审计,采用以下方法:
|
||||||
|
|
||||||
|
1. **文档对齐** — Wiki 8 篇文档 vs 代码实现对比
|
||||||
|
2. **追踪数据流** — 从 UI → BLoC → Repository → API → Handler → Service → Entity 完链路追踪
|
||||||
|
3. **识别 Dead Code** — 搜索未使用模块、未调用方法、未接入功能
|
||||||
|
4. **检查 Trait 实现** — 验证接口定义与实际实现一致性
|
||||||
|
5. **端到端验证** — API 端点注册 → Handler → Service → Entity 完整性检查
|
||||||
|
|
||||||
|
### 1.2 项目关键数字
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|------|------|
|
||||||
|
| Rust 后端 | 8 crate, ~51,500 行 (erp-diary 5,108 行) |
|
||||||
|
| Flutter 学生端 | 74 文件, ~19,500 行 |
|
||||||
|
| 管理端 Web | ~317 TypeScript 文件, 4 暖记页面 |
|
||||||
|
| 后端测试 | 77 通过 ✅ |
|
||||||
|
| 前端测试 | 84 BLoC + 8 文件 |
|
||||||
|
| SeaORM Entity | 15 (diary) + 50+ (基座) |
|
||||||
|
| API 端点 | 27 个 diary 端点 |
|
||||||
|
|
||||||
|
### 1.3 总体完成度评分
|
||||||
|
|
||||||
|
| 层级 | 完成度 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| **后端 (erp-diary)** | **82%** | Entity/Service/Handler 骨架完整,权限种子和内容安全有缺口 |
|
||||||
|
| **Flutter 学生端** | **68%** | 16 模块骨架完整,但 6 个页面缺 Provider 注入,SyncEngine 未接入,多处硬编码 |
|
||||||
|
| **管理端 Web** | **88%** | 4 页面功能丰富,品牌定制完成度高,但 CRUD 不完整 |
|
||||||
|
| **三端一致性** | **65%** | 多处端间功能不同步,是最大风险 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、功能清单与完成度矩阵
|
||||||
|
|
||||||
|
### 2.1 核心功能模块完成度
|
||||||
|
|
||||||
|
| 功能模块 | 后端 | Flutter | 管理端 | 整体 | 备注 |
|
||||||
|
|----------|------|---------|--------|------|------|
|
||||||
|
| 日记 CRUD | 95% | 85% | 95% | **92%** | 管理端仅只读,EditorPage authorId 硬编码 |
|
||||||
|
| 日记同步 | 90% | 40% | N/A | **65%** | SyncEngine 已写但未接入 app.dart |
|
||||||
|
| 班级管理 | 80% | 75% | 70% | **75%** | ⚠️ API 不匹配:管理端 GET 返回 my_classes |
|
||||||
|
| 手写引擎 | 95% | 90% | N/A | **93%** | 双层 Canvas+光栅化缓存完整,toImage() 主线程风险 |
|
||||||
|
| 贴纸系统 | 85% | 80% | 60% | **75%** | 后端仅 GET,管理端仅只读 |
|
||||||
|
| 模板系统 | 85% | 75% | 20% | **60%** | 管理端 API 有但无页面 |
|
||||||
|
| 评论/点评 | 90% | 50% | 85% | **75%** | 内容安全未接入评论服务,管理端无删除按钮 |
|
||||||
|
| 成就系统 | 80% | 60% | 0% | **47%** | 管理端完全缺失 |
|
||||||
|
| 心情统计 | 85% | 75% | 0% | **53%** | 管理端无页面,后端 API 完整 |
|
||||||
|
| 家长中心 | 85% | 60% | 0% | **48%** | 后端 API 完整,管理端完全缺失 |
|
||||||
|
| 认证/登录 | 95% | 80% | 95% | **90%** | 班级码后端验证待接入 |
|
||||||
|
| 搜索 | 30% | 50% | N/A | **40%** | Isar FTS 未实现,后端无搜索端点 |
|
||||||
|
|
||||||
|
### 2.2 基座功能继承(零开发)
|
||||||
|
|
||||||
|
| 功能 | 状态 | 验证 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户/角色/权限 CRUD | ✅ 继承 | 管理端正常 |
|
||||||
|
| JWT + Token 轮换 | ✅ 继承 | 后端测试通过 |
|
||||||
|
| RBAC 权限码守卫 | ✅ 继承 | 所有 Handler 有 require_permission |
|
||||||
|
| 事件总线 + Outbox | ✅ 继承 | EventBus.publish 在 Service 层使用 |
|
||||||
|
| PII 加密 + 盲索引 | ✅ 继承 | erp-core 提供 |
|
||||||
|
| 审计日志(哈希链) | ✅ 继承 | 管理端可查看 |
|
||||||
|
| 多租户隔离 (RLS) | ✅ 继承 | 中间件自动注入 |
|
||||||
|
| 字典/菜单/设置 | ✅ 继承 | 管理端正常 |
|
||||||
|
| 消息/通知/SSE | ✅ 继承 | SSE 端口不一致问题待修 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、CRITICAL 问题(必须修复)
|
||||||
|
|
||||||
|
### C1. 管理端班级列表 API 不匹配 ⛔
|
||||||
|
|
||||||
|
**位置**: `apps/web/src/api/diary/classes.ts:7` → `crates/erp-diary/src/lib.rs:141`
|
||||||
|
|
||||||
|
**问题**: 前端 `GET /diary/classes` 期望返回全部班级,但后端映射的是 `my_classes`(仅当前用户加入的班级)。管理员无法在管理端看到所有班级。
|
||||||
|
|
||||||
|
**影响**: ClassList 和 JournalList 班级筛选数据不完整,核心管理功能受损。
|
||||||
|
|
||||||
|
**修复方案**:
|
||||||
|
- 后端新增 `list_all_classes` handler(需 `diary.class.manage` 权限),返回全部班级
|
||||||
|
- 或修改 `my_classes` 使 admin/teacher 角色返回全部班级
|
||||||
|
|
||||||
|
### C2. 权限 Seed 缺失 ⛔
|
||||||
|
|
||||||
|
**位置**: `crates/erp-server/migration/src/`
|
||||||
|
|
||||||
|
**问题**: 部分权限码未在 seed 迁移中注册。后端 Handler 使用了 `diary.class.manage`, `diary.comment.write`, `diary.comment.delete` 等权限码,但角色种子数据中可能缺少对应条目。
|
||||||
|
|
||||||
|
**影响**: 即便角色有正确的权限码,如果 seed 未写入则 `require_permission` 永远返回 403。
|
||||||
|
|
||||||
|
**修复方案**: 补充缺失的权限码到 `m20260601_000300_diary_role_seed.rs`。
|
||||||
|
|
||||||
|
### C3. 6 个 Flutter 页面缺少 Repository Provider 注入 ⛔
|
||||||
|
|
||||||
|
**位置**: `app/lib/core/routing/app_router.dart`
|
||||||
|
|
||||||
|
**问题**: 以下页面在路由跳转时没有 `RepositoryProvider` 注入,导致页面内 BLoC 无法访问数据仓库:
|
||||||
|
- `/stickers` — StickerBloc 无法获取 StickerRepository
|
||||||
|
- `/templates` — TemplateBloc 无法获取 TemplateRepository
|
||||||
|
- `/achievements` — AchievementBloc 无法获取数据
|
||||||
|
- `/mood` — MoodBloc 无法获取 JournalRepository
|
||||||
|
- `/calendar` — CalendarBloc 无法获取 JournalRepository
|
||||||
|
- `/parent` — ParentBloc 无法获取 ClassRepository
|
||||||
|
|
||||||
|
**影响**: 这些页面运行时会抛出 `ProviderNotFoundException`,功能完全不可用。
|
||||||
|
|
||||||
|
**修复方案**: 在 `app_router.dart` 的对应路由中添加 `RepositoryProvider` 嵌套,或改为在 `app.dart` 全局注入。
|
||||||
|
|
||||||
|
### C4. SyncEngine 已实现但未接入 ⛔
|
||||||
|
|
||||||
|
**位置**: `app/lib/app.dart`
|
||||||
|
|
||||||
|
**问题**: SyncEngine 在 `app.dart` 中创建了实例,但 `restorePendingQueue()` 和自动同步触发都未实际调用。离线数据永远不会同步到后端。
|
||||||
|
|
||||||
|
**影响**: 离线优先架构的核心承诺(联网后自动同步)不成立。
|
||||||
|
|
||||||
|
**修复方案**:
|
||||||
|
1. 在 `app.dart` 的 `initState` 中调用 `syncEngine.restorePendingQueue()`
|
||||||
|
2. 添加网络监听,WiFi 恢复时触发 `syncEngine.trySync()`
|
||||||
|
3. EditorBloc 保存时调用 `syncEngine.enqueue()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、HIGH 问题(应该修复)
|
||||||
|
|
||||||
|
### H1. EditorPage authorId 硬编码 'local'
|
||||||
|
|
||||||
|
**位置**: `app/lib/features/editor/views/editor_page.dart:90`
|
||||||
|
|
||||||
|
**问题**: `authorId: 'local'` 未从 AuthBloc 获取真实用户 ID。所有日记的作者都是 'local'。
|
||||||
|
|
||||||
|
**影响**: 多用户场景下无法区分日记归属,日记列表、班级日记墙的作者显示全部为 'local'。
|
||||||
|
|
||||||
|
### H2. SSE 端口配置不一致
|
||||||
|
|
||||||
|
**位置**: `app/lib/data/services/sse_notification_service.dart:42`
|
||||||
|
|
||||||
|
**问题**: SSE 默认 `http://localhost:3000`,但 Flutter Web 运行在 `:8080`。SSE 连接需要后端支持,端口不匹配导致推送完全失效。
|
||||||
|
|
||||||
|
**影响**: 实时通知功能不可用。
|
||||||
|
|
||||||
|
### H3. API Base URL 硬编码
|
||||||
|
|
||||||
|
**位置**: `app/lib/config/app_config.dart:24-25`
|
||||||
|
|
||||||
|
**问题**: `http://localhost:3000/api/v1` 硬编码为默认值。虽然支持 `--dart-define` 覆盖,但无构建配置管理。
|
||||||
|
|
||||||
|
**影响**: 生产环境部署需手动配置,容易出错。
|
||||||
|
|
||||||
|
### H4. 内容安全过滤未接入评论服务
|
||||||
|
|
||||||
|
**位置**: `crates/erp-diary/src/service/comment_service.rs`
|
||||||
|
|
||||||
|
**问题**: `ContentSafetyService` 只在 `JournalService` 的创建/更新时调用,评论内容完全绕过了敏感词检查。
|
||||||
|
|
||||||
|
**影响**: 评论中可以包含不当内容,违反 PIPL 内容安全要求。
|
||||||
|
|
||||||
|
### H5. catch(_) 静默吞异常 — 15 处
|
||||||
|
|
||||||
|
**位置**: 遍布 Flutter 代码(sync_engine, editor, class_bloc, calendar 等)
|
||||||
|
|
||||||
|
**问题**: `catch (_)` 完全忽略异常,不记录日志也不向用户反馈。
|
||||||
|
|
||||||
|
**影响**: 问题排查极其困难,用户看到的只有"没反应",无法定位原因。
|
||||||
|
|
||||||
|
### H6. 班级编辑/停用/重置班级码缺失
|
||||||
|
|
||||||
|
**位置**: 前后端均无
|
||||||
|
|
||||||
|
**问题**: 班级一旦创建就无法编辑名称、停用或重置班级码。CLAUDE.md 明确要求"老师可随时重置"。
|
||||||
|
|
||||||
|
### H7. 贴纸管理只有只读
|
||||||
|
|
||||||
|
**位置**: 前后端均无 POST/PUT/DELETE 端点
|
||||||
|
|
||||||
|
**问题**: 贴纸包和贴纸的创建/编辑/删除功能完全缺失,只能通过数据库手动操作。
|
||||||
|
|
||||||
|
### H8. 主题管理缺少停用/编辑
|
||||||
|
|
||||||
|
**位置**: 前后端均无
|
||||||
|
|
||||||
|
**问题**: 主题一旦发布无法修改或停用,过期的主题仍然会显示给学生。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、MEDIUM 问题(建议修复)
|
||||||
|
|
||||||
|
### M1. Flutter 状态管理不统一
|
||||||
|
|
||||||
|
| 模块 | 模式 | 问题 |
|
||||||
|
|------|------|------|
|
||||||
|
| EditorBloc | flutter_bloc ✅ | — |
|
||||||
|
| AuthBloc | flutter_bloc ✅ | — |
|
||||||
|
| HomeBloc | flutter_bloc ✅ | — |
|
||||||
|
| CalendarBloc | flutter_bloc ✅ | — |
|
||||||
|
| SearchBloc | flutter_bloc ✅ | — |
|
||||||
|
| ClassBloc | flutter_bloc ✅ | — |
|
||||||
|
| ParentBloc | flutter_bloc ✅ | — |
|
||||||
|
| MoodBloc | ChangeNotifier ⚠️ | 不支持 DevTools 调试 |
|
||||||
|
| AchievementBloc | ChangeNotifier ⚠️ | 同上 |
|
||||||
|
| StickerBloc | ChangeNotifier ⚠️ | 同上 |
|
||||||
|
| TemplateBloc | ChangeNotifier ⚠️ | 同上 |
|
||||||
|
| SettingsBloc | ChangeNotifier ✅ | 合理选择 |
|
||||||
|
|
||||||
|
**建议**: Phase 2 将 Mood/Sticker/Template/Achievement 迁移到 flutter_bloc。
|
||||||
|
|
||||||
|
### M2. 管理端 HMS 遗留代码需清理
|
||||||
|
|
||||||
|
| 文件 | 内容 | 建议 |
|
||||||
|
|------|------|------|
|
||||||
|
| `api/copilot.ts` | HMS AI 分析 API | 删除 |
|
||||||
|
| `test/fixtures/healthFixtures.ts` | 患者 fixture | 删除 |
|
||||||
|
| `test/mocks/healthHandlers.ts` | 健康 mock | 删除 |
|
||||||
|
| `pages/settings/AuditLogViewer.tsx` | 患者资源类型 | 移除 HMS 选项 |
|
||||||
|
| `stores/auth.test.ts` | health.* 权限码 | 更新为 diary.* |
|
||||||
|
|
||||||
|
### M3. Home 页面快捷入口缺少暖记功能
|
||||||
|
|
||||||
|
`Home.tsx` 的 `QUICK_ACTIONS` 只有基座功能入口,缺少班级管理、日记审核、主题管理。
|
||||||
|
|
||||||
|
### M4. 编辑器不加载已有数据
|
||||||
|
|
||||||
|
journalId 非空时,EditorPage 未从 Isar 读取已有日记数据。编辑已有日记时页面空白。
|
||||||
|
|
||||||
|
### M5. DiscoverPage 全 mock 数据
|
||||||
|
|
||||||
|
`features/discover/views/discover_page.dart` 完全是硬编码数据,无任何数据源接入。
|
||||||
|
|
||||||
|
### M6. 后端 Dead Code — NotificationService
|
||||||
|
|
||||||
|
`notification_service.rs` 有完整的事件监听逻辑,但从未在任何 Handler 或 lib.rs 中注册/订阅。事件发布了但无人消费。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、LOW 问题(建议优化)
|
||||||
|
|
||||||
|
| ID | 问题 | 位置 |
|
||||||
|
|----|------|------|
|
||||||
|
| L1 | `toImage()` 同步阻塞主线程 | handwriting_engine |
|
||||||
|
| L2 | 画布旋转缓存失效 | stroke_cache.dart |
|
||||||
|
| L3 | freezed 声明未使用 | pubspec.yaml |
|
||||||
|
| L4 | Isar FTS 未实现 | search 功能空壳 |
|
||||||
|
| L5 | 用户协议/隐私政策 TODO | login_page.dart |
|
||||||
|
| L6 | UUID 直接展示给管理员 | JournalList/ClassList |
|
||||||
|
| L7 | Logo 显示字母 'N' 而非正式图标 | MainLayout.tsx |
|
||||||
|
| L8 | templateApi 无管理端页面 | stickers.ts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、5 种差距模式分类
|
||||||
|
|
||||||
|
### 7.1 "写了没接"(代码已实现但未接入业务流程)
|
||||||
|
|
||||||
|
| 组件 | 位置 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| SyncEngine | `app/lib/data/services/` | 完整实现但未在 app.dart 调用 |
|
||||||
|
| NotificationService | `crates/erp-diary/src/service/` | 事件监听逻辑完整但未注册订阅 |
|
||||||
|
| ContentSafetyService | `crates/erp-diary/src/service/` | 只接入了 Journal,未接入 Comment |
|
||||||
|
| SSE Notification | `app/lib/data/services/` | 服务完整但端口配置错误 |
|
||||||
|
| usePermFilteredTabs | `apps/web/src/hooks/` | Hook 存在但暖记页面未使用 |
|
||||||
|
|
||||||
|
### 7.2 "接了没传"(功能已接入但关键参数未传递)
|
||||||
|
|
||||||
|
| 组件 | 位置 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| EditorPage authorId | `editor_page.dart:90` | 硬编码 'local',未从 AuthBloc 获取 |
|
||||||
|
| ClassBloc 班级码 | `teacher_page.dart:72` | 硬编码 'a1b2c3',未从后端获取 |
|
||||||
|
| ClassList 分页参数 | `classes.ts` | 传了 page/page_size 但 my_classes 不接受 |
|
||||||
|
| JournalList 班级筛选 | `JournalList.tsx:131` | 只能获取当前用户的班级 |
|
||||||
|
|
||||||
|
### 7.3 "传了没存"(参数已传递但未持久化)
|
||||||
|
|
||||||
|
| 组件 | 位置 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| ClassList 编辑 | `ClassList.tsx:52` | 编辑 Drawer 存在但 onUpdate 为空 |
|
||||||
|
| SettingsBloc 持久化 | `settings_bloc.dart:38` | TODO: 持久化到 SharedPreferences |
|
||||||
|
|
||||||
|
### 7.4 "存了没用"(数据已存储但未在业务逻辑中使用)
|
||||||
|
|
||||||
|
| 组件 | 位置 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| MoodStats API | `/diary/stats/mood` | 管理端无页面展示 |
|
||||||
|
| Achievements | `/diary/achievements` | 管理端无页面 |
|
||||||
|
| Template API | `templateApi` | API 定义完整但无管理端页面 |
|
||||||
|
| total_pages | 后端返回但前端未使用 | PaginatedResponse 字段 |
|
||||||
|
|
||||||
|
### 7.5 "双系统不同步"(各端功能实现不一致)
|
||||||
|
|
||||||
|
| 功能 | 后端 | Flutter | 管理端 | 差距 |
|
||||||
|
|------|------|---------|--------|------|
|
||||||
|
| 班级 CRUD | C/R (无 U/D) | 加入/查看 | 创建/查看 | 管理端缺编辑/停用 |
|
||||||
|
| 评论删除 | ✅ DELETE | ❌ 无 UI | ❌ 无按钮 | 后端有但两端未暴露 |
|
||||||
|
| 成就系统 | ✅ API | 部分实现 | ❌ 无 | 管理端完全缺失 |
|
||||||
|
| 心情统计 | ✅ API | 页面存在 | ❌ 无 | 管理端完全缺失 |
|
||||||
|
| 家长中心 | ✅ 完整 API | 部分实现 | ❌ 无 | 管理端完全缺失 |
|
||||||
|
| 模板管理 | ✅ API | 浏览页 | ❌ 无页面 | 管理端缺页面 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、10 项通用审计清单
|
||||||
|
|
||||||
|
| # | 审计项 | 后端 | Flutter | 管理端 | 评估 |
|
||||||
|
|---|--------|------|---------|--------|------|
|
||||||
|
| 1 | **代码存在性** | ✅ 15 Entity + 10 Service + 8 Handler | ⚠️ 6 模块缺 Provider | ✅ 4 页面 | Flutter 有缺口 |
|
||||||
|
| 2 | **调用链连通** | ✅ Handler→Service→Entity 完整 | ❌ SyncEngine 断链 | ⚠️ classes API 不匹配 | 核心链路有断点 |
|
||||||
|
| 3 | **配置传递** | ✅ Feature Flag + Config | ❌ URL 硬编码 | ✅ Vite proxy | Flutter 配置管理弱 |
|
||||||
|
| 4 | **降级策略** | ✅ DiaryError 枚举完整 | ❌ catch(_) 吞异常 | ⚠️ 班级 fallback | 异常处理不足 |
|
||||||
|
| 5 | **权限守卫** | ⚠️ Seed 可能有缺失 | N/A | ✅ routeConfig | 后端需验证 |
|
||||||
|
| 6 | **输入验证** | ✅ Validate derive | ⚠️ 部分页面无验证 | ✅ Form rules | 基本完整 |
|
||||||
|
| 7 | **数据加密** | ✅ AES-256-GCM 基座 | ✅ Isar 内置加密 | N/A | 已继承 |
|
||||||
|
| 8 | **多租户隔离** | ✅ 中间件注入 | N/A | ✅ JWT 提取 | 已继承 |
|
||||||
|
| 9 | **离线可用** | ✅ Sync API 完整 | ❌ SyncEngine 未接入 | N/A | **核心缺口** |
|
||||||
|
| 10 | **测试覆盖** | ✅ 77 测试 | ⚠️ 8 文件 (6 BLoC) | ⚠️ HMS 遗留测试 | Flutter 严重不足 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、功能点完成度评估
|
||||||
|
|
||||||
|
### 9.1 后端 API 端点完成度
|
||||||
|
|
||||||
|
| 端点 | CRUD | 权限 | 注解 | 事件 | 评分 |
|
||||||
|
|------|------|------|------|------|------|
|
||||||
|
| `/diary/journals` | C+R+U+D | ✅ | ✅ | ✅ | 100% |
|
||||||
|
| `/diary/sync` | POST | ✅ | ✅ | — | 90% |
|
||||||
|
| `/diary/classes` | C+R | ⚠️ | ✅ | ✅ | 75% |
|
||||||
|
| `/diary/classes/join` | POST | ✅ | ✅ | ✅ | 95% |
|
||||||
|
| `/diary/journals/:id/comments` | C+R+D | ✅ | ✅ | ✅ | 95% |
|
||||||
|
| `/diary/classes/:id/topics` | C+R | ✅ | ✅ | ✅ | 85% |
|
||||||
|
| `/diary/sticker-packs` | R only | ✅ | ✅ | — | 50% |
|
||||||
|
| `/diary/templates` | R only | ✅ | ✅ | — | 50% |
|
||||||
|
| `/diary/achievements` | R+POST | ✅ | ✅ | — | 70% |
|
||||||
|
| `/diary/stats/mood` | GET | ✅ | ✅ | — | 85% |
|
||||||
|
| `/diary/parent/*` | 完整 | ✅ | ✅ | ✅ | 90% |
|
||||||
|
|
||||||
|
### 9.2 Flutter 功能模块完成度
|
||||||
|
|
||||||
|
| 模块 | BLoC | View | Widget | 离线 | 测试 | 评分 |
|
||||||
|
|------|------|------|--------|------|------|------|
|
||||||
|
| editor | ✅ | ✅ | ✅ | ⚠️ | ✅ | 85% |
|
||||||
|
| auth | ✅ | ✅ | — | — | ✅ | 85% |
|
||||||
|
| home | ✅ | ✅ | — | ⚠️ | ✅ | 80% |
|
||||||
|
| calendar | ✅ | ✅ | — | ⚠️ | ✅ | 75% |
|
||||||
|
| search | ✅ | ✅ | — | ❌ | ❌ | 50% |
|
||||||
|
| class_ | ✅ | ✅ | — | ⚠️ | ❌ | 70% |
|
||||||
|
| mood | ⚠️ CN | ✅ | — | ❌ | ❌ | 55% |
|
||||||
|
| achievement | ⚠️ CN | ✅ | — | ❌ | ❌ | 45% |
|
||||||
|
| stickers | ⚠️ CN | ✅ | ✅ | ❌ | ❌ | 50% |
|
||||||
|
| templates | ⚠️ CN | ✅ | — | ❌ | ❌ | 45% |
|
||||||
|
| profile | ⚠️ CN | ✅ | — | ❌ | ❌ | 50% |
|
||||||
|
| teacher | — | ✅ | — | ❌ | ❌ | 40% |
|
||||||
|
| parent | ✅ | ✅ | — | ❌ | ❌ | 45% |
|
||||||
|
| discover | — | ✅ | — | ❌ | ❌ | 15% |
|
||||||
|
|
||||||
|
### 9.3 管理端页面完成度
|
||||||
|
|
||||||
|
| 页面 | CRUD | 筛选 | 分页 | 主题 | 品牌 | 评分 |
|
||||||
|
|------|------|------|------|------|------|------|
|
||||||
|
| 班级管理 | C+R | — | ⚠️ | ✅ | ✅ | 85% |
|
||||||
|
| 日记审阅 | R | ✅ | ✅ | ✅ | ✅ | 95% |
|
||||||
|
| 主题管理 | C+R | ✅ | — | ✅ | ✅ | 90% |
|
||||||
|
| 贴纸管理 | R | ✅ | — | ✅ | ✅ | 85% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、问题统计与优先级
|
||||||
|
|
||||||
|
### 10.1 问题严重程度分布
|
||||||
|
|
||||||
|
| 严重程度 | 数量 | 占比 |
|
||||||
|
|----------|------|------|
|
||||||
|
| CRITICAL | 4 | 12% |
|
||||||
|
| HIGH | 8 | 24% |
|
||||||
|
| MEDIUM | 6 | 18% |
|
||||||
|
| LOW | 8 | 24% |
|
||||||
|
| INFO/建议 | 7 | 22% |
|
||||||
|
| **合计** | **33** | 100% |
|
||||||
|
|
||||||
|
### 10.2 问题分布
|
||||||
|
|
||||||
|
| 层级 | CRITICAL | HIGH | MEDIUM | LOW |
|
||||||
|
|------|----------|------|--------|-----|
|
||||||
|
| 后端 | 1 (C2) | 1 (H4) | 1 (M6) | 0 |
|
||||||
|
| Flutter | 2 (C3, C4) | 4 (H1, H2, H3, H5) | 2 (M1, M4) | 4 |
|
||||||
|
| 管理端 | 1 (C1) | 3 (H6, H7, H8) | 3 (M2, M3, M5) | 4 |
|
||||||
|
| 跨端 | — | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、推荐修复顺序
|
||||||
|
|
||||||
|
### Phase 1.1 — 紧急修复(1-2 天)
|
||||||
|
|
||||||
|
| # | 问题 | 工作量 | 涉及文件 |
|
||||||
|
|---|------|--------|----------|
|
||||||
|
| 1 | C3: 6 个 Flutter 页面添加 Provider 注入 | ~100 行 | app_router.dart |
|
||||||
|
| 2 | C4: SyncEngine 接入 app.dart | ~50 行 | app.dart, editor_bloc.dart |
|
||||||
|
| 3 | H1: EditorPage authorId 从 AuthBloc 获取 | ~20 行 | editor_page.dart |
|
||||||
|
| 4 | H5: catch(_) → catch(e) + log | ~30 行 | 多文件 |
|
||||||
|
|
||||||
|
### Phase 1.2 — 核心功能修复(3-5 天)
|
||||||
|
|
||||||
|
| # | 问题 | 工作量 | 涉及文件 |
|
||||||
|
|---|------|--------|----------|
|
||||||
|
| 5 | C1: 后端新增 list_all_classes handler | ~150 行 Rust | class_handler.rs, class_service.rs, lib.rs |
|
||||||
|
| 6 | C2: 补充权限 seed 迁移 | ~100 行 SQL | diary_role_seed.rs |
|
||||||
|
| 7 | H4: ContentSafetyService 接入评论 | ~30 行 Rust | comment_service.rs |
|
||||||
|
| 8 | H6: 班级编辑/停用/重置班级码 | ~300 行 Rust + ~100 行 TS | class_handler.rs, ClassList.tsx |
|
||||||
|
| 9 | H2: SSE 端口配置修复 | ~20 行 Dart | sse_notification_service.dart |
|
||||||
|
|
||||||
|
### Phase 1.3 — 完善修复(5-7 天)
|
||||||
|
|
||||||
|
| # | 问题 | 工作量 | 涉及文件 |
|
||||||
|
|---|------|--------|----------|
|
||||||
|
| 10 | H7: 贴纸 CRUD | ~400 行 Rust + ~200 行 TS | sticker_handler.rs, StickerPackList.tsx |
|
||||||
|
| 11 | H8: 主题停用/编辑 | ~200 行 Rust + ~100 行 TS | topic_handler.rs, TopicList.tsx |
|
||||||
|
| 12 | M2: HMS 遗留代码清理 | ~200 行删除 | 多文件 |
|
||||||
|
| 13 | M4: 编辑器加载已有数据 | ~100 行 Dart | editor_page.dart |
|
||||||
|
| 14 | M6: NotificationService 注册 | ~50 行 Rust | lib.rs |
|
||||||
|
|
||||||
|
### Phase 2 — 后续规划
|
||||||
|
|
||||||
|
| # | 功能 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 15 | 成就系统管理端页面 | 完整 CRUD |
|
||||||
|
| 16 | 心情统计管理端页面 | 图表展示 |
|
||||||
|
| 17 | 家长中心管理端 | 数据查看/导出 |
|
||||||
|
| 18 | BLoC 统一迁移 | ChangeNotifier → flutter_bloc |
|
||||||
|
| 19 | Isar FTS 搜索实现 | 全文搜索 |
|
||||||
|
| 20 | 测试覆盖率提升 | 80%+ 目标 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、总结
|
||||||
|
|
||||||
|
### 做得好的方面
|
||||||
|
|
||||||
|
1. **后端架构扎实** — Entity/Service/Handler 分层清晰,权限守卫、事件发布、多租户隔离完整
|
||||||
|
2. **手写引擎高质量** — 双层 Canvas + 光栅化缓存 + Listener 低延迟,核心价值完整
|
||||||
|
3. **管理端品牌定制完成度高** — 登录页、侧边栏、主题色、菜单种子全部完成暖记替换
|
||||||
|
4. **设计系统一致性** — 三端使用统一的珊瑚色/鼠尾草绿/暖金配色体系
|
||||||
|
5. **基座继承零开发** — 8 个基座 crate 的全部功能直接可用
|
||||||
|
|
||||||
|
### 最大风险
|
||||||
|
|
||||||
|
1. **SyncEngine 未接入** — 离线优先是核心承诺,但目前离线数据永远不会同步
|
||||||
|
2. **6 个 Flutter 页面缺 Provider** — 这些页面在运行时会崩溃
|
||||||
|
3. **管理端/学生端功能不同步** — 成就、心情、家长中心后端 API 完整但管理端完全缺失
|
||||||
|
4. **catch(_) 静默吞异常** — 15 处异常被完全忽略,严重影响问题排查
|
||||||
|
|
||||||
|
### 一句话总结
|
||||||
|
|
||||||
|
> **暖记项目基座架构优秀,后端 API 覆盖 90%,但 Flutter 端的"最后一公里"(Provider 注入、SyncEngine 接入、authorId 硬编码)和管理端的 CRUD 补全是当前最紧迫的工作。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*报告生成: 2026-06-02 | 审计工具: Claude Code + 三端并行 Agent*
|
||||||
BIN
docs/verification/home-page.png
Normal file
|
After Width: | Height: | Size: 329 KiB |
BIN
docs/verification/homepage.png
Normal file
|
After Width: | Height: | Size: 329 KiB |
BIN
docs/verification/hptest.png
Normal file
|
After Width: | Height: | Size: 329 KiB |
1
docs/verification/login-page-b64.txt
Normal file
BIN
docs/verification/login-page.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/verification/mood-page.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
docs/verification/parent-page.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/verification/profile-page.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/verification/search-page.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/verification/settings-page.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
docs/verification/stickers-page.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
docs/verification/teacher-page.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
docs/verification/templates-page.png
Normal file
|
After Width: | Height: | Size: 67 KiB |