Files
observer/flutter_app/lib/annotate/annotate_api.dart
T
2026-09-07 10:11:36 +08:00

261 lines
8.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>());
}
/// 图片完整 URLImage.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>;
}
}