diff --git a/app/lib/data/repositories/class_repository.dart b/app/lib/data/repositories/class_repository.dart new file mode 100644 index 0000000..0d82343 --- /dev/null +++ b/app/lib/data/repositories/class_repository.dart @@ -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 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 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 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> getMyClasses() async { + final response = await _api.get('/diary/classes'); + final body = response.data as Map; + final items = body['data'] as List? ?? []; + return items + .map((json) => SchoolClass.fromJson(json as Map)) + .toList(); + } + + /// 创建班级(老师) + Future 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; + return SchoolClass.fromJson(body['data'] as Map); + } + + /// 加入班级(通过班级码) + Future 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; + return SchoolClass.fromJson(body['data'] as Map); + } + + /// 获取班级详情 + Future getClass(String classId) async { + final response = await _api.get('/diary/classes/$classId'); + final body = response.data as Map; + return SchoolClass.fromJson(body['data'] as Map); + } + + /// 获取班级成员列表 + Future> getMembers(String classId) async { + final response = await _api.get('/diary/classes/$classId/members'); + final body = response.data as Map; + final items = body['data'] as List? ?? []; + return items + .map((json) => ClassMemberDto.fromJson(json as Map)) + .toList(); + } + + // ===== 主题 ===== + + /// 获取班级主题列表 + Future> getTopics(String classId) async { + final response = await _api.get('/diary/classes/$classId/topics'); + final body = response.data as Map; + final items = body['data'] as List? ?? []; + return items + .map((json) => TopicDto.fromJson(json as Map)) + .toList(); + } + + /// 布置主题(老师) + Future 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; + return TopicDto.fromJson(body['data'] as Map); + } + + // ===== 评语 ===== + + /// 获取日记评语列表 + Future> getComments(String journalId) async { + final response = await _api.get('/diary/journals/$journalId/comments'); + final body = response.data as Map; + final items = body['data'] as List? ?? []; + return items + .map((json) => CommentDto.fromJson(json as Map)) + .toList(); + } + + /// 添加评语(老师点评学生日记) + Future 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; + return CommentDto.fromJson(body['data'] as Map); + } + + /// 删除评语 + Future deleteComment(String commentId) async { + await _api.delete('/diary/comments/$commentId'); + } +} diff --git a/app/lib/data/repositories/remote_journal_repository.dart b/app/lib/data/repositories/remote_journal_repository.dart new file mode 100644 index 0000000..4d7e9b4 --- /dev/null +++ b/app/lib/data/repositories/remote_journal_repository.dart @@ -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> getJournals({ + DateTime? dateFrom, + DateTime? dateTo, + int? page, + int? pageSize, + }) async { + final queryParams = {}; + 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; + final items = body['data'] as List? ?? []; + return items + .map((json) => JournalEntry.fromJson(json as Map)) + .toList(); + } + + @override + Future getJournal(String id) async { + try { + final response = await _api.get('/diary/journals/$id'); + final body = response.data as Map; + return JournalEntry.fromJson(body['data'] as Map); + } on ApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + + @override + Future createJournal(JournalEntry entry) async { + final response = await _api.post('/diary/journals', data: entry.toJson()); + final body = response.data as Map; + return JournalEntry.fromJson(body['data'] as Map); + } + + @override + Future 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; + return JournalEntry.fromJson(body['data'] as Map); + } + + @override + Future deleteJournal(String id) async { + await _api.delete('/diary/journals/$id'); + } + + @override + Future> getElements(String journalId) async { + final response = await _api.get('/diary/journals/$journalId/elements'); + final body = response.data as Map; + final items = body['data'] as List? ?? []; + return items + .map((json) => JournalElement.fromJson(json as Map)) + .toList(); + } + + @override + Future addElement(JournalElement element) async { + final response = await _api.post( + '/diary/journals/${element.journalId}/elements', + data: element.toJson(), + ); + final body = response.data as Map; + return JournalElement.fromJson(body['data'] as Map); + } + + @override + Future 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; + return JournalElement.fromJson(body['data'] as Map); + } + + @override + Future 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'; +} diff --git a/app/lib/data/services/sse_notification_service.dart b/app/lib/data/services/sse_notification_service.dart new file mode 100644 index 0000000..0562002 --- /dev/null +++ b/app/lib/data/services/sse_notification_service.dart @@ -0,0 +1,146 @@ +// SSE 通知服务 — 监听服务端推送事件 + +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +/// SSE 通知事件 +class NotificationEvent { + final String type; + final Map 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? _response; + StreamController? _controller; + bool _disposed = false; + + SseNotificationService({ + required String token, + String baseUrl = 'http://localhost:8080/api/v1', + }) : _token = token, + _baseUrl = baseUrl; + + /// 通知事件流 + Stream get events { + _controller ??= StreamController.broadcast(); + return _controller!.stream; + } + + /// 连接到 SSE 端点 + Future 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('/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 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; + _controller!.add(NotificationEvent( + type: type, + payload: payload, + receivedAt: DateTime.now(), + )); + } catch (_) { + // 忽略解析错误 + } + } + + /// 断开连接并释放资源 + void dispose() { + _disposed = true; + _response?.data?.stream.listen((_) {}); + _controller?.close(); + _dio?.close(); + } +}