diff --git a/lib/app/bindings/initial_binding.dart b/lib/app/bindings/initial_binding.dart index b09de08..e8e15f8 100644 --- a/lib/app/bindings/initial_binding.dart +++ b/lib/app/bindings/initial_binding.dart @@ -2,8 +2,9 @@ import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:problem_check_system/data/providers/connectivity_provider.dart'; import 'package:problem_check_system/data/providers/http_provider.dart'; -import 'package:problem_check_system/data/providers/local_database.dart'; +import 'package:problem_check_system/data/providers/sqlite_provider.dart'; import 'package:problem_check_system/data/repositories/auth_repository.dart'; +import 'package:problem_check_system/data/repositories/file_repository.dart'; import 'package:problem_check_system/data/repositories/problem_repository.dart'; class InitialBinding implements Bindings { @@ -17,11 +18,13 @@ class InitialBinding implements Bindings { /// 立即注册所有的适配器 Get.put(GetStorage(), permanent: true); Get.put(HttpProvider()); - Get.put(LocalDatabase()); + Get.put(SQLiteProvider()); Get.put(ConnectivityProvider()); } void _registerRepositories() { + Get.lazyPut(() => FileRepository()); + /// 懒加载注册所有的仓库 Get.lazyPut( () => AuthRepository( @@ -32,7 +35,7 @@ class InitialBinding implements Bindings { ); Get.lazyPut( () => ProblemRepository( - localDatabase: Get.find(), + sqliteProvider: Get.find(), httpProvider: Get.find(), connectivityProvider: Get.find(), ), diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 856326b..3528d3b 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -3,7 +3,7 @@ import 'package:problem_check_system/modules/home/bindings/home_binding.dart'; import 'package:problem_check_system/modules/home/views/home_page.dart'; import 'package:problem_check_system/modules/auth/bindings/login_binding.dart'; import 'package:problem_check_system/modules/auth/views/login_page.dart'; -import 'package:problem_check_system/modules/my/bingdings/change_password_binding.dart'; +import 'package:problem_check_system/modules/my/bindings/change_password_binding.dart'; import 'package:problem_check_system/modules/my/views/change_password.dart'; import 'package:problem_check_system/modules/problem/views/problem_upload_page.dart'; diff --git a/lib/data/models/enum_model.dart b/lib/data/models/enum_model.dart new file mode 100644 index 0000000..4292078 --- /dev/null +++ b/lib/data/models/enum_model.dart @@ -0,0 +1,22 @@ +enum SyncStatus { + /// 未同步到服务器 + notSynced, + + /// 已同步,本地无修改 + synced, + + /// 已同步,但本地有修改 + modified, +} + +// 图片的同步状态 +enum ImageStatus { + /// 新增的本地图片,需要上传 + local, + + /// 已上传,本地无修改 + synced, + + /// 已同步,但已在本地删除,需要通知服务器 + deleted, +} diff --git a/lib/data/models/image_metadata_model.dart b/lib/data/models/image_metadata_model.dart new file mode 100644 index 0000000..522a01d --- /dev/null +++ b/lib/data/models/image_metadata_model.dart @@ -0,0 +1,32 @@ +// image_metadata_model.dart +import 'package:problem_check_system/data/models/enum_model.dart'; + +class ImageMetadata { + final String localPath; + final String? remoteUrl; + final ImageStatus status; + + ImageMetadata({ + required this.localPath, + this.remoteUrl, + required this.status, + }); + + // For saving to SQL + Map toMap() { + return { + 'localPath': localPath, + 'remoteUrl': remoteUrl, + 'status': status.index, + }; + } + + // For reading from SQL + factory ImageMetadata.fromMap(Map map) { + return ImageMetadata( + localPath: map['localPath'] as String, + remoteUrl: map['remoteUrl'] as String?, + status: ImageStatus.values[map['status'] as int], + ); + } +} diff --git a/lib/data/models/problem_model.dart b/lib/data/models/problem_model.dart index 2a3f0b3..c9a074e 100644 --- a/lib/data/models/problem_model.dart +++ b/lib/data/models/problem_model.dart @@ -1,36 +1,39 @@ -import 'package:get/get.dart'; +// problem_model.dart +import 'dart:convert'; +import 'package:problem_check_system/data/models/enum_model.dart'; +import 'package:problem_check_system/data/models/image_metadata_model.dart'; class Problem { String? id; String description; String location; - List imageUrls; - DateTime createdAt; - bool isUploaded; + List imageUrls; + DateTime creationTime; + SyncStatus syncStatus; String? censorTaskId; String? bindData; - // 添加可观察的选中状态 - final RxBool isChecked = false.obs; + bool isChecked; Problem({ this.id, required this.description, required this.location, required this.imageUrls, - required this.createdAt, + required this.creationTime, + this.syncStatus = SyncStatus.notSynced, this.censorTaskId, this.bindData, - this.isUploaded = false, + this.isChecked = false, }); - // copyWith 方法:根据字段和构造函数进行修正 + // copyWith method to create a new instance with updated values Problem copyWith({ String? id, String? description, String? location, - List? imageUrls, - DateTime? createdAt, - bool? isUploaded, + List? imageUrls, + DateTime? creationTime, + SyncStatus? syncStatus, String? censorTaskId, String? bindData, }) { @@ -39,40 +42,42 @@ class Problem { description: description ?? this.description, location: location ?? this.location, imageUrls: imageUrls ?? this.imageUrls, - createdAt: createdAt ?? this.createdAt, - isUploaded: isUploaded ?? this.isUploaded, + creationTime: creationTime ?? this.creationTime, + syncStatus: syncStatus ?? this.syncStatus, censorTaskId: censorTaskId ?? this.censorTaskId, bindData: bindData ?? this.bindData, ); } - // toMap 方法:修正键名,确保与 fromMap 保持一致 + // toMap method for serializing to a database-friendly Map Map toMap() { return { 'id': id, 'description': description, 'location': location, - 'imageUrls': imageUrls.join(';;'), // 使用正确的键名 'imageUrls' - 'createdAt': createdAt.millisecondsSinceEpoch, - 'isUploaded': isUploaded ? 1 : 0, + 'imageUrls': jsonEncode(imageUrls.map((meta) => meta.toMap()).toList()), + 'creationTime': creationTime.millisecondsSinceEpoch, + 'syncStatus': syncStatus.index, 'censorTaskId': censorTaskId, - 'bindData': bindData, // 使用正确的键名 'bindData' + 'bindData': bindData, }; } - // fromMap 方法:修正键名,确保与 toMap 保持一致 + // fromMap factory constructor for deserializing from a Map factory Problem.fromMap(Map map) { return Problem( id: map['id'], description: map['description'], location: map['location'], - imageUrls: (map['imageUrls'] as String).split( - ';;', - ), // 使用正确的键名 'imageUrls' - createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt']), - isUploaded: map['isUploaded'] == 1, + imageUrls: (jsonDecode(map['imageUrls']) as List) + .map((item) => ImageMetadata.fromMap(item as Map)) + .toList(), + creationTime: DateTime.fromMillisecondsSinceEpoch( + map['creationTime'] as int, + ), + syncStatus: SyncStatus.values[map['syncStatus'] as int], censorTaskId: map['censorTaskId'], - bindData: map['bindData'], // 使用正确的键名 'bindData' + bindData: map['bindData'], ); } } diff --git a/lib/data/providers/local_database.dart b/lib/data/providers/local_database.dart deleted file mode 100644 index c2dea50..0000000 --- a/lib/data/providers/local_database.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'package:get/get.dart'; -import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart'; -import 'package:uuid/uuid.dart'; -import '../models/problem_model.dart'; - -/// LocalDatabase 是一个 GetxService,它负责管理本地 SQLite 数据库。 -/// GetxService 确保这个类在整个应用生命周期中都是一个单例,并且不会被自动销毁。 -class LocalDatabase extends GetxService { - static const String _dbName = 'problems.db'; - static const String _tableName = 'problems'; - - /// 私有的数据库实例,只能在 LocalDatabase 类内部访问。 - late Database _database; - - /// onInit 是 GetxService 的生命周期方法,在服务首次被创建时调用。 - /// 它是执行异步初始化的最佳位置,确保在使用服务前,数据库已经准备就绪。 - @override - void onInit() { - super.onInit(); - _initDatabase(); - } - - /// 异步初始化数据库。它会打开数据库连接,如果数据库不存在则创建。 - Future _initDatabase() async { - final databasePath = await getDatabasesPath(); - final path = join(databasePath, _dbName); - - _database = await openDatabase(path, version: 1, onCreate: _onCreate); - } - - /// 数据库创建时的回调函数,用于执行建表语句。 - Future _onCreate(Database db, int version) async { - await db.execute(''' - CREATE TABLE $_tableName( - id TEXT PRIMARY KEY, - description TEXT NOT NULL, - location TEXT NOT NULL, - imageUrls TEXT NOT NULL, - createdAt INTEGER NOT NULL, - isUploaded INTEGER NOT NULL, - censorTaskId TEXT, - bindData TEXT - ) - '''); - } - - /// 插入一个新问题到数据库。在插入前,会为其生成一个唯一的 UUID。 - /// 返回插入的行数。 - Future insertProblem(Problem problem) async { - problem.id = const Uuid().v4(); - return await _database.insert(_tableName, problem.toMap()); - } - - /// 根据 ID 删除数据库中的问题。 - /// 返回删除的行数。 - Future deleteProblem(String id) async { - return await _database.delete(_tableName, where: 'id = ?', whereArgs: [id]); - } - - /// 更新数据库中已存在的问题。 - /// 返回更新的行数。 - Future updateProblem(Problem problem) async { - return await _database.update( - _tableName, - problem.toMap(), - where: 'id = ?', - whereArgs: [problem.id], - ); - } - - /// 根据 ID 获取单个问题。 - /// 如果找到问题,返回 Problem 对象;否则,返回 null。 - Future getProblemById(String id) async { - final List> maps = await _database.query( - _tableName, - where: 'id = ?', - whereArgs: [id], - limit: 1, // 限制结果为1,因为ID是唯一的 - ); - - if (maps.isNotEmpty) { - // 找到问题,将其转换为 Problem 对象 - return Problem.fromMap(maps.first); - } else { - // 未找到问题,返回 null - return null; - } - } - - /// 通用查询方法,根据可选的筛选条件获取问题列表。 - /// - `startDate`/`endDate`:筛选创建时间范围。 - /// - `uploadStatus`:筛选上传状态('已上传', '未上传', '全部')。 - /// - `bindStatus`:筛选绑定状态('已绑定', '未绑定', '全部')。 - Future> getProblems({ - DateTime? startDate, - DateTime? endDate, - String uploadStatus = '全部', - String bindStatus = '全部', - }) async { - final List whereClauses = []; - final List whereArgs = []; - - if (startDate != null) { - whereClauses.add('createdAt >= ?'); - whereArgs.add(startDate.millisecondsSinceEpoch); - } - - if (endDate != null) { - whereClauses.add('createdAt <= ?'); - whereArgs.add(endDate.millisecondsSinceEpoch); - } - - if (uploadStatus != '全部') { - whereClauses.add('isUploaded = ?'); - whereArgs.add(uploadStatus == '已上传' ? 1 : 0); - } - - if (bindStatus != '全部') { - if (bindStatus == '已绑定') { - whereClauses.add('censorTaskId IS NOT NULL'); - } else { - whereClauses.add('censorTaskId IS NULL'); - } - } - - final String whereString = whereClauses.join(' AND '); - - final List> maps = await _database.query( - _tableName, - where: whereString.isEmpty ? null : whereString, - whereArgs: whereArgs.isEmpty ? null : whereArgs, - orderBy: 'createdAt DESC', - ); - - return List.generate(maps.length, (i) { - return Problem.fromMap(maps[i]); - }); - } - - /// onClose 是 GetxService 的生命周期方法,在服务被销毁前调用。 - /// 虽然 GetxService 默认是永久的,但养成关闭数据库连接的习惯是很好的实践。 - @override - void onClose() { - _database.close(); - super.onClose(); - } -} diff --git a/lib/data/providers/sqlite_provider.dart b/lib/data/providers/sqlite_provider.dart new file mode 100644 index 0000000..ec81f0d --- /dev/null +++ b/lib/data/providers/sqlite_provider.dart @@ -0,0 +1,147 @@ +// sqlite_provider.dart +import 'package:get/get.dart'; +import 'package:problem_check_system/data/models/enum_model.dart'; +import 'package:problem_check_system/data/models/problem_model.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import 'package:uuid/uuid.dart'; + +/// `SQLiteProvider` 是一个 GetxService,负责管理本地 SQLite 数据库。 +/// 作为一个单例服务,它在整个应用生命周期中只会被创建一次。 +class SQLiteProvider extends GetxService { + static const String _dbName = 'problems.db'; + static const String _tableName = 'problems'; + + /// 私有数据库实例,仅在服务内部访问。 + late Database _database; + + /// 在服务首次初始化时调用,用于执行异步设置。 + @override + void onInit() { + super.onInit(); + _initDatabase(); + } + + /// 异步初始化数据库连接。如果数据库不存在,则会创建它。 + Future _initDatabase() async { + final databasePath = await getDatabasesPath(); + final path = join(databasePath, _dbName); + + _database = await openDatabase(path, version: 1, onCreate: _onCreate); + } + + /// 数据库创建时的回调函数,用于定义表结构。 + Future _onCreate(Database db, int version) async { + await db.execute(''' + CREATE TABLE $_tableName( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + location TEXT NOT NULL, + imageUrls TEXT NOT NULL, + creationTime INTEGER NOT NULL, + syncStatus INTEGER NOT NULL, + censorTaskId TEXT, + bindData TEXT + ) + '''); + } + + /// --- + + /// **数据操作 (CRUD) 方法** + + /// 向数据库中插入一个新问题。 + /// 如果 `problem` 没有 `id`,会自动生成一个唯一的 UUID。 + Future insertProblem(Problem problem) async { + final problemToInsert = problem.copyWith( + id: problem.id ?? const Uuid().v4(), + ); + return await _database.insert( + _tableName, + problemToInsert.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// 根据 ID 从数据库中删除一个问题。 + /// 返回被删除的行数。 + Future deleteProblem(String id) async { + return await _database.delete(_tableName, where: 'id = ?', whereArgs: [id]); + } + + /// 更新数据库中已存在的问题。 + /// 返回被更新的行数。 + Future updateProblem(Problem problem) async { + return await _database.update( + _tableName, + problem.toMap(), + where: 'id = ?', + whereArgs: [problem.id], + ); + } + + /// 根据 ID 获取单个问题。 + /// 如果找到则返回 `Problem` 对象,否则返回 `null`。 + Future getProblemById(String id) async { + final List> maps = await _database.query( + _tableName, + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + + if (maps.isNotEmpty) { + return Problem.fromMap(maps.first); + } + return null; + } + + /// 获取所有问题,支持按筛选条件查询。 + /// 可选参数用于筛选创建时间范围和同步状态。 + Future> getProblems({ + DateTime? startDate, + DateTime? endDate, + SyncStatus? syncStatus, + }) async { + final List whereClauses = []; + final List whereArgs = []; + + if (startDate != null) { + whereClauses.add('creationTime >= ?'); + whereArgs.add(startDate.millisecondsSinceEpoch); + } + + if (endDate != null) { + whereClauses.add('creationTime <= ?'); + whereArgs.add(endDate.millisecondsSinceEpoch); + } + + if (syncStatus != null) { + whereClauses.add('syncStatus = ?'); + whereArgs.add(syncStatus.index); + } + + final String? whereString = whereClauses.isNotEmpty + ? whereClauses.join(' AND ') + : null; + + final List> maps = await _database.query( + _tableName, + where: whereString, + whereArgs: whereArgs.isEmpty ? null : whereArgs, + orderBy: 'creationTime DESC', + ); + + return maps.map((json) => Problem.fromMap(json)).toList(); + } + + /// --- + + /// `GetxService` 生命周期方法,在服务被销毁前调用, + /// 用于关闭数据库连接,防止资源泄漏。 + @override + void onClose() { + _database.close(); + super.onClose(); + } +} diff --git a/lib/data/repositories/auth_repository.dart b/lib/data/repositories/auth_repository.dart index bff1c2a..68a2cf8 100644 --- a/lib/data/repositories/auth_repository.dart +++ b/lib/data/repositories/auth_repository.dart @@ -20,7 +20,7 @@ class AuthRepository extends GetxService { static const String _tokenKey = 'token'; static const String _refreshTokenKey = 'refresh_token'; static const String _loginKey = 'user'; - static const String _rememberPassword = 'remember_password'; + static const String _rememberMe = 'remember_me'; void saveToken(String token) { storage.write(_tokenKey, token); @@ -60,12 +60,12 @@ class AuthRepository extends GetxService { storage.remove(_loginKey); } - void addRememberPassword(bool remembered) { - storage.write(_rememberPassword, remembered); + void addRememberMe(bool remembered) { + storage.write(_rememberMe, remembered); } - bool getRememberPassword() { - return storage.read(_rememberPassword); + bool getRememberMe() { + return storage.read(_rememberMe) ?? false; } void clearAuthData() { diff --git a/lib/data/repositories/file_repository.dart b/lib/data/repositories/file_repository.dart new file mode 100644 index 0000000..19c5fd4 --- /dev/null +++ b/lib/data/repositories/file_repository.dart @@ -0,0 +1,71 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; // 引入 kDebugMode 和 debugPrint +import 'package:get/get.dart' hide FormData, MultipartFile; +import 'package:path/path.dart' as p; +import 'package:problem_check_system/data/providers/http_provider.dart'; + +class FileRepository { + final HttpProvider _httpProvider = Get.find(); + + /// 上传图片文件到服务器。 + /// @param imageFile 要上传的本地图片文件。 + /// @param cancelToken 用于取消上传任务的令牌。 + /// @param onSendProgress 上传进度回调,提供已发送和总大小。 + /// @return 上传成功后服务器返回的图片 URL。 + Future uploadImage( + File imageFile, { + required CancelToken cancelToken, + ProgressCallback? onSendProgress, + }) async { + try { + // 1. 创建 FormData 对象,用于构建 multipart/form-data 请求体 + final formData = FormData.fromMap({ + // 'file': 这通常是后端接口定义的文件字段名 + 'file': await MultipartFile.fromFile( + imageFile.path, + filename: p.basename(imageFile.path), + ), + }); + + // 2. 使用 HttpProvider 的 post 方法发送请求 + final response = await _httpProvider.post( + '/api/Objects/association/problem', + data: formData, + cancelToken: cancelToken, // 将取消令牌传递给 post 请求 + onSendProgress: onSendProgress, // 将进度回调传递给 post 请求 + ); + + // --- 在这里打印服务器的完整响应结构 (仅在调试模式下) --- + if (kDebugMode) { + debugPrint('服务器返回的状态码: ${response.statusCode}'); + debugPrint('服务器返回的原始数据: ${response.data}'); + } + + // 3. 处理响应,并返回图片 URL + if (response.statusCode == 200) { + final Map data = response.data; + + // 假设服务器返回的图片 URL 字段名为 'url' + final imageUrl = data['url']; + + if (imageUrl is String && imageUrl.isNotEmpty) { + return imageUrl; + } else { + // 如果返回结构不符合预期,抛出自定义异常 + throw Exception('服务器响应中未找到有效的图片URL'); + } + } else { + // 对于非 200 状态码,抛出异常 + throw Exception('上传失败,状态码: ${response.statusCode}'); + } + } on DioException catch (e) { + // 捕获 Dio 异常并重新抛出,以便上层逻辑可以处理(如取消、超时等) + // 使用 rethrow 来保留原始的异常栈信息 + rethrow; + } catch (e) { + // 捕获其他未知错误 + throw Exception('图片上传发生未知错误: $e'); + } + } +} diff --git a/lib/data/repositories/problem_repository.dart b/lib/data/repositories/problem_repository.dart index cca5f2c..0000ed4 100644 --- a/lib/data/repositories/problem_repository.dart +++ b/lib/data/repositories/problem_repository.dart @@ -1,20 +1,27 @@ -import 'package:get/get.dart'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:get/get.dart' hide MultipartFile, FormData; +import 'package:problem_check_system/data/models/enum_model.dart'; +import 'package:problem_check_system/data/models/image_metadata_model.dart'; import 'package:problem_check_system/data/models/problem_model.dart'; import 'package:problem_check_system/data/providers/connectivity_provider.dart'; import 'package:problem_check_system/data/providers/http_provider.dart'; -import 'package:problem_check_system/data/providers/local_database.dart'; +import 'package:problem_check_system/data/providers/sqlite_provider.dart'; +import 'package:problem_check_system/data/repositories/file_repository.dart'; /// 问题仓库,负责处理问题数据的本地持久化。 /// 它封装了底层数据库操作,为业务逻辑层提供一个简洁的接口。 class ProblemRepository extends GetxService { - final LocalDatabase localDatabase; + final SQLiteProvider sqliteProvider; final HttpProvider httpProvider; final ConnectivityProvider connectivityProvider; + final FileRepository fileRepository = Get.find(); RxBool get isOnline => connectivityProvider.isOnline; ProblemRepository({ - required this.localDatabase, + required this.sqliteProvider, required this.httpProvider, required this.connectivityProvider, }); @@ -23,14 +30,14 @@ class ProblemRepository extends GetxService { /// 如果问题存在则更新,如果不存在则插入。 Future updateProblem(Problem problem) async { // 检查问题是否存在,通常通过ID判断 - final existingProblem = await localDatabase.getProblemById(problem.id!); + final existingProblem = await sqliteProvider.getProblemById(problem.id!); if (existingProblem != null) { // 问题已存在,执行更新操作 - await localDatabase.updateProblem(problem); + await sqliteProvider.updateProblem(problem); } else { // 问题不存在,执行插入操作 - await localDatabase.insertProblem(problem); + await sqliteProvider.insertProblem(problem); } } @@ -44,19 +51,155 @@ class ProblemRepository extends GetxService { String uploadStatus = '全部', String bindStatus = '全部', }) async { - return await localDatabase.getProblems( + return await sqliteProvider.getProblems( startDate: startDate, endDate: endDate, - uploadStatus: uploadStatus, - bindStatus: bindStatus, + syncStatus: SyncStatus.notSynced, ); } Future insertProblem(Problem problem) async { - await localDatabase.insertProblem(problem); + await sqliteProvider.insertProblem(problem); } Future deleteProblem(String id) async { - await localDatabase.deleteProblem(id); + await sqliteProvider.deleteProblem(id); + } + + // * /api/Objects/association/${file.name} + /// 上传单个问题及其所有关联的图片。 + Future uploadProblem( + Problem problem, { + required CancelToken cancelToken, + required void Function(double progress) onProgress, + }) async { + try { + final newImages = problem.imageUrls + .where((img) => img.status == ImageStatus.local) + .toList(); + final totalFilesToUpload = newImages.length; + int filesUploadedCount = 0; + + // 1. 上传所有状态为 ImageStatus.local 的新图片 + final List remoteUrls = []; + for (var image in newImages) { + // 修正:当上传被取消时,抛出异常而不是无返回值的 return + if (cancelToken.isCancelled) { + throw DioException( + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.cancel, + error: '上传已取消', + ); + } + final imageFile = File(image.localPath); + final url = await fileRepository.uploadImage( + imageFile, + cancelToken: cancelToken, + onSendProgress: (sent, total) { + double overallProgress = + (filesUploadedCount + (sent / total)) / totalFilesToUpload; + onProgress(overallProgress); + }, + ); + remoteUrls.add(url); + filesUploadedCount++; + } + onProgress(1.0); // 确保图片上传进度为100% + + // 2. 构建 API payload + final List finalRemoteUrls = []; + int newImageIndex = 0; + for (var image in problem.imageUrls) { + if (image.status == ImageStatus.synced) { + finalRemoteUrls.add(image.remoteUrl!); + } else if (image.status == ImageStatus.local) { + finalRemoteUrls.add(remoteUrls[newImageIndex]); + newImageIndex++; + } + } + + final apiPayload = { + 'id': problem.id, + 'description': problem.description, + 'location': problem.location, + 'imageUrls': finalRemoteUrls, // 使用整合后的网络URL列表 + 'createdAt': problem.creationTime.toIso8601String(), + // ... 其他字段 + }; + + // 3. 发送给服务器 + final response = await httpProvider.post( + '/api/problem', + data: apiPayload, + cancelToken: cancelToken, + ); + + // 4. 处理服务器响应,并更新本地模型状态 + if (response.statusCode == 200) { + final List updatedImageMetadata = []; + int uploadedUrlIndex = 0; + for (var image in problem.imageUrls) { + if (image.status == ImageStatus.local) { + updatedImageMetadata.add( + ImageMetadata( + localPath: image.localPath, + remoteUrl: remoteUrls[uploadedUrlIndex], + status: ImageStatus.synced, + ), + ); + uploadedUrlIndex++; + } else { + updatedImageMetadata.add(image); + } + } + + // 返回一个包含新同步状态和更新后图片列表的对象 + return problem.copyWith( + syncStatus: SyncStatus.synced, + imageUrls: updatedImageMetadata, + ); + } else { + throw Exception('问题上传失败,状态码: ${response.statusCode}'); + } + } on DioException { + rethrow; + } + } + + /// 新增:上传问题列表。 + /// 遍历问题列表,并计算总进度。 + Future> uploadProblems( + List problems, { + required CancelToken cancelToken, + required void Function(double progress) onProgress, + }) async { + final int totalProblems = problems.length; + final List updatedProblems = []; + + try { + for (int i = 0; i < totalProblems; i++) { + // 如果取消令牌被触发,停止并返回 + if (cancelToken.isCancelled) { + break; + } + + final problemToUpload = problems[i]; + + // 传递一个子进度回调,用于计算单个问题的进度 + final updatedProblem = await uploadProblem( + problemToUpload, + cancelToken: cancelToken, + onProgress: (progress) { + // 计算总体进度:(已完成的问题数 + 当前问题的进度) / 总问题数 + final overallProgress = (i + progress) / totalProblems; + onProgress(overallProgress); + }, + ); + updatedProblems.add(updatedProblem); + } + return updatedProblems; + } on DioException { + rethrow; + } } } diff --git a/lib/modules/auth/controllers/login_controller.dart b/lib/modules/auth/controllers/login_controller.dart index a37a364..a58cf6c 100644 --- a/lib/modules/auth/controllers/login_controller.dart +++ b/lib/modules/auth/controllers/login_controller.dart @@ -10,7 +10,7 @@ class LoginController extends GetxController { final TextEditingController passwordController = TextEditingController(); final isLoading = false.obs; - final rememberPassword = false.obs; + final rememberMe = false.obs; LoginController({required AuthRepository authRepository}) : _authRepository = authRepository; @@ -18,13 +18,12 @@ class LoginController extends GetxController { @override void onInit() { super.onInit(); - _loadRememberedUser(); + _loadRememberedMe(); } - void _loadRememberedUser() { - final remember = _authRepository.getRememberPassword(); - rememberPassword.value = remember; - if (remember) { + void _loadRememberedMe() { + rememberMe.value = _authRepository.getRememberMe(); + if (rememberMe.value) { final loginData = _authRepository.getLoginKey(); usernameController.text = loginData.username; passwordController.text = loginData.password; @@ -64,9 +63,9 @@ class LoginController extends GetxController { // 登录成功后 _authRepository.saveToken(loginResponse.token); _authRepository.saveRefreshToken(loginResponse.refreshToken); - _authRepository.addRememberPassword(rememberPassword.value); + _authRepository.addRememberMe(rememberMe.value); - if (rememberPassword.value) { + if (rememberMe.value) { _authRepository.addLoginKey(loginRequest); } else { _authRepository.removeLoginKey(); diff --git a/lib/modules/auth/views/login_page.dart b/lib/modules/auth/views/login_page.dart index c81594f..22d2483 100644 --- a/lib/modules/auth/views/login_page.dart +++ b/lib/modules/auth/views/login_page.dart @@ -127,8 +127,8 @@ class LoginPage extends GetView { children: [ Obx( () => Checkbox( - value: controller.rememberPassword.value, - onChanged: (value) => controller.rememberPassword.value = value!, + value: controller.rememberMe.value, + onChanged: (value) => controller.rememberMe.value = value!, ), ), Text( diff --git a/lib/modules/my/bingdings/change_password_binding.dart b/lib/modules/my/bindings/change_password_binding.dart similarity index 100% rename from lib/modules/my/bingdings/change_password_binding.dart rename to lib/modules/my/bindings/change_password_binding.dart diff --git a/lib/modules/problem/controllers/problem_controller.dart b/lib/modules/problem/controllers/problem_controller.dart index 0385a47..ca37015 100644 --- a/lib/modules/problem/controllers/problem_controller.dart +++ b/lib/modules/problem/controllers/problem_controller.dart @@ -5,8 +5,6 @@ import 'package:dio/dio.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart' hide MultipartFile, FormData; import 'package:flutter/material.dart'; -import 'dart:io'; -import 'package:path/path.dart' as path; import 'package:problem_check_system/data/repositories/problem_repository.dart'; import 'package:problem_check_system/modules/problem/views/widgets/custom_data_range_dropdown.dart'; import 'package:problem_check_system/data/models/problem_model.dart'; @@ -26,16 +24,15 @@ class ProblemController extends GetxController final RxList unUploadedProblems = [].obs; final Rx allSelected = false.obs; final RxDouble uploadProgress = 0.0.obs; + // Dio 的取消令牌,用于取消正在进行的请求 + late CancelToken _cancelToken; + int get selectedCount { - return unUploadedProblems - .where((problem) => problem.isChecked.value) - .length; + return unUploadedProblems.where((problem) => problem.isChecked).length; } - List get selectedUnUploadedProblems { - return unUploadedProblems - .where((problem) => problem.isChecked.value) - .toList(); + List get selectedProblems { + return unUploadedProblems.where((problem) => problem.isChecked).toList(); } /// 筛选条件 @@ -83,75 +80,111 @@ class ProblemController extends GetxController void selectAll() { final bool newState = !allSelected.value; for (var problem in unUploadedProblems) { - problem.isChecked.value = newState; + problem.isChecked = newState; } allSelected.value = newState; // _updateSelectedList(); } - void uploadProblems() async { - // if (selectedUnUploadedProblems.isEmpty) return; - // // 实际的上传逻辑,例如调用 API - // // 上传完成后,清空列表或更新状态 - // selectedUnUploadedProblems.clear(); - for (var problem in selectedUnUploadedProblems) { - await uploadProblem(problem); + // 启动上传流程 + Future handleUpload() async { + if (selectedProblems.isEmpty) { + Get.snackbar('提示', '请选择要上传的问题'); + return; } - } - Future uploadProblem(Problem problem) async { + uploadProgress.value = 0.0; + _cancelToken = CancelToken(); + + // 显示上传对话框 + showUploadProgressDialog(); + try { - final formData = FormData.fromMap({ - 'description': problem.description, - 'location': problem.location, - 'createdAt': problem.createdAt.toIso8601String(), - 'boundInfo': problem.bindData ?? '', - }); - - for (var imageUrl in problem.imageUrls) { - final file = File(imageUrl); - if (await file.exists()) { - formData.files.add( - MapEntry( - 'images', - await MultipartFile.fromFile( - imageUrl, - filename: path.basename(imageUrl), - ), - ), - ); - } - } + // 直接将问题列表传递给 Repository + await problemRepository.uploadProblems( + selectedProblems, + cancelToken: _cancelToken, + onProgress: (progress) { + uploadProgress.value = progress; + }, + ); - // final response = await httpProvider.post( - // 'https://your-server.com/api/problems', - // data: formData, - // options: Options( - // sendTimeout: const Duration(seconds: 30), - // receiveTimeout: const Duration(seconds: 30), - // ), - // ); - - // if (response.statusCode == 200) { - // final updatedProblem = problem.copyWith(isUploaded: true); - // await updateProblem(updatedProblem); - return true; - // } else { - // throw Exception('服务器返回错误状态码: ${response.statusCode}'); - // } + Get.back(); + Get.snackbar('成功', '所有问题已成功上传!', snackPosition: SnackPosition.BOTTOM); } on DioException catch (e) { - if (e.type == DioExceptionType.connectionTimeout || - e.type == DioExceptionType.receiveTimeout) { - Get.snackbar('网络超时', '请检查网络连接后重试'); + Get.back(); + if (CancelToken.isCancel(e)) { + Get.snackbar('提示', '上传已取消', snackPosition: SnackPosition.BOTTOM); } else { - Get.snackbar('网络错误', '上传问题失败: ${e.message}'); + Get.snackbar( + '上传失败', + '错误: ${e.message}', + snackPosition: SnackPosition.BOTTOM, + ); } - return false; } catch (e) { - Get.snackbar('错误', '上传问题失败: $e'); - return false; + Get.back(); + Get.snackbar('上传失败', '发生未知错误', snackPosition: SnackPosition.BOTTOM); } } + + /// 显示上传对话框 + void showUploadProgressDialog() { + // 显示对话框 + Get.defaultDialog( + title: '上传问题中...', + content: Obx(() { + // final progress = (uploadProgress.value * 100).toInt(); + return Column( + // mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 16.h), + LinearProgressIndicator( + value: uploadProgress.value, + backgroundColor: Colors.grey[300], + valueColor: const AlwaysStoppedAnimation(Colors.blue), + ), + SizedBox(height: 16.h), + // Text('已完成: $progress%'), + Text('已上传: ${unUploadedProblems.length} / $selectedCount'), + ], + ); + }), + barrierDismissible: false, // 防止用户点击外部关闭对话框 + // 添加一个 "取消" 按钮 + cancel: ElevatedButton( + onPressed: () { + // 调用 controller 中的取消方法 + cancelUpload(); + }, + child: Text('取消', style: TextStyle(color: Colors.red)), + ), + ); + // 启动上传逻辑 + // ... + } + + void cancelUpload() { + // 在这里实现你的取消逻辑 + // 1. 停止上传任务(例如,取消 HTTP 请求) + // 2. 将上传进度重置为0 + uploadProgress.value = 0.0; + // 3. 关闭对话框 + Get.back(); + // 4. 可以添加一个提示,例如: + // Get.snackbar('提示', '上传已取消'); + } + + void uploadProblems() async { + // if (selectedUnUploadedProblems.isEmpty) return; + // // 实际的上传逻辑,例如调用 API + // // 上传完成后,清空列表或更新状态 + // selectedUnUploadedProblems.clear(); + // for (var problem in selectedUnUploadedProblems) { + // await uploadProblem(problem); + // } + } + // #endregion // #region 悬浮按钮 @@ -323,16 +356,16 @@ class ProblemController extends GetxController } Future _deleteProblemImages(Problem problem) async { - for (var imagePath in problem.imageUrls) { - try { - final file = File(imagePath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - throw Exception(e); - } - } + // for (var imagePath in problem.imageUrls) { + // try { + // final file = File(imagePath); + // if (await file.exists()) { + // await file.delete(); + // } + // } catch (e) { + // throw Exception(e); + // } + // } } /// 显示日期选择器 diff --git a/lib/modules/problem/controllers/problem_form_controller.dart b/lib/modules/problem/controllers/problem_form_controller.dart index ed37561..346951c 100644 --- a/lib/modules/problem/controllers/problem_form_controller.dart +++ b/lib/modules/problem/controllers/problem_form_controller.dart @@ -4,12 +4,14 @@ import 'package:image_picker/image_picker.dart'; import 'package:path/path.dart' as path; import 'package:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:problem_check_system/data/models/enum_model.dart'; +import 'package:problem_check_system/data/models/image_metadata_model.dart'; import 'dart:io'; -import '../../../data/models/problem_model.dart'; +import 'package:problem_check_system/data/models/problem_model.dart'; import 'problem_controller.dart'; class ProblemFormController extends GetxController { - final ProblemController _problemController; + final ProblemController problemController; final TextEditingController descriptionController = TextEditingController(); final TextEditingController locationController = TextEditingController(); final RxList selectedImages = [].obs; @@ -19,27 +21,23 @@ class ProblemFormController extends GetxController { Problem? _currentProblem; // 使用依赖注入,便于测试 - ProblemFormController({ProblemController? problemController}) - : _problemController = problemController ?? Get.find(); + ProblemFormController({required this.problemController}); - // 是否是编辑模式 - bool get isEditing => _currentProblem != null; - - // 初始化方法,用于加载编辑数据 + /// 初始化方法,用于加载现有问题数据 void init(Problem? problem) { + _currentProblem = problem; if (problem != null) { - _currentProblem = problem; descriptionController.text = problem.description; locationController.text = problem.location; - // 清空旧图片,并加载新图片的路径 - selectedImages.clear(); - for (var path in problem.imageUrls) { - selectedImages.add(XFile(path)); - } + // 清空旧数据,并从 ImageMetadata 中加载图片路径 + // 假定您需要一个List来存储图片路径 + final imagePaths = problem.imageUrls + .map((meta) => XFile(meta.localPath)) + .toList(); + // 假设您的selectedImages是一个RxList或类似的列表,支触发一次UI重建 + selectedImages.assignAll(imagePaths); } else { - // 重置状态,确保是新增模式 - _currentProblem = null; descriptionController.clear(); locationController.clear(); selectedImages.clear(); @@ -134,7 +132,7 @@ class ProblemFormController extends GetxController { return true; } - // 保存问题 + /// 保存图片 Future saveProblem() async { if (!_validateForm()) { return; @@ -144,18 +142,18 @@ class ProblemFormController extends GetxController { try { // 保存图片到本地 - final List imagePaths = await _saveImagesToLocal(); + final List imagePaths = await _saveImagesToLocal(); - if (isEditing && _currentProblem != null) { + if (_currentProblem != null) { // 编辑模式:更新现有问题 final updatedProblem = _currentProblem!.copyWith( description: descriptionController.text, location: locationController.text, imageUrls: imagePaths, - createdAt: DateTime.now(), // 更新编辑时间 + creationTime: DateTime.now(), // 更新编辑时间 ); - await _problemController.updateProblem(updatedProblem); + await problemController.updateProblem(updatedProblem); Get.back(result: true); // 返回成功结果 Get.snackbar('成功', '问题已更新'); } else { @@ -164,11 +162,11 @@ class ProblemFormController extends GetxController { description: descriptionController.text, location: locationController.text, imageUrls: imagePaths, - createdAt: DateTime.now(), - isUploaded: false, + creationTime: DateTime.now(), + syncStatus: SyncStatus.synced, ); - await _problemController.addProblem(problem); + await problemController.addProblem(problem); Get.back(result: true); // 返回成功结果 Get.snackbar('成功', '问题已保存'); } @@ -180,8 +178,8 @@ class ProblemFormController extends GetxController { } // 保存图片到本地存储 - Future> _saveImagesToLocal() async { - final List imagePaths = []; + Future> _saveImagesToLocal() async { + final List imagePaths = []; final directory = await getApplicationDocumentsDirectory(); final imagesDir = Directory('${directory.path}/problem_images'); @@ -195,13 +193,17 @@ class ProblemFormController extends GetxController { final String fileName = 'problem_${DateTime.now().millisecondsSinceEpoch}_${path.basename(image.name)}'; final String imagePath = '${imagesDir.path}/$fileName'; + final ImageMetadata imageData = ImageMetadata( + localPath: imagePath, + status: ImageStatus.local, + ); final File imageFile = File(imagePath); // 读取图片字节并写入文件 final imageBytes = await image.readAsBytes(); await imageFile.writeAsBytes(imageBytes); - imagePaths.add(imagePath); + imagePaths.add(imageData); } catch (e) { throw Exception(e); } diff --git a/lib/modules/problem/views/problem_form_page.dart b/lib/modules/problem/views/problem_form_page.dart index 0498614..7db53ca 100644 --- a/lib/modules/problem/views/problem_form_page.dart +++ b/lib/modules/problem/views/problem_form_page.dart @@ -6,21 +6,16 @@ import 'package:image_picker/image_picker.dart'; import 'package:problem_check_system/modules/problem/controllers/problem_form_controller.dart'; import 'package:problem_check_system/data/models/problem_model.dart'; -class ProblemFormPage extends StatelessWidget { +class ProblemFormPage extends GetView { final Problem? problem; - final bool isReadOnly; // 新增的只读标志 - final ProblemFormController controller = Get.put(ProblemFormController()); + final bool isReadOnly; // 构造函数,接收只读标志 - ProblemFormPage({super.key, this.problem, this.isReadOnly = false}); + const ProblemFormPage({super.key, this.problem, this.isReadOnly = false}); @override Widget build(BuildContext context) { - // 在页面首次绘制后调用控制器初始化方法 - WidgetsBinding.instance.addPostFrameCallback((_) { - controller.init(problem); - }); - + controller.init(problem); return Scaffold( appBar: AppBar( flexibleSpace: Container( diff --git a/lib/modules/problem/views/problem_list_page.dart b/lib/modules/problem/views/problem_list_page.dart index f2fdbb6..fe96749 100644 --- a/lib/modules/problem/views/problem_list_page.dart +++ b/lib/modules/problem/views/problem_list_page.dart @@ -56,10 +56,10 @@ class ProblemListPage extends GetView { controller.deleteProblem(problem); Get.snackbar('成功', '问题已删除'); }, - child: ProblemCard(problem, viewType: viewType), + child: ProblemCard(problem: problem.obs, viewType: viewType), ); } else { - return ProblemCard(problem, viewType: viewType); + return ProblemCard(problem: problem.obs, viewType: viewType); } } diff --git a/lib/modules/problem/views/problem_upload_page.dart b/lib/modules/problem/views/problem_upload_page.dart index 2271b3e..0f6b021 100644 --- a/lib/modules/problem/views/problem_upload_page.dart +++ b/lib/modules/problem/views/problem_upload_page.dart @@ -59,7 +59,7 @@ class ProblemUploadPage extends GetView { child: Obx( () => ElevatedButton( onPressed: controller.selectedCount > 0 - ? controller.uploadProblems + ? controller.handleUpload : null, style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, @@ -73,28 +73,4 @@ class ProblemUploadPage extends GetView { ), ); } - - Widget uploadProgressWidget() { - return AlertDialog( - title: Text('上传中...'), - content: Obx(() { - final progress = (controller.uploadProgress.value * 100).toInt(); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - LinearProgressIndicator( - value: controller.uploadProgress.value, - backgroundColor: Colors.grey[300], - valueColor: AlwaysStoppedAnimation(Colors.blue), - ), - SizedBox(height: 16), - Text('已完成: $progress%'), - Text( - '已上传: ${controller.unUploadedProblems.length} / ${controller.selectedCount}', - ), - ], - ); - }), - ); - } } diff --git a/lib/modules/problem/views/widgets/problem_card.dart b/lib/modules/problem/views/widgets/problem_card.dart index c5368c0..45ff053 100644 --- a/lib/modules/problem/views/widgets/problem_card.dart +++ b/lib/modules/problem/views/widgets/problem_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; +import 'package:problem_check_system/data/models/enum_model.dart'; import 'package:problem_check_system/data/models/problem_model.dart'; import 'package:problem_check_system/modules/problem/controllers/problem_controller.dart'; import 'package:problem_check_system/modules/problem/views/widgets/custom_button.dart'; @@ -12,12 +13,12 @@ import 'package:tdesign_flutter/tdesign_flutter.dart'; enum ProblemCardViewType { buttons, checkbox } class ProblemCard extends GetView { - final Problem problem; + final Rx problem; final ProblemCardViewType viewType; - const ProblemCard( - this.problem, { + const ProblemCard({ super.key, + required this.problem, this.viewType = ProblemCardViewType.buttons, }); @@ -37,7 +38,7 @@ class ProblemCard extends GetView { subtitle: LayoutBuilder( builder: (context, constraints) { return Text( - problem.description, + problem.value.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14.sp), @@ -53,7 +54,10 @@ class ProblemCard extends GetView { children: [ Icon(Icons.location_on, color: Colors.grey, size: 16.h), SizedBox(width: 8.w), - Text(problem.location, style: TextStyle(fontSize: 12.sp)), + Text( + problem.value.location, + style: TextStyle(fontSize: 12.sp), + ), ], ), SizedBox(width: 16.w), @@ -62,7 +66,9 @@ class ProblemCard extends GetView { Icon(Icons.access_time, color: Colors.grey, size: 16.h), SizedBox(width: 8.w), Text( - DateFormat('yyyy-MM-dd HH:mm').format(problem.createdAt), + DateFormat( + 'yyyy-MM-dd HH:mm', + ).format(problem.value.creationTime), style: TextStyle(fontSize: 12.sp), ), ], @@ -77,10 +83,11 @@ class ProblemCard extends GetView { Wrap( spacing: 8, children: [ - problem.isUploaded + problem.value.syncStatus == SyncStatus.synced ? TDTag('已上传', isLight: true, theme: TDTagTheme.success) : TDTag('未上传', isLight: true, theme: TDTagTheme.danger), - problem.bindData != null && problem.bindData!.isNotEmpty + problem.value.bindData != null && + problem.value.bindData!.isNotEmpty ? TDTag('已绑定', isLight: true, theme: TDTagTheme.primary) : TDTag('未绑定', isLight: true, theme: TDTagTheme.warning), ], @@ -104,14 +111,16 @@ class ProblemCard extends GetView { CustomButton( text: '修改', onTap: () { - Get.to(ProblemFormPage(problem: problem)); + Get.to(ProblemFormPage(problem: problem.value)); }, ), SizedBox(width: 8.w), CustomButton( text: '查看', onTap: () { - Get.to(ProblemFormPage(problem: problem, isReadOnly: true)); + Get.to( + ProblemFormPage(problem: problem.value, isReadOnly: true), + ); }, ), SizedBox(width: 16.w), @@ -123,10 +132,10 @@ class ProblemCard extends GetView { child: Obx( () => Checkbox( // 将 Checkbox 的 value 绑定到 controller.isChecked.value - value: problem.isChecked.value, + value: problem.value.isChecked, // 当 Checkbox 状态改变时,调用 controller 中的方法来更新状态 onChanged: (bool? value) { - problem.isChecked.value = value ?? false; + problem.value.isChecked = value ?? false; controller.onProblemCheckedChange(); }, ),