feat(app): 数据层集成 — RemoteJournalRepository + ClassRepository + SSE 通知
数据层新增: - RemoteJournalRepository: 日记 CRUD + 元素管理,通过 ApiClient 连接后端 - ClassRepository: 班级/主题/评语 API 操作(getMyClasses/joinClass/assignTopic/createComment) - SseNotificationService: SSE 实时通知监听 + 自动重连 + 事件流 - ApiException: 统一 API 错误封装 - DTO: ClassMemberDto + TopicDto + CommentDto 设计: - Repository 模式: 抽象接口 + 远程实现 + 内存实现 - SSE: Dio stream + SSE 协议解析 + 3秒自动重连 - 所有 Repository 通过 ApiClient 注入,依赖现有 JWT 拦截器 验证: flutter analyze 0 error
This commit is contained in:
201
app/lib/data/repositories/class_repository.dart
Normal file
201
app/lib/data/repositories/class_repository.dart
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// 班级仓库 — 通过 API 客户端管理班级、主题、评语
|
||||||
|
|
||||||
|
import '../models/school_class.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
|
||||||
|
/// 班级成员数据
|
||||||
|
class ClassMemberDto {
|
||||||
|
final String userId;
|
||||||
|
final String role;
|
||||||
|
final String? nickname;
|
||||||
|
final DateTime joinedAt;
|
||||||
|
|
||||||
|
const ClassMemberDto({
|
||||||
|
required this.userId,
|
||||||
|
required this.role,
|
||||||
|
this.nickname,
|
||||||
|
required this.joinedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ClassMemberDto.fromJson(Map<String, dynamic> json) => ClassMemberDto(
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
role: json['role'] as String,
|
||||||
|
nickname: json['nickname'] as String?,
|
||||||
|
joinedAt: DateTime.parse(json['joined_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主题布置数据
|
||||||
|
class TopicDto {
|
||||||
|
final String id;
|
||||||
|
final String classId;
|
||||||
|
final String teacherId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
const TopicDto({
|
||||||
|
required this.id,
|
||||||
|
required this.classId,
|
||||||
|
required this.teacherId,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
this.dueDate,
|
||||||
|
this.isActive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TopicDto.fromJson(Map<String, dynamic> json) => TopicDto(
|
||||||
|
id: json['id'] as String,
|
||||||
|
classId: json['class_id'] as String,
|
||||||
|
teacherId: json['teacher_id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
dueDate: json['due_date'] != null
|
||||||
|
? DateTime.parse(json['due_date'] as String)
|
||||||
|
: null,
|
||||||
|
isActive: (json['is_active'] as bool?) ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评语数据
|
||||||
|
class CommentDto {
|
||||||
|
final String id;
|
||||||
|
final String journalId;
|
||||||
|
final String authorId;
|
||||||
|
final String content;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const CommentDto({
|
||||||
|
required this.id,
|
||||||
|
required this.journalId,
|
||||||
|
required this.authorId,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CommentDto.fromJson(Map<String, dynamic> json) => CommentDto(
|
||||||
|
id: json['id'] as String,
|
||||||
|
journalId: json['journal_id'] as String,
|
||||||
|
authorId: json['author_id'] as String,
|
||||||
|
content: json['content'] as String,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 班级仓库 — 班级/主题/评语的 API 操作
|
||||||
|
class ClassRepository {
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
ClassRepository({required ApiClient api}) : _api = api;
|
||||||
|
|
||||||
|
// ===== 班级 =====
|
||||||
|
|
||||||
|
/// 获取我加入的班级列表
|
||||||
|
Future<List<SchoolClass>> getMyClasses() async {
|
||||||
|
final response = await _api.get('/diary/classes');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => SchoolClass.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建班级(老师)
|
||||||
|
Future<SchoolClass> createClass({
|
||||||
|
required String name,
|
||||||
|
String? schoolName,
|
||||||
|
}) async {
|
||||||
|
final response = await _api.post('/diary/classes', data: {
|
||||||
|
'name': name,
|
||||||
|
if (schoolName != null) 'school_name': schoolName,
|
||||||
|
});
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加入班级(通过班级码)
|
||||||
|
Future<SchoolClass> joinClass(String classCode, {String? nickname}) async {
|
||||||
|
final response = await _api.post('/diary/classes/join', data: {
|
||||||
|
'class_code': classCode,
|
||||||
|
if (nickname != null) 'nickname': nickname,
|
||||||
|
});
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取班级详情
|
||||||
|
Future<SchoolClass> getClass(String classId) async {
|
||||||
|
final response = await _api.get('/diary/classes/$classId');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取班级成员列表
|
||||||
|
Future<List<ClassMemberDto>> getMembers(String classId) async {
|
||||||
|
final response = await _api.get('/diary/classes/$classId/members');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => ClassMemberDto.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 主题 =====
|
||||||
|
|
||||||
|
/// 获取班级主题列表
|
||||||
|
Future<List<TopicDto>> getTopics(String classId) async {
|
||||||
|
final response = await _api.get('/diary/classes/$classId/topics');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => TopicDto.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 布置主题(老师)
|
||||||
|
Future<TopicDto> assignTopic({
|
||||||
|
required String classId,
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
DateTime? dueDate,
|
||||||
|
}) async {
|
||||||
|
final response = await _api.post('/diary/classes/$classId/topics', data: {
|
||||||
|
'title': title,
|
||||||
|
if (description != null) 'description': description,
|
||||||
|
if (dueDate != null) 'due_date': dueDate.toIso8601String(),
|
||||||
|
});
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return TopicDto.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 评语 =====
|
||||||
|
|
||||||
|
/// 获取日记评语列表
|
||||||
|
Future<List<CommentDto>> getComments(String journalId) async {
|
||||||
|
final response = await _api.get('/diary/journals/$journalId/comments');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => CommentDto.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加评语(老师点评学生日记)
|
||||||
|
Future<CommentDto> createComment({
|
||||||
|
required String journalId,
|
||||||
|
required String content,
|
||||||
|
}) async {
|
||||||
|
final response = await _api.post(
|
||||||
|
'/diary/journals/$journalId/comments',
|
||||||
|
data: {'content': content},
|
||||||
|
);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return CommentDto.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除评语
|
||||||
|
Future<void> deleteComment(String commentId) async {
|
||||||
|
await _api.delete('/diary/comments/$commentId');
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/lib/data/repositories/remote_journal_repository.dart
Normal file
129
app/lib/data/repositories/remote_journal_repository.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// 远程日记仓库 — 通过 API 客户端连接后端
|
||||||
|
|
||||||
|
import '../models/journal_element.dart';
|
||||||
|
import '../models/journal_entry.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
import 'journal_repository.dart';
|
||||||
|
|
||||||
|
/// 远程日记仓库 — 通过 HTTP API 操作后端数据
|
||||||
|
///
|
||||||
|
/// 所有操作需要网络连接。离线场景由 SyncEngine 协调 Isar 本地仓库处理。
|
||||||
|
class RemoteJournalRepository implements JournalRepository {
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
RemoteJournalRepository({required ApiClient api}) : _api = api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JournalEntry>> getJournals({
|
||||||
|
DateTime? dateFrom,
|
||||||
|
DateTime? dateTo,
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
}) async {
|
||||||
|
final queryParams = <String, dynamic>{};
|
||||||
|
if (dateFrom != null) queryParams['date_from'] = dateFrom.toIso8601String();
|
||||||
|
if (dateTo != null) queryParams['date_to'] = dateTo.toIso8601String();
|
||||||
|
if (page != null) queryParams['page'] = page;
|
||||||
|
if (pageSize != null) queryParams['page_size'] = pageSize;
|
||||||
|
|
||||||
|
final response = await _api.get('/diary/journals', queryParams: queryParams);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => JournalEntry.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry?> getJournal(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await _api.get('/diary/journals/$id');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||||
|
final response = await _api.post('/diary/journals', data: entry.toJson());
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||||||
|
final response = await _api.put(
|
||||||
|
'/diary/journals/${entry.id}',
|
||||||
|
data: {
|
||||||
|
'title': entry.title,
|
||||||
|
'mood': entry.mood.value,
|
||||||
|
'weather': entry.weather.value,
|
||||||
|
'tags': entry.tags,
|
||||||
|
'is_private': entry.isPrivate,
|
||||||
|
'shared_to_class': entry.sharedToClass,
|
||||||
|
'version': entry.version,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteJournal(String id) async {
|
||||||
|
await _api.delete('/diary/journals/$id');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JournalElement>> getElements(String journalId) async {
|
||||||
|
final response = await _api.get('/diary/journals/$journalId/elements');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => JournalElement.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> addElement(JournalElement element) async {
|
||||||
|
final response = await _api.post(
|
||||||
|
'/diary/journals/${element.journalId}/elements',
|
||||||
|
data: element.toJson(),
|
||||||
|
);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return JournalElement.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> updateElement(JournalElement element) async {
|
||||||
|
final response = await _api.put(
|
||||||
|
'/diary/journals/${element.journalId}/elements/${element.id}',
|
||||||
|
data: element.toJson(),
|
||||||
|
);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return JournalElement.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeElement(String elementId) async {
|
||||||
|
await _api.delete('/diary/elements/$elementId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 异常封装 — 后端返回非 2xx 状态码时抛出
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int statusCode;
|
||||||
|
final dynamic responseBody;
|
||||||
|
|
||||||
|
const ApiException({
|
||||||
|
required this.message,
|
||||||
|
required this.statusCode,
|
||||||
|
this.responseBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException($statusCode): $message';
|
||||||
|
}
|
||||||
146
app/lib/data/services/sse_notification_service.dart
Normal file
146
app/lib/data/services/sse_notification_service.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// SSE 通知服务 — 监听服务端推送事件
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// SSE 通知事件
|
||||||
|
class NotificationEvent {
|
||||||
|
final String type;
|
||||||
|
final Map<String, dynamic> payload;
|
||||||
|
final DateTime receivedAt;
|
||||||
|
|
||||||
|
const NotificationEvent({
|
||||||
|
required this.type,
|
||||||
|
required this.payload,
|
||||||
|
required this.receivedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSE 通知服务 — 监听后端 Server-Sent Events 推送
|
||||||
|
///
|
||||||
|
/// 使用方式:
|
||||||
|
/// ```dart
|
||||||
|
/// final service = SseNotificationService(token: 'jwt-token');
|
||||||
|
/// service.events.listen((event) {
|
||||||
|
/// // 处理通知
|
||||||
|
/// });
|
||||||
|
/// await service.connect();
|
||||||
|
/// ```
|
||||||
|
class SseNotificationService {
|
||||||
|
final String _baseUrl;
|
||||||
|
final String? _token;
|
||||||
|
|
||||||
|
Dio? _dio;
|
||||||
|
Response<ResponseBody>? _response;
|
||||||
|
StreamController<NotificationEvent>? _controller;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
SseNotificationService({
|
||||||
|
required String token,
|
||||||
|
String baseUrl = 'http://localhost:8080/api/v1',
|
||||||
|
}) : _token = token,
|
||||||
|
_baseUrl = baseUrl;
|
||||||
|
|
||||||
|
/// 通知事件流
|
||||||
|
Stream<NotificationEvent> get events {
|
||||||
|
_controller ??= StreamController<NotificationEvent>.broadcast();
|
||||||
|
return _controller!.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连接到 SSE 端点
|
||||||
|
Future<void> connect() async {
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
_dio = Dio(BaseOptions(
|
||||||
|
baseUrl: _baseUrl,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
if (_token != null) 'Authorization': 'Bearer $_token',
|
||||||
|
},
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
_response = await _dio!.get<ResponseBody>('/message/stream');
|
||||||
|
|
||||||
|
if (_response?.data == null) return;
|
||||||
|
|
||||||
|
_response!.data!.stream.listen(
|
||||||
|
(data) {
|
||||||
|
_parseSseData(data);
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
if (!_disposed) {
|
||||||
|
// 自动重连逻辑(3秒延迟)
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (!_disposed) connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
if (!_disposed) {
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (!_disposed) connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 连接失败,延迟重连
|
||||||
|
if (!_disposed) {
|
||||||
|
Future.delayed(const Duration(seconds: 5), () {
|
||||||
|
if (!_disposed) connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 SSE 数据帧
|
||||||
|
void _parseSseData(List<int> data) {
|
||||||
|
final text = utf8.decode(data);
|
||||||
|
final lines = text.split('\n');
|
||||||
|
|
||||||
|
String? eventType;
|
||||||
|
String? eventData;
|
||||||
|
|
||||||
|
for (final line in lines) {
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
eventType = line.substring(6).trim();
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
eventData = line.substring(5).trim();
|
||||||
|
} else if (line.isEmpty && eventType != null && eventData != null) {
|
||||||
|
// 空行 = 事件结束
|
||||||
|
_emitEvent(eventType, eventData);
|
||||||
|
eventType = null;
|
||||||
|
eventData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发射通知事件到流
|
||||||
|
void _emitEvent(String type, String data) {
|
||||||
|
if (_disposed || _controller == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final payload = jsonDecode(data) as Map<String, dynamic>;
|
||||||
|
_controller!.add(NotificationEvent(
|
||||||
|
type: type,
|
||||||
|
payload: payload,
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
} catch (_) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 断开连接并释放资源
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_response?.data?.stream.listen((_) {});
|
||||||
|
_controller?.close();
|
||||||
|
_dio?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user