This commit is contained in:
2026-09-07 10:11:36 +08:00
parent 47a06af0d4
commit b8dd969cb1
33 changed files with 3247 additions and 173 deletions
@@ -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),
),
),
]),
),
],
],
),
),
);
}
}