import 'dart:convert'; import 'dart:io' show Platform; import 'package:cupertino_http/cupertino_http.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:http/http.dart' as http; import '../auth/session_store.dart'; import '../config/app_config.dart'; /// 标注众包 API 客户端(契约见 server/README.md「标注众包」)。 /// 异常语义同 OrderApi:失败抛 AnnotateApiException,登录失效抛 SessionExpired。 class AnnotateApiException implements Exception { final String message; const AnnotateApiException(this.message); @override String toString() => message; } class SessionExpiredException extends AnnotateApiException { const SessionExpiredException() : super('登录已失效,请重新登录'); } /// 众包任务条目 class AnnotateTaskInfo { final int id; final String name; final String datasetName; final String species; final int poolRemain; const AnnotateTaskInfo({ required this.id, required this.name, required this.datasetName, required this.species, required this.poolRemain, }); factory AnnotateTaskInfo.fromJson(Map j) => AnnotateTaskInfo( id: (j['id'] as num).toInt(), name: (j['name'] as String?) ?? '', datasetName: (j['datasetName'] as String?) ?? '', species: (j['species'] as String?) ?? '', poolRemain: (j['poolRemain'] as num?)?.toInt() ?? 0, ); } /// 我的标注统计(进度/奖励/冻结) class AnnotateStats { final int submittedTotal; final int approved; final int rejected; final double approveRatio; final int progressDone; final int rewardPerImages; final int rewardMinutes; final int totalEarnedMinutes; final int todayEarnedMinutes; final int dailyCapMinutes; final DateTime? frozenUntil; final DateTime? expiresAt; const AnnotateStats({ required this.submittedTotal, required this.approved, required this.rejected, required this.approveRatio, required this.progressDone, required this.rewardPerImages, required this.rewardMinutes, required this.totalEarnedMinutes, required this.todayEarnedMinutes, required this.dailyCapMinutes, required this.frozenUntil, required this.expiresAt, }); bool get frozen => frozenUntil != null && frozenUntil!.isAfter(DateTime.now()); factory AnnotateStats.fromJson(Map j) => AnnotateStats( submittedTotal: (j['submittedTotal'] as num?)?.toInt() ?? 0, approved: (j['approved'] as num?)?.toInt() ?? 0, rejected: (j['rejected'] as num?)?.toInt() ?? 0, approveRatio: (j['approveRatio'] as num?)?.toDouble() ?? 1, progressDone: (j['progressDone'] as num?)?.toInt() ?? 0, rewardPerImages: (j['rewardPerImages'] as num?)?.toInt() ?? 10, rewardMinutes: (j['rewardMinutes'] as num?)?.toInt() ?? 30, totalEarnedMinutes: (j['totalEarnedMinutes'] as num?)?.toInt() ?? 0, todayEarnedMinutes: (j['todayEarnedMinutes'] as num?)?.toInt() ?? 0, dailyCapMinutes: (j['dailyCapMinutes'] as num?)?.toInt() ?? 120, frozenUntil: j['frozenUntil'] == null ? null : DateTime.tryParse(j['frozenUntil'] as String), expiresAt: j['expiresAt'] == null ? null : DateTime.tryParse(j['expiresAt'] as String), ); } /// 领取到的单张图 class AnnotateClaimImage { final int imageId; final String url; // 相对路径,需拼 baseUrl;访问需 Bearer 头 final int width; final int height; const AnnotateClaimImage({ required this.imageId, required this.url, required this.width, required this.height, }); factory AnnotateClaimImage.fromJson(Map j) => AnnotateClaimImage( imageId: (j['imageId'] as num).toInt(), url: (j['url'] as String?) ?? '', width: (j['width'] as num?)?.toInt() ?? 0, height: (j['height'] as num?)?.toInt() ?? 0, ); } /// 提交结果(是否触发奖励发放) class AnnotateSubmitResult { final bool granted; final int minutes; final int todayEarnedMinutes; final int progressDone; const AnnotateSubmitResult({ required this.granted, required this.minutes, required this.todayEarnedMinutes, required this.progressDone, }); factory AnnotateSubmitResult.fromJson(Map j) => AnnotateSubmitResult( granted: j['granted'] == true, minutes: (j['minutes'] as num?)?.toInt() ?? 0, todayEarnedMinutes: (j['todayEarnedMinutes'] as num?)?.toInt() ?? 0, progressDone: (j['progressDone'] as num?)?.toInt() ?? 0, ); } class AnnotateApi { final String baseUrl; final SessionStore sessionStore; final http.Client _client; // iOS 本地网络 socket 拦截绕行,同 OrderApi(见其注释) AnnotateApi({String? baseUrl, required this.sessionStore, http.Client? client}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl, _client = client ?? _defaultHttpClient(); static http.Client _defaultHttpClient() { if (!kIsWeb && Platform.isIOS) { return CupertinoClient.defaultSessionConfiguration(); } return http.Client(); } /// 可领任务列表 + 我的统计 Future<(List, AnnotateStats)> tasks() async { final json = await _get('/api/v1/annotate/tasks'); final list = (json['list'] as List? ?? []) .map((e) => AnnotateTaskInfo.fromJson((e as Map).cast())) .toList(); final stats = AnnotateStats.fromJson( ((json['stats'] as Map?) ?? const {}).cast()); return (list, stats); } /// 领取一批标注图片 Future> claim(int taskId) async { final json = await _post( '/api/v1/annotate/claim', jsonEncode({'taskId': taskId})); return (json['images'] as List? ?? []) .map((e) => AnnotateClaimImage.fromJson((e as Map).cast())) .toList(); } /// 提交单张标注(boxes 元素:{class,cx,cy,w,h,confidence},归一化坐标) Future submit( int imageId, List> boxes) async { final json = await _post( '/api/v1/annotate/submit', jsonEncode({'imageId': imageId, 'boxes': boxes})); return AnnotateSubmitResult.fromJson(json); } /// 我的统计 Future me() async { final json = await _get('/api/v1/annotate/me'); return AnnotateStats.fromJson( ((json['stats'] as Map?) ?? const {}).cast()); } /// 图片完整 URL(Image.network 用,需带 Bearer 头) Uri imageUrl(String relative) => Uri.parse('$baseUrl$relative'); /// 图片请求头(标注器 Image.network 加载用) Future> imageHeaders() => _authHeaders(); Future> _authHeaders() async { final token = await sessionStore.readToken(); if (token == null || token.isEmpty) { throw const SessionExpiredException(); } return {'Authorization': 'Bearer $token'}; } Future> _get(String path) async { try { final res = await _client .get(Uri.parse('$baseUrl$path'), headers: await _authHeaders()) .timeout(const Duration(seconds: 30)); return _decode(res); } catch (e) { if (e is AnnotateApiException) rethrow; throw AnnotateApiException('网络请求失败: $e'); } } Future> _post(String path, String body) async { try { final res = await _client .post(Uri.parse('$baseUrl$path'), headers: { 'Content-Type': 'application/json', ...await _authHeaders(), }, body: body) .timeout(const Duration(seconds: 30)); return _decode(res); } catch (e) { if (e is AnnotateApiException) rethrow; throw AnnotateApiException('网络请求失败: $e'); } } Map _decode(http.Response res) { final Map json; try { json = jsonDecode(res.body) as Map; } catch (_) { throw AnnotateApiException('服务端响应异常 (${res.statusCode})'); } if (res.statusCode != 200 || json['code'] != 0) { final message = json['message'] as String? ?? '服务端错误 (${res.statusCode})'; if (json['code'] == 61) { throw const SessionExpiredException(); } throw AnnotateApiException(message); } return json['data'] as Map; } }