feat(diary): 数据层 + 班级系统 (Phase F1 + B3)

Flutter 数据层 (Phase F1):
- journal_entry.dart: 日记数据模型 (Mood/Weather/tags/version)
- journal_element.dart: 元素模型 (text/image/sticker/handwriting_ref/tape)
- school_class.dart: 班级模型
- user_settings.dart: 用户设置 (主题/画笔/字号)
- isar_database.dart: Isar 初始化
- api_client.dart: Dio + JWT注入 + 离线感知 + 401处理
- journal_repository.dart: 抽象接口 + InMemory实现 (乐观锁)
- sync_engine.dart: WiFi同步 + 操作队列 + 重试(5次) + 快照持久化

Rust 班级系统 (Phase B3):
- class_service.rs: 创建班级(6位码) + 加入班级 + 成员管理
- topic_service.rs: 老师布置主题 + 主题列表
- comment_service.rs: 老师点评 + 评语列表
- class_handler.rs: 5个API端点 + 权限守卫
- topic_handler.rs: 2个API端点
- comment_handler.rs: 2个API端点
- dto.rs: 新增5个DTO (ClassMemberResp/CreateTopicReq/TopicResp/CreateCommentReq/CommentResp)
- 6条新路由注册

验证: cargo check 通过, 433测试全绿, flutter analyze 1 warning
This commit is contained in:
iven
2026-06-01 00:55:51 +08:00
parent d0653614e0
commit 5e6c6fdd62
18 changed files with 2205 additions and 1 deletions

View File

@@ -0,0 +1,156 @@
// 日记元素数据模型 — 手写不可变类(避免 build_runner 依赖)
import 'dart:collection';
import 'package:uuid/uuid.dart';
/// 元素类型 — 日记页面上的各种内容载体
enum ElementType {
text('text'),
image('image'),
sticker('sticker'),
handwritingRef('handwriting_ref'),
tape('tape');
const ElementType(this.value);
final String value;
}
/// 日记元素 — 日记页面中的单个内容单元
///
/// 每个元素有独立的位置、尺寸、旋转和层级。
/// [content] 字段根据 [elementType] 存储不同的结构化数据:
/// - text: {'text': '...', 'fontSize': 16.0, 'fontColor': '#2D2420'}
/// - image: {'filePath': '...', 'thumbnailPath': '...'}
/// - sticker: {'stickerPackId': '...', 'stickerId': '...'}
/// - handwriting_ref: {'strokesFileId': '...', 'strokeCount': 42}
/// - tape: {'tapeStyle': 'washi_dots', 'tapeColor': '#F2CC8F'}
class JournalElement {
final String id;
final String journalId;
final ElementType elementType;
final double positionX;
final double positionY;
final double width;
final double height;
final double rotation;
final int zIndex;
final Map<String, dynamic> content;
final int version;
final DateTime createdAt;
final DateTime updatedAt;
const JournalElement({
required this.id,
required this.journalId,
required this.elementType,
this.positionX = 0,
this.positionY = 0,
this.width = 100,
this.height = 100,
this.rotation = 0,
this.zIndex = 0,
this.content = const {},
this.version = 1,
required this.createdAt,
required this.updatedAt,
});
JournalElement copyWith({
String? id,
String? journalId,
ElementType? elementType,
double? positionX,
double? positionY,
double? width,
double? height,
double? rotation,
int? zIndex,
Map<String, dynamic>? content,
int? version,
DateTime? createdAt,
DateTime? updatedAt,
}) =>
JournalElement(
id: id ?? this.id,
journalId: journalId ?? this.journalId,
elementType: elementType ?? this.elementType,
positionX: positionX ?? this.positionX,
positionY: positionY ?? this.positionY,
width: width ?? this.width,
height: height ?? this.height,
rotation: rotation ?? this.rotation,
zIndex: zIndex ?? this.zIndex,
content: content ?? this.content,
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
Map<String, dynamic> toJson() => {
'id': id,
'journal_id': journalId,
'element_type': elementType.value,
'position_x': positionX,
'position_y': positionY,
'width': width,
'height': height,
'rotation': rotation,
'z_index': zIndex,
'content': content,
'version': version,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
factory JournalElement.fromJson(Map<String, dynamic> json) => JournalElement(
id: json['id'] as String,
journalId: json['journal_id'] as String,
elementType: ElementType.values.firstWhere(
(e) => e.value == json['element_type'],
orElse: () => ElementType.text,
),
positionX: (json['position_x'] as num?)?.toDouble() ?? 0,
positionY: (json['position_y'] as num?)?.toDouble() ?? 0,
width: (json['width'] as num?)?.toDouble() ?? 100,
height: (json['height'] as num?)?.toDouble() ?? 100,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0,
zIndex: (json['z_index'] as int?) ?? 0,
content: UnmodifiableMapView(
Map<String, dynamic>.from(json['content'] as Map? ?? {}),
),
version: (json['version'] as int?) ?? 1,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
/// 创建新元素的工厂方法 — 自动生成 id 和时间戳
factory JournalElement.create({
required String journalId,
required ElementType elementType,
double positionX = 0,
double positionY = 0,
double width = 100,
double height = 100,
double rotation = 0,
int zIndex = 0,
Map<String, dynamic> content = const {},
}) {
final now = DateTime.now();
return JournalElement(
id: const Uuid().v4(),
journalId: journalId,
elementType: elementType,
positionX: positionX,
positionY: positionY,
width: width,
height: height,
rotation: rotation,
zIndex: zIndex,
content: content,
version: 1,
createdAt: now,
updatedAt: now,
);
}
}

View File

@@ -0,0 +1,172 @@
// 日记条目数据模型 — 手写不可变类(避免 build_runner 依赖)
import 'package:uuid/uuid.dart';
/// 心情枚举 — 对应日记心情选择器
enum Mood {
happy('happy'),
calm('calm'),
sad('sad'),
angry('angry'),
thinking('thinking');
const Mood(this.value);
final String value;
}
/// 天气枚举 — 对应日记天气标签
enum Weather {
sunny('sunny'),
cloudy('cloudy'),
rainy('rainy'),
snowy('snowy'),
windy('windy');
const Weather(this.value);
final String value;
}
/// 日记条目 — 核心数据模型
///
/// 每篇日记包含标题、日期、心情、天气、标签等元信息。
/// 日记的具体内容(文字/图片/手写/贴纸)通过 [JournalElement] 管理。
class JournalEntry {
final String id;
final String authorId;
final String? classId;
final String title;
final DateTime date;
final Mood mood;
final Weather weather;
final List<String> tags;
final bool isPrivate;
final bool sharedToClass;
final String? assignedTopicId;
final int version;
final DateTime createdAt;
final DateTime updatedAt;
const JournalEntry({
required this.id,
required this.authorId,
this.classId,
required this.title,
required this.date,
this.mood = Mood.calm,
this.weather = Weather.sunny,
this.tags = const [],
this.isPrivate = true,
this.sharedToClass = false,
this.assignedTopicId,
this.version = 1,
required this.createdAt,
required this.updatedAt,
});
JournalEntry copyWith({
String? id,
String? authorId,
String? classId,
bool clearClassId = false,
String? title,
DateTime? date,
Mood? mood,
Weather? weather,
List<String>? tags,
bool? isPrivate,
bool? sharedToClass,
String? assignedTopicId,
bool clearAssignedTopicId = false,
int? version,
DateTime? createdAt,
DateTime? updatedAt,
}) =>
JournalEntry(
id: id ?? this.id,
authorId: authorId ?? this.authorId,
classId: clearClassId ? null : (classId ?? this.classId),
title: title ?? this.title,
date: date ?? this.date,
mood: mood ?? this.mood,
weather: weather ?? this.weather,
tags: tags ?? this.tags,
isPrivate: isPrivate ?? this.isPrivate,
sharedToClass: sharedToClass ?? this.sharedToClass,
assignedTopicId:
clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId),
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
Map<String, dynamic> toJson() => {
'id': id,
'author_id': authorId,
'class_id': classId,
'title': title,
'date': date.toIso8601String(),
'mood': mood.value,
'weather': weather.value,
'tags': tags,
'is_private': isPrivate,
'shared_to_class': sharedToClass,
'assigned_topic_id': assignedTopicId,
'version': version,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
factory JournalEntry.fromJson(Map<String, dynamic> json) => JournalEntry(
id: json['id'] as String,
authorId: json['author_id'] as String,
classId: json['class_id'] as String?,
title: json['title'] as String,
date: DateTime.parse(json['date'] as String),
mood: Mood.values.firstWhere(
(m) => m.value == json['mood'],
orElse: () => Mood.calm,
),
weather: Weather.values.firstWhere(
(w) => w.value == json['weather'],
orElse: () => Weather.sunny,
),
tags: List<String>.from(json['tags'] as List? ?? []),
isPrivate: (json['is_private'] as bool?) ?? true,
sharedToClass: (json['shared_to_class'] as bool?) ?? false,
assignedTopicId: json['assigned_topic_id'] as String?,
version: (json['version'] as int?) ?? 1,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
/// 创建新日记的工厂方法 — 自动生成 id 和时间戳
factory JournalEntry.create({
required String authorId,
required String title,
required DateTime date,
String? classId,
Mood mood = Mood.calm,
Weather weather = Weather.sunny,
List<String> tags = const [],
bool isPrivate = true,
String? assignedTopicId,
}) {
final now = DateTime.now();
return JournalEntry(
id: const Uuid().v4(),
authorId: authorId,
classId: classId,
title: title,
date: date,
mood: mood,
weather: weather,
tags: tags,
isPrivate: isPrivate,
sharedToClass: false,
assignedTopicId: assignedTopicId,
version: 1,
createdAt: now,
updatedAt: now,
);
}
}

View File

@@ -0,0 +1,112 @@
// 班级数据模型 — 手写不可变类(避免 build_runner 依赖)
import 'package:uuid/uuid.dart';
/// 班级 — 老师创建,学生通过班级码加入
///
/// 班级码安全规则:
/// - 6 位字母数字混合62^6 约 568 亿种组合)
/// - 有效期控制(学期结束自动失效)
/// - 连续 5 次错误锁定 30 分钟
class SchoolClass {
final String id;
final String name;
final String schoolName;
final String teacherId;
final String classCode;
final int memberCount;
final bool isActive;
final DateTime? expiresAt;
final DateTime createdAt;
final DateTime updatedAt;
const SchoolClass({
required this.id,
required this.name,
required this.schoolName,
required this.teacherId,
required this.classCode,
this.memberCount = 0,
this.isActive = true,
this.expiresAt,
required this.createdAt,
required this.updatedAt,
});
SchoolClass copyWith({
String? id,
String? name,
String? schoolName,
String? teacherId,
String? classCode,
int? memberCount,
bool? isActive,
DateTime? expiresAt,
bool clearExpiresAt = false,
DateTime? createdAt,
DateTime? updatedAt,
}) =>
SchoolClass(
id: id ?? this.id,
name: name ?? this.name,
schoolName: schoolName ?? this.schoolName,
teacherId: teacherId ?? this.teacherId,
classCode: classCode ?? this.classCode,
memberCount: memberCount ?? this.memberCount,
isActive: isActive ?? this.isActive,
expiresAt: clearExpiresAt ? null : (expiresAt ?? this.expiresAt),
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'school_name': schoolName,
'teacher_id': teacherId,
'class_code': classCode,
'member_count': memberCount,
'is_active': isActive,
'expires_at': expiresAt?.toIso8601String(),
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
factory SchoolClass.fromJson(Map<String, dynamic> json) => SchoolClass(
id: json['id'] as String,
name: json['name'] as String,
schoolName: json['school_name'] as String,
teacherId: json['teacher_id'] as String,
classCode: json['class_code'] as String,
memberCount: (json['member_count'] as int?) ?? 0,
isActive: (json['is_active'] as bool?) ?? true,
expiresAt: json['expires_at'] != null
? DateTime.parse(json['expires_at'] as String)
: null,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
/// 创建新班级的工厂方法 — 自动生成 id 和时间戳
factory SchoolClass.create({
required String name,
required String schoolName,
required String teacherId,
required String classCode,
DateTime? expiresAt,
}) {
final now = DateTime.now();
return SchoolClass(
id: const Uuid().v4(),
name: name,
schoolName: schoolName,
teacherId: teacherId,
classCode: classCode,
memberCount: 0,
isActive: true,
expiresAt: expiresAt,
createdAt: now,
updatedAt: now,
);
}
}

View File

@@ -0,0 +1,105 @@
// 用户设置数据模型 — 手写不可变类(避免 build_runner 依赖)
/// 主题模式 — 与系统设置同步
enum ThemeMode { light, dark, system }
/// 默认画笔类型(复用 StrokeModel 中的枚举值)
enum DefaultBrushType {
pen('pen'),
pencil('pencil'),
marker('marker');
const DefaultBrushType(this.value);
final String value;
}
/// 用户设置 — 持久化的个人偏好
///
/// 存储用户的主题偏好、默认画笔、字体大小等。
/// 每个用户只有一条设置记录,通过 [userId] 关联。
class UserSettings {
final String id;
final String userId;
final ThemeMode theme;
final DefaultBrushType defaultBrushType;
final String defaultBrushColor;
final double fontSize;
final DateTime createdAt;
final DateTime updatedAt;
const UserSettings({
required this.id,
required this.userId,
this.theme = ThemeMode.system,
this.defaultBrushType = DefaultBrushType.pen,
this.defaultBrushColor = '#2D2420',
this.fontSize = 16.0,
required this.createdAt,
required this.updatedAt,
});
UserSettings copyWith({
String? id,
String? userId,
ThemeMode? theme,
DefaultBrushType? defaultBrushType,
String? defaultBrushColor,
double? fontSize,
DateTime? createdAt,
DateTime? updatedAt,
}) =>
UserSettings(
id: id ?? this.id,
userId: userId ?? this.userId,
theme: theme ?? this.theme,
defaultBrushType: defaultBrushType ?? this.defaultBrushType,
defaultBrushColor: defaultBrushColor ?? this.defaultBrushColor,
fontSize: fontSize ?? this.fontSize,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
Map<String, dynamic> toJson() => {
'id': id,
'user_id': userId,
'theme': theme.name,
'default_brush_type': defaultBrushType.value,
'default_brush_color': defaultBrushColor,
'font_size': fontSize,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
factory UserSettings.fromJson(Map<String, dynamic> json) => UserSettings(
id: json['id'] as String,
userId: json['user_id'] as String,
theme: ThemeMode.values.firstWhere(
(t) => t.name == json['theme'],
orElse: () => ThemeMode.system,
),
defaultBrushType: DefaultBrushType.values.firstWhere(
(b) => b.value == json['default_brush_type'],
orElse: () => DefaultBrushType.pen,
),
defaultBrushColor:
(json['default_brush_color'] as String?) ?? '#2D2420',
fontSize: (json['font_size'] as num?)?.toDouble() ?? 16.0,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
/// 创建默认设置的工厂方法
factory UserSettings.create({
required String userId,
ThemeMode theme = ThemeMode.system,
}) {
final now = DateTime.now();
return UserSettings(
id: userId,
userId: userId,
theme: theme,
createdAt: now,
updatedAt: now,
);
}
}