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>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'annotate_api.dart';
|
||||
import 'annotator_screen.dart';
|
||||
|
||||
String _fmt(DateTime t) {
|
||||
String p(int n) => n.toString().padLeft(2, '0');
|
||||
return '${t.month}/${t.day} ${p(t.hour)}:${p(t.minute)}';
|
||||
}
|
||||
|
||||
/// 标注赚时长首页:我的统计(进度/今日已得/冻结态)+ 可领任务列表。
|
||||
/// 领取后进入标注器逐张画框提交,提交满 [AnnotateStats.rewardPerImages] 张自动到账时长。
|
||||
class AnnotateTasksScreen extends StatefulWidget {
|
||||
final AnnotateApi api;
|
||||
|
||||
const AnnotateTasksScreen({super.key, required this.api});
|
||||
|
||||
@override
|
||||
State<AnnotateTasksScreen> createState() => _AnnotateTasksScreenState();
|
||||
}
|
||||
|
||||
class _AnnotateTasksScreenState extends State<AnnotateTasksScreen> {
|
||||
late Future<(List<AnnotateTaskInfo>, AnnotateStats)> _future;
|
||||
bool _claiming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.api.tasks();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = widget.api.tasks());
|
||||
}
|
||||
|
||||
Future<void> _claim(AnnotateTaskInfo task) async {
|
||||
if (_claiming) return;
|
||||
setState(() => _claiming = true);
|
||||
try {
|
||||
final images = await widget.api.claim(task.id);
|
||||
if (!mounted) return;
|
||||
if (images.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('任务池暂时没有可领取的图片,稍后再来')));
|
||||
_reload();
|
||||
return;
|
||||
}
|
||||
final earned = await Navigator.of(context).push<bool>(MaterialPageRoute(
|
||||
builder: (_) => AnnotatorScreen(api: widget.api, task: task, images: images),
|
||||
));
|
||||
if (earned == true) _reload(); // 本批有提交:回列表刷新进度/奖励
|
||||
} on SessionExpiredException {
|
||||
if (mounted) _sessionExpired();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e'.replaceFirst('Exception: ', ''))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _claiming = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _sessionExpired() {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('登录已失效,请重新登录')));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('标注赚时长')),
|
||||
body: FutureBuilder<(List<AnnotateTaskInfo>, AnnotateStats)>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('加载失败:${snap.error}'.replaceFirst('Exception: ', '')),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: _reload, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final (tasks, stats) = snap.data!;
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _reload(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_StatsCard(stats: stats),
|
||||
const SizedBox(height: 16),
|
||||
Text('可领任务', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (tasks.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: Text('暂无可领任务,敬请期待')),
|
||||
)
|
||||
else
|
||||
...tasks.map((t) => Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
title: Text(t.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
'物种:${t.species.isEmpty ? t.datasetName : t.species} · 池余量 ${t.poolRemain} 张'),
|
||||
trailing: FilledButton(
|
||||
onPressed: _claiming || stats.frozen || t.poolRemain <= 0
|
||||
? null
|
||||
: () => _claim(t),
|
||||
child: const Text('领取'),
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsCard extends StatelessWidget {
|
||||
final AnnotateStats stats;
|
||||
|
||||
const _StatsCard({required this.stats});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.emoji_events,
|
||||
color: Colors.amber.shade700, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
Text('标 ${stats.rewardPerImages} 张得 ${stats.rewardMinutes} 分钟',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: stats.rewardPerImages <= 0
|
||||
? 0
|
||||
: (stats.progressDone % stats.rewardPerImages) /
|
||||
stats.rewardPerImages,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'本档进度 ${stats.progressDone}/${stats.rewardPerImages} · 今日已得 ${stats.todayEarnedMinutes}/${stats.dailyCapMinutes} 分钟 · 累计 ${stats.totalEarnedMinutes} 分钟',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
Text(
|
||||
'通过 ${stats.approved} / 拒绝 ${stats.rejected}(通过率 ${(stats.approveRatio * 100).toStringAsFixed(0)}%)',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
if (stats.frozen) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.block, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'标注质量未达标,资格冻结中(${_fmt(stats.frozenUntil!)} 解冻)',
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'annotate_api.dart';
|
||||
|
||||
/// 标注器:逐张画框提交。交互:空白处拖拽画框、点框选中(高亮)、
|
||||
/// 撤销/删除选中;每张提交进「待审核」,满 rewardPerImages 张自动到账时长。
|
||||
class AnnotatorScreen extends StatefulWidget {
|
||||
final AnnotateApi api;
|
||||
final AnnotateTaskInfo task;
|
||||
final List<AnnotateClaimImage> images;
|
||||
|
||||
const AnnotatorScreen({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.task,
|
||||
required this.images,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnnotatorScreen> createState() => _AnnotatorScreenState();
|
||||
}
|
||||
|
||||
/// 归一化框(cx,cy 中心 + w,h,0~1,同服务端 AdminLabelBox)
|
||||
class _NormBox {
|
||||
double cx;
|
||||
double cy;
|
||||
double w;
|
||||
double h;
|
||||
_NormBox(this.cx, this.cy, this.w, this.h);
|
||||
|
||||
bool hit(Offset p) =>
|
||||
p.dx >= cx - w / 2 && p.dx <= cx + w / 2 && p.dy >= cy - h / 2 && p.dy <= cy + h / 2;
|
||||
}
|
||||
|
||||
class _AnnotatorScreenState extends State<AnnotatorScreen> {
|
||||
int _index = 0;
|
||||
final List<_NormBox> _boxes = [];
|
||||
int? _selected;
|
||||
Offset? _dragStart; // 拖拽画框进行中(归一化坐标)
|
||||
Offset? _dragCur;
|
||||
bool _submitting = false;
|
||||
bool _earnedThisBatch = false;
|
||||
|
||||
AnnotateClaimImage get _current => widget.images[_index];
|
||||
|
||||
void _clamp(_NormBox b) {
|
||||
// 边界钳制 + 防越界(归一化 0~1,同服务端校验)
|
||||
b.cx = b.cx.clamp(0.0, 1.0);
|
||||
b.cy = b.cy.clamp(0.0, 1.0);
|
||||
b.w = b.w.clamp(0.001, 1.0);
|
||||
b.h = b.h.clamp(0.001, 1.0);
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails d) {
|
||||
_dragStart = d.localPosition;
|
||||
_dragCur = d.localPosition;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails d) {
|
||||
_dragCur = d.localPosition;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPanEnd(DragEndDetails d) {
|
||||
final s = _dragStart;
|
||||
final e = _dragCur;
|
||||
_dragStart = null;
|
||||
_dragCur = null;
|
||||
if (s == null || e == null) {
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
final dx = (e.dx - s.dx).abs();
|
||||
final dy = (e.dy - s.dy).abs();
|
||||
if (dx < 8 || dy < 8) {
|
||||
setState(() {}); // 过小视为误触
|
||||
return;
|
||||
}
|
||||
final box = _NormBox(
|
||||
(s.dx + e.dx) / 2,
|
||||
(s.dy + e.dy) / 2,
|
||||
dx,
|
||||
dy,
|
||||
);
|
||||
final size = _canvasSize;
|
||||
if (size == null || size.width <= 0 || size.height <= 0) {
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
box.cx /= size.width;
|
||||
box.cy /= size.height;
|
||||
box.w /= size.width;
|
||||
box.h /= size.height;
|
||||
_clamp(box);
|
||||
setState(() {
|
||||
_boxes.add(box);
|
||||
_selected = _boxes.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
Size? _canvasSize;
|
||||
|
||||
void _onTapUp(TapUpDetails d) {
|
||||
final p = d.localPosition;
|
||||
final size = _canvasSize;
|
||||
if (size == null) return;
|
||||
final n = Offset(p.dx / size.width, p.dy / size.height);
|
||||
int? hit;
|
||||
for (var i = _boxes.length - 1; i >= 0; i--) {
|
||||
if (_boxes[i].hit(n)) {
|
||||
hit = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setState(() => _selected = hit);
|
||||
}
|
||||
|
||||
void _deleteSelected() {
|
||||
if (_selected == null) return;
|
||||
setState(() {
|
||||
_boxes.removeAt(_selected!);
|
||||
_selected = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _undo() {
|
||||
if (_boxes.isEmpty) return;
|
||||
setState(() {
|
||||
_boxes.removeLast();
|
||||
_selected = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit({required bool noTarget}) async {
|
||||
if (_submitting) return;
|
||||
if (!noTarget && _boxes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请先画框,或使用「画面无目标」提交')));
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final payload = noTarget
|
||||
? <Map<String, dynamic>>[]
|
||||
: _boxes
|
||||
.map((b) => <String, dynamic>{
|
||||
'class': 0,
|
||||
'cx': b.cx,
|
||||
'cy': b.cy,
|
||||
'w': b.w,
|
||||
'h': b.h,
|
||||
'confidence': 1,
|
||||
})
|
||||
.toList();
|
||||
final res = await widget.api.submit(_current.imageId, payload);
|
||||
if (res.granted) _earnedThisBatch = true;
|
||||
if (!mounted) return;
|
||||
if (res.granted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
'恭喜!累计提交满档,${res.minutes} 分钟时长已到账(今日已得 ${res.todayEarnedMinutes} 分钟)'),
|
||||
backgroundColor: Colors.green,
|
||||
));
|
||||
}
|
||||
_next();
|
||||
} on SessionExpiredException {
|
||||
if (mounted) Navigator.of(context).pop(false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('$e'.replaceFirst('Exception: ', ''))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _next() {
|
||||
if (_index + 1 >= widget.images.length) {
|
||||
// 本批完成:回任务列表刷新进度
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(_earnedThisBatch ? '本批完成,奖励已到账' : '本批完成,继续加油!')));
|
||||
Navigator.of(context).pop(true);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_index++;
|
||||
_boxes.clear();
|
||||
_selected = null;
|
||||
_dragStart = null;
|
||||
_dragCur = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final img = _current;
|
||||
final aspect =
|
||||
img.width > 0 && img.height > 0 ? img.width / img.height : 4 / 3;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${widget.task.name}(${_index + 1}/${widget.images.length})'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: FutureBuilder<Map<String, String>>(
|
||||
future: widget.api.imageHeaders(),
|
||||
builder: (context, snap) {
|
||||
final headers = snap.data ?? const <String, String>{};
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final maxW = constraints.maxWidth;
|
||||
final maxH = constraints.maxHeight.isInfinite
|
||||
? MediaQuery.of(context).size.height * 0.55
|
||||
: constraints.maxHeight;
|
||||
var w = maxW;
|
||||
var h = w / aspect;
|
||||
if (h > maxH) {
|
||||
h = maxH;
|
||||
w = h * aspect;
|
||||
}
|
||||
_canvasSize = Size(w, h);
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
onPanEnd: _onPanEnd,
|
||||
onTapUp: _onTapUp,
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: w,
|
||||
height: h,
|
||||
child: Image.network(
|
||||
widget.api.imageUrl(img.url).toString(),
|
||||
headers: headers,
|
||||
fit: BoxFit.fill,
|
||||
loadingBuilder: (c, child, progress) =>
|
||||
progress == null
|
||||
? child
|
||||
: const Center(
|
||||
child: CircularProgressIndicator()),
|
||||
errorBuilder: (c, e, st) => const Center(
|
||||
child: Text('图片加载失败',
|
||||
style: TextStyle(color: Colors.red))),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _BoxesPainter(
|
||||
boxes: _boxes,
|
||||
selected: _selected,
|
||||
dragStart: _dragStart == null
|
||||
? null
|
||||
: Offset(_dragStart!.dx / w,
|
||||
_dragStart!.dy / h),
|
||||
dragCur: _dragCur == null
|
||||
? null
|
||||
: Offset(
|
||||
_dragCur!.dx / w, _dragCur!.dy / h),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _boxes.isEmpty ? null : _undo,
|
||||
icon: const Icon(Icons.undo),
|
||||
label: const Text('撤销'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _selected == null ? null : _deleteSelected,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('删除选中'),
|
||||
),
|
||||
Text(
|
||||
'${_boxes.length} 框',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _submitting ? null : () => _submit(noTarget: true),
|
||||
child: const Text('画面无目标'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _submitting ? null : () => _submit(noTarget: false),
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.check),
|
||||
label: Text(_submitting
|
||||
? '提交中…'
|
||||
: '提交${_boxes.isEmpty ? '' : '(${_boxes.length} 框)'}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BoxesPainter extends CustomPainter {
|
||||
final List<_NormBox> boxes;
|
||||
final int? selected;
|
||||
final Offset? dragStart; // 归一化
|
||||
final Offset? dragCur;
|
||||
|
||||
_BoxesPainter({
|
||||
required this.boxes,
|
||||
required this.selected,
|
||||
required this.dragStart,
|
||||
required this.dragCur,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final border = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
final b = boxes[i];
|
||||
border.color = i == selected ? Colors.orange : Colors.green;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH((b.cx - b.w / 2) * size.width, (b.cy - b.h / 2) * size.height,
|
||||
b.w * size.width, b.h * size.height),
|
||||
border,
|
||||
);
|
||||
}
|
||||
final s = dragStart;
|
||||
final e = dragCur;
|
||||
if (s != null && e != null) {
|
||||
border.color = Colors.blue;
|
||||
canvas.drawRect(
|
||||
Rect.fromPoints(Offset(s.dx * size.width, s.dy * size.height),
|
||||
Offset(e.dx * size.width, e.dy * size.height)),
|
||||
border,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _BoxesPainter old) =>
|
||||
old.boxes != boxes || old.selected != selected || old.dragCur != dragCur;
|
||||
}
|
||||
Reference in New Issue
Block a user