1
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<AnnotateTaskInfo>, 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<String, dynamic>()))
|
||||
.toList();
|
||||
final stats = AnnotateStats.fromJson(
|
||||
((json['stats'] as Map?) ?? const {}).cast<String, dynamic>());
|
||||
return (list, stats);
|
||||
}
|
||||
|
||||
/// 领取一批标注图片
|
||||
Future<List<AnnotateClaimImage>> 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<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 提交单张标注(boxes 元素:{class,cx,cy,w,h,confidence},归一化坐标)
|
||||
Future<AnnotateSubmitResult> submit(
|
||||
int imageId, List<Map<String, dynamic>> boxes) async {
|
||||
final json = await _post(
|
||||
'/api/v1/annotate/submit', jsonEncode({'imageId': imageId, 'boxes': boxes}));
|
||||
return AnnotateSubmitResult.fromJson(json);
|
||||
}
|
||||
|
||||
/// 我的统计
|
||||
Future<AnnotateStats> me() async {
|
||||
final json = await _get('/api/v1/annotate/me');
|
||||
return AnnotateStats.fromJson(
|
||||
((json['stats'] as Map?) ?? const {}).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// 图片完整 URL(Image.network 用,需带 Bearer 头)
|
||||
Uri imageUrl(String relative) => Uri.parse('$baseUrl$relative');
|
||||
|
||||
/// 图片请求头(标注器 Image.network 加载用)
|
||||
Future<Map<String, String>> imageHeaders() => _authHeaders();
|
||||
|
||||
Future<Map<String, String>> _authHeaders() async {
|
||||
final token = await sessionStore.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
return {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _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<Map<String, dynamic>> _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<String, dynamic> _decode(http.Response res) {
|
||||
final Map<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} 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<String, dynamic>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user