update:bug修复
This commit is contained in:
parent
9b914062d1
commit
a419e3da30
@ -19,20 +19,20 @@ abstract final class AppPages {
|
||||
page: LoginPage.new,
|
||||
binding: LoginBinding(),
|
||||
transition: Transition.rightToLeft,
|
||||
transitionDuration: Duration(milliseconds: 360),
|
||||
transitionDuration: Duration(milliseconds: 200),
|
||||
),
|
||||
GetPage(
|
||||
name: AppRoutes.apiSettings,
|
||||
page: ApiSettingsPage.new,
|
||||
transition: Transition.rightToLeft,
|
||||
transitionDuration: Duration(milliseconds: 360),
|
||||
transitionDuration: Duration(milliseconds: 200),
|
||||
),
|
||||
GetPage(
|
||||
name: AppRoutes.home,
|
||||
page: () =>
|
||||
const AnimatedAppBackground(gridOpacity: .11, child: HomePage()),
|
||||
transition: Transition.rightToLeft,
|
||||
transitionDuration: Duration(milliseconds: 360),
|
||||
transitionDuration: Duration(milliseconds: 200),
|
||||
),
|
||||
GetPage(
|
||||
name: AppRoutes.scoring,
|
||||
@ -40,14 +40,14 @@ abstract final class AppPages {
|
||||
const AnimatedAppBackground(gridOpacity: .11, child: ScoringPage()),
|
||||
binding: ScoringBinding(),
|
||||
transition: Transition.rightToLeft,
|
||||
transitionDuration: Duration(milliseconds: 360),
|
||||
transitionDuration: Duration(milliseconds: 200),
|
||||
),
|
||||
GetPage(
|
||||
name: AppRoutes.messages,
|
||||
page: () =>
|
||||
const AnimatedAppBackground(gridOpacity: .11, child: MessagesPage()),
|
||||
transition: Transition.rightToLeft,
|
||||
transitionDuration: Duration(milliseconds: 360),
|
||||
transitionDuration: Duration(milliseconds: 200),
|
||||
),
|
||||
GetPage(
|
||||
name: AppRoutes.corrections,
|
||||
@ -56,14 +56,14 @@ abstract final class AppPages {
|
||||
child: CorrectionsPage(),
|
||||
),
|
||||
transition: Transition.rightToLeft,
|
||||
transitionDuration: Duration(milliseconds: 360),
|
||||
transitionDuration: Duration(milliseconds: 200),
|
||||
),
|
||||
GetPage(
|
||||
name: AppRoutes.results,
|
||||
page: () =>
|
||||
const AnimatedAppBackground(gridOpacity: .11, child: ResultsPage()),
|
||||
transition: Transition.rightToLeft,
|
||||
transitionDuration: Duration(milliseconds: 360),
|
||||
transitionDuration: Duration(milliseconds: 200),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
84
lib/core/models/task_queue_policy.dart
Normal file
84
lib/core/models/task_queue_policy.dart
Normal file
@ -0,0 +1,84 @@
|
||||
/// A task decorated with the minimum scheduling data needed by the home queue.
|
||||
class TaskQueueCandidate<T> {
|
||||
const TaskQueueCandidate({
|
||||
required this.value,
|
||||
required this.eventId,
|
||||
required this.sessionOrder,
|
||||
required this.playOrder,
|
||||
required this.submitted,
|
||||
this.startTime,
|
||||
this.scheduleOrder = 0,
|
||||
this.attention = false,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final String eventId;
|
||||
final int sessionOrder;
|
||||
final int playOrder;
|
||||
final bool submitted;
|
||||
final DateTime? startTime;
|
||||
final int scheduleOrder;
|
||||
final bool attention;
|
||||
}
|
||||
|
||||
abstract final class TaskQueuePolicy {
|
||||
/// Keeps the nearest unfinished event and every non-blocking attention task.
|
||||
static List<T> nearestPendingEvent<T>(
|
||||
Iterable<TaskQueueCandidate<T>> candidates,
|
||||
) {
|
||||
final ordered = candidates.toList(growable: false)
|
||||
..sort(_compareCandidates);
|
||||
TaskQueueCandidate<T>? firstPending;
|
||||
for (final candidate in ordered) {
|
||||
if (!candidate.submitted && !candidate.attention) {
|
||||
firstPending = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (firstPending == null) {
|
||||
return [
|
||||
for (final candidate in ordered)
|
||||
if (candidate.attention) candidate.value,
|
||||
];
|
||||
}
|
||||
final eventId = firstPending.eventId;
|
||||
return [
|
||||
for (final candidate in ordered)
|
||||
if (candidate.attention || candidate.eventId == eventId)
|
||||
candidate.value,
|
||||
];
|
||||
}
|
||||
|
||||
/// Only the first unfinished item is scoreable.
|
||||
static int activeIndex(Iterable<bool> submitted) {
|
||||
var index = 0;
|
||||
for (final value in submitted) {
|
||||
if (!value) return index;
|
||||
index++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int _compareCandidates<T>(
|
||||
TaskQueueCandidate<T> a,
|
||||
TaskQueueCandidate<T> b,
|
||||
) {
|
||||
if (a.scheduleOrder > 0 && b.scheduleOrder > 0) {
|
||||
final schedule = a.scheduleOrder.compareTo(b.scheduleOrder);
|
||||
if (schedule != 0) return schedule;
|
||||
}
|
||||
final aTime = a.startTime;
|
||||
final bTime = b.startTime;
|
||||
if (aTime != null && bTime != null) {
|
||||
final time = aTime.compareTo(bTime);
|
||||
if (time != 0) return time;
|
||||
} else if (aTime != null) {
|
||||
return -1;
|
||||
} else if (bTime != null) {
|
||||
return 1;
|
||||
}
|
||||
final session = a.sessionOrder.compareTo(b.sessionOrder);
|
||||
if (session != 0) return session;
|
||||
return a.playOrder.compareTo(b.playOrder);
|
||||
}
|
||||
}
|
||||
@ -167,18 +167,6 @@ class ApiClient {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<String> applyCorrection({
|
||||
required String scoreSheetId,
|
||||
required String reason,
|
||||
}) async {
|
||||
final value = await _request(
|
||||
'POST',
|
||||
'$_judgeScoringPath/corrections/apply',
|
||||
data: {'scoreSheetId': scoreSheetId, 'applyReason': reason.trim()},
|
||||
);
|
||||
return '$value';
|
||||
}
|
||||
|
||||
Future<List<JudgeMessage>> getMessages({bool? onlyUnread}) async {
|
||||
final data = await _requestList(
|
||||
'GET',
|
||||
|
||||
@ -120,6 +120,7 @@ class JudgeSession {
|
||||
required this.judgeRoleName,
|
||||
required this.entryCount,
|
||||
required this.scoredCount,
|
||||
this.arrangementOrder = 0,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
@ -138,6 +139,7 @@ class JudgeSession {
|
||||
final String judgeRoleName;
|
||||
final int entryCount;
|
||||
final int scoredCount;
|
||||
final int arrangementOrder;
|
||||
|
||||
factory JudgeSession.fromJson(Map<String, dynamic> json) => JudgeSession(
|
||||
sessionId: _string(json['sessionId']),
|
||||
@ -156,6 +158,7 @@ class JudgeSession {
|
||||
judgeRoleName: _string(json['judgeRoleName']),
|
||||
entryCount: _int(json['entryCount']),
|
||||
scoredCount: _int(json['scoredCount']),
|
||||
arrangementOrder: _int(json['arrangementOrder']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@ -175,6 +178,7 @@ class JudgeSession {
|
||||
'judgeRoleName': judgeRoleName,
|
||||
'entryCount': entryCount,
|
||||
'scoredCount': scoredCount,
|
||||
'arrangementOrder': arrangementOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@ -192,6 +196,7 @@ class JudgeEntry {
|
||||
required this.isDisqualified,
|
||||
required this.scoreSheetId,
|
||||
this.myScore,
|
||||
this.scheduleOrder = 0,
|
||||
});
|
||||
|
||||
final String sessionEntryId;
|
||||
@ -206,6 +211,7 @@ class JudgeEntry {
|
||||
final String? myScore;
|
||||
final bool isDisqualified;
|
||||
final String scoreSheetId;
|
||||
final int scheduleOrder;
|
||||
|
||||
factory JudgeEntry.fromJson(Map<String, dynamic> json) => JudgeEntry(
|
||||
sessionEntryId: _string(json['sessionEntryId']),
|
||||
@ -220,6 +226,7 @@ class JudgeEntry {
|
||||
myScore: json['myScore']?.toString(),
|
||||
isDisqualified: _bool(json['isDisqualified']),
|
||||
scoreSheetId: _string(json['scoreSheetId']),
|
||||
scheduleOrder: _int(json['scheduleOrder']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@ -235,6 +242,7 @@ class JudgeEntry {
|
||||
'myScore': myScore,
|
||||
'isDisqualified': isDisqualified,
|
||||
'scoreSheetId': scoreSheetId,
|
||||
'scheduleOrder': scheduleOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@ -900,7 +908,9 @@ class JudgeCorrectionCandidate {
|
||||
final String? applyReason;
|
||||
final String? approveRemark;
|
||||
final bool canApply;
|
||||
bool get canResume => correctionStatus == 10 || correctionStatus == 30;
|
||||
|
||||
/// Only corrections dispatched or approved by the platform are actionable.
|
||||
bool get isAssignedTask => correctionStatus == 10 || correctionStatus == 30;
|
||||
factory JudgeCorrectionCandidate.fromJson(Map<String, dynamic> json) =>
|
||||
JudgeCorrectionCandidate(
|
||||
correctionId: json['correctionId']?.toString(),
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
import 'api_client.dart';
|
||||
|
||||
abstract final class CorrectionErrorPresenter {
|
||||
static String messageFor(Object error) {
|
||||
final message = error is ApiException ? error.message.trim() : '';
|
||||
if (_isCompetitionStateError(message)) {
|
||||
return '当前比赛状态暂不允许申请成绩修正,请在赛事管理员开放评分或修正后重试。';
|
||||
}
|
||||
if (message.isNotEmpty) return message;
|
||||
return '暂时无法申请成绩修正,请稍后重试或联系赛事管理员。';
|
||||
}
|
||||
|
||||
static bool _isCompetitionStateError(String message) {
|
||||
const keywords = [
|
||||
'暂不能评分',
|
||||
'不允许评分',
|
||||
'比赛尚未开始',
|
||||
'比赛已结束',
|
||||
'场次已结束',
|
||||
'比赛状态',
|
||||
'场次状态',
|
||||
'当前阶段',
|
||||
];
|
||||
return keywords.any(message.contains);
|
||||
}
|
||||
}
|
||||
@ -103,11 +103,6 @@ class JudgeApiRepository {
|
||||
String sessionId,
|
||||
) => _client.getCorrectionCandidates(sessionId);
|
||||
|
||||
Future<String> applyCorrection({
|
||||
required String scoreSheetId,
|
||||
required String reason,
|
||||
}) => _client.applyCorrection(scoreSheetId: scoreSheetId, reason: reason);
|
||||
|
||||
Future<ApiLoad<List<JudgeMessage>>> loadMessages({bool? onlyUnread}) async {
|
||||
final key = _key(onlyUnread == true ? 'messages-unread' : 'messages');
|
||||
try {
|
||||
|
||||
@ -138,9 +138,7 @@ abstract final class RemoteScoringAdapter {
|
||||
orderNo: entry.playOrder,
|
||||
heatId: session.sessionId,
|
||||
fieldId: session.venueName,
|
||||
session: sheet.header.sessionVenue.isEmpty
|
||||
? '${session.sessionName}-${session.venueName}'
|
||||
: sheet.header.sessionVenue,
|
||||
session: sessionVenueLabel(session, sheet.header),
|
||||
groupName: sheet.header.groupName.isEmpty
|
||||
? entry.groupName
|
||||
: sheet.header.groupName,
|
||||
@ -157,6 +155,41 @@ abstract final class RemoteScoringAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
static String sessionVenueLabel(
|
||||
JudgeSession session, [
|
||||
JudgeSheetHeader? header,
|
||||
]) {
|
||||
final venue = session.venueName.trim();
|
||||
var sessionName = session.sessionName.trim();
|
||||
for (final redundant in [session.projectName, session.groupName, venue]) {
|
||||
final value = redundant.trim();
|
||||
if (value.isNotEmpty) sessionName = sessionName.replaceAll(value, '');
|
||||
}
|
||||
sessionName = _cleanSessionPart(sessionName);
|
||||
|
||||
if (sessionName.isEmpty && header != null) {
|
||||
sessionName = header.sessionVenue.trim();
|
||||
for (final redundant in [header.projectName, header.groupName, venue]) {
|
||||
final value = redundant.trim();
|
||||
if (value.isNotEmpty) sessionName = sessionName.replaceAll(value, '');
|
||||
}
|
||||
sessionName = _cleanSessionPart(sessionName);
|
||||
}
|
||||
if (sessionName.isEmpty &&
|
||||
session.arrangementOrder > 0 &&
|
||||
session.arrangementOrder < 0x7fffffff) {
|
||||
sessionName = '第${session.arrangementOrder}场';
|
||||
}
|
||||
if (sessionName.isEmpty) return venue;
|
||||
if (venue.isEmpty || sessionName.contains(venue)) return sessionName;
|
||||
return '$sessionName-$venue';
|
||||
}
|
||||
|
||||
static String _cleanSessionPart(String value) => value
|
||||
.replaceAll(RegExp(r'^[\s·|/_—–-]+|[\s·|/_—–-]+$'), '')
|
||||
.replaceAll(RegExp(r'([·|/_—–-])\1+'), r'$1')
|
||||
.trim();
|
||||
|
||||
static CompetitionKind competitionFor(JudgeSheetHeader header) {
|
||||
final ruleType = header.ruleType.toUpperCase();
|
||||
if (ruleType == 'PRESCRIBED') {
|
||||
|
||||
@ -6,7 +6,6 @@ import '../../../common/widgets/app_gradient_button.dart';
|
||||
import '../../../common/widgets/page_loading_view.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_models.dart';
|
||||
import '../../../core/network/correction_error_presenter.dart';
|
||||
import '../../../core/network/judge_api_repository.dart';
|
||||
import '../../../core/network/remote_scoring_adapter.dart';
|
||||
|
||||
@ -49,7 +48,9 @@ class _CorrectionsPageState extends State<CorrectionsPage> {
|
||||
final data = await _api.loadCorrectionCandidates(session.sessionId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_items = data;
|
||||
_items = data
|
||||
.where((item) => item.isAssignedTask)
|
||||
.toList(growable: false);
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
@ -63,40 +64,6 @@ class _CorrectionsPageState extends State<CorrectionsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _apply(JudgeCorrectionCandidate candidate) async {
|
||||
final reason = await Get.dialog<String>(
|
||||
_CorrectionApplyDialog(candidate: candidate),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
if (reason == null || !mounted) return;
|
||||
try {
|
||||
await _api.applyCorrection(
|
||||
scoreSheetId: candidate.scoreSheetId,
|
||||
reason: reason,
|
||||
);
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
JudgeCorrectionCandidate? updated;
|
||||
for (final item in _items) {
|
||||
if (item.scoreSheetId == candidate.scoreSheetId) updated = item;
|
||||
}
|
||||
if (updated?.canResume == true) {
|
||||
Get.back(result: candidate.sessionEntryId);
|
||||
} else {
|
||||
Get.back();
|
||||
Get.snackbar('申请已提交', '请等待赛事管理员审批,审批结果会通过消息中心通知');
|
||||
}
|
||||
} on Object catch (error) {
|
||||
if (!mounted) return;
|
||||
Get.snackbar(
|
||||
'暂时无法申请修正',
|
||||
CorrectionErrorPresenter.messageFor(error),
|
||||
duration: const Duration(seconds: 5),
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading && _items.isEmpty) {
|
||||
@ -130,7 +97,7 @@ class _CorrectionsPageState extends State<CorrectionsPage> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'申请、审批状态与修正记录',
|
||||
'后台下发给当前裁判的待处理任务',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
@ -178,7 +145,7 @@ class _CorrectionsPageState extends State<CorrectionsPage> {
|
||||
: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _items.isEmpty
|
||||
? const Center(child: Text('当前场次暂无可修正的已提交评分'))
|
||||
? const Center(child: Text('当前场次暂无后台下发的成绩修正任务'))
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(32),
|
||||
itemCount: _items.length,
|
||||
@ -230,10 +197,9 @@ class _CorrectionsPageState extends State<CorrectionsPage> {
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (item.approveRemark?.isNotEmpty ==
|
||||
true)
|
||||
if (item.applyReason?.isNotEmpty == true)
|
||||
Text(
|
||||
'审批意见:${item.approveRemark}',
|
||||
'修正原因:${item.applyReason}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFD97706),
|
||||
),
|
||||
@ -241,21 +207,16 @@ class _CorrectionsPageState extends State<CorrectionsPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.canResume)
|
||||
AppGradientButton(
|
||||
onPressed: () =>
|
||||
Get.back(result: item.sessionEntryId),
|
||||
minimumSize: const Size(130, 44),
|
||||
child: const Text('继续修正'),
|
||||
)
|
||||
else if (item.canApply)
|
||||
AppGradientButton(
|
||||
onPressed: () => _apply(item),
|
||||
minimumSize: const Size(130, 44),
|
||||
child: const Text('申请修正'),
|
||||
)
|
||||
else
|
||||
Chip(label: Text(item.correctionStatusName)),
|
||||
AppGradientButton(
|
||||
onPressed: () =>
|
||||
Get.back(result: item.sessionEntryId),
|
||||
minimumSize: const Size(130, 44),
|
||||
child: Text(
|
||||
item.correctionStatus == 30
|
||||
? '继续修改'
|
||||
: '修改成绩',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -269,66 +230,3 @@ class _CorrectionsPageState extends State<CorrectionsPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CorrectionApplyDialog extends StatefulWidget {
|
||||
const _CorrectionApplyDialog({required this.candidate});
|
||||
|
||||
final JudgeCorrectionCandidate candidate;
|
||||
|
||||
@override
|
||||
State<_CorrectionApplyDialog> createState() => _CorrectionApplyDialogState();
|
||||
}
|
||||
|
||||
class _CorrectionApplyDialogState extends State<_CorrectionApplyDialog> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: const Text('申请成绩修正'),
|
||||
content: SizedBox(
|
||||
width: 620,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${widget.candidate.entryNo} · ${widget.candidate.participantName}',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
maxLength: 512,
|
||||
maxLines: 4,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '修正原因',
|
||||
hintText: '请说明需要修正的内容和原因',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: Get.back, child: const Text('取消')),
|
||||
AppGradientButton(
|
||||
minimumSize: const Size(120, 44),
|
||||
onPressed: () {
|
||||
final value = _controller.text.trim();
|
||||
if (value.isEmpty) {
|
||||
Get.snackbar('请填写原因', '修正原因不能为空');
|
||||
return;
|
||||
}
|
||||
Get.back(result: value);
|
||||
},
|
||||
child: const Text('提交申请'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import '../../../common/widgets/app_gradient_button.dart';
|
||||
import '../../../common/widgets/page_loading_view.dart';
|
||||
import '../../../core/models/prescribed_execution_rule.dart';
|
||||
import '../../../core/models/scoring_task.dart';
|
||||
import '../../../core/models/task_queue_policy.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/background_sync_service.dart';
|
||||
import '../../../core/network/api_models.dart';
|
||||
@ -35,6 +36,7 @@ class _HomePageState extends State<HomePage> {
|
||||
AuthSessionStore get _auth => Get.find<AuthSessionStore>();
|
||||
|
||||
final List<_RemoteTask> _remoteTasks = [];
|
||||
final List<JudgeSession> _allSessions = [];
|
||||
bool _loading = true;
|
||||
bool _fromCache = false;
|
||||
String? _error;
|
||||
@ -42,6 +44,7 @@ class _HomePageState extends State<HomePage> {
|
||||
late final Worker _syncStatusWorker;
|
||||
Timer? _messageTimer;
|
||||
int _unreadMessages = 0;
|
||||
int _correctionTaskCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -51,10 +54,12 @@ class _HomePageState extends State<HomePage> {
|
||||
});
|
||||
_loadTasks();
|
||||
_loadUnreadMessages();
|
||||
_messageTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _loadUnreadMessages(),
|
||||
);
|
||||
_messageTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
_loadUnreadMessages();
|
||||
if (_allSessions.isNotEmpty) {
|
||||
_loadCorrectionTaskCount(_allSessions);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@ -81,25 +86,35 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
}),
|
||||
);
|
||||
final tasks =
|
||||
<_RemoteTask>[
|
||||
for (final batch in batches)
|
||||
for (final entry in batch.entries)
|
||||
_RemoteTask(
|
||||
session: batch.session,
|
||||
entry: entry,
|
||||
task: _taskFor(batch.session, entry),
|
||||
),
|
||||
]..sort((a, b) {
|
||||
final time = (a.session.startTime ?? DateTime(2100)).compareTo(
|
||||
b.session.startTime ?? DateTime(2100),
|
||||
);
|
||||
return time != 0
|
||||
? time
|
||||
: a.entry.playOrder.compareTo(b.entry.playOrder);
|
||||
});
|
||||
final allTasks = <_RemoteTask>[
|
||||
for (final indexedBatch in batches.indexed)
|
||||
for (final entry in indexedBatch.$2.entries)
|
||||
_RemoteTask(
|
||||
session: indexedBatch.$2.session,
|
||||
entry: entry,
|
||||
task: _taskFor(indexedBatch.$2.session, entry),
|
||||
sessionOrder: indexedBatch.$1,
|
||||
),
|
||||
];
|
||||
final tasks = TaskQueuePolicy.nearestPendingEvent(
|
||||
allTasks.map(
|
||||
(task) => TaskQueueCandidate(
|
||||
value: task,
|
||||
eventId: task.session.eventId,
|
||||
sessionOrder: task.sessionOrder,
|
||||
playOrder: task.entry.playOrder,
|
||||
scheduleOrder: task.entry.scheduleOrder,
|
||||
startTime: task.session.startTime,
|
||||
submitted: _isSubmittedStatus(_statusFor(task)),
|
||||
attention: task.entry.scoreStatus == 3,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_allSessions
|
||||
..clear()
|
||||
..addAll(sessionsResult.data);
|
||||
_remoteTasks
|
||||
..clear()
|
||||
..addAll(tasks);
|
||||
@ -107,6 +122,7 @@ class _HomePageState extends State<HomePage> {
|
||||
sessionsResult.fromCache || batches.any((item) => item.fromCache);
|
||||
_loading = false;
|
||||
});
|
||||
unawaited(_loadCorrectionTaskCount(sessionsResult.data));
|
||||
} on ApiException catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@ -143,15 +159,9 @@ class _HomePageState extends State<HomePage> {
|
||||
status == ScoringTaskStatus.waitingUpload.label ||
|
||||
status == ScoringTaskStatus.syncing.label,
|
||||
);
|
||||
final lastEntryId = _storage.settings.get(
|
||||
'judge-api-last-entry-${session?.user.id ?? ''}',
|
||||
final activeIndex = TaskQueuePolicy.activeIndex(
|
||||
statuses.map(_isSubmittedStatus),
|
||||
);
|
||||
final restoredIndex = _remoteTasks.indexWhere(
|
||||
(item) => item.entry.sessionEntryId == lastEntryId,
|
||||
);
|
||||
final activeIndex = restoredIndex >= 0
|
||||
? restoredIndex
|
||||
: statuses.indexWhere((status) => !_isSubmittedStatus(status));
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
@ -162,6 +172,7 @@ class _HomePageState extends State<HomePage> {
|
||||
userName: userName,
|
||||
roleSummary: roleSummary,
|
||||
unreadMessages: _unreadMessages,
|
||||
correctionTaskCount: _correctionTaskCount,
|
||||
onMessages: _openMessages,
|
||||
onCorrections: _openCorrections,
|
||||
onResults: chiefSessions.isNotEmpty ? _openResults : null,
|
||||
@ -204,13 +215,20 @@ class _HomePageState extends State<HomePage> {
|
||||
),
|
||||
status: statuses[entry.$1],
|
||||
isCurrent: entry.$1 == activeIndex,
|
||||
isLocked:
|
||||
!_isSubmittedStatus(statuses[entry.$1]) &&
|
||||
entry.$1 != activeIndex,
|
||||
onTap:
|
||||
_openingEntryId ==
|
||||
_remoteTasks[entry.$1]
|
||||
.entry
|
||||
.sessionEntryId ||
|
||||
(blockingIndex >= 0 &&
|
||||
entry.$1 != blockingIndex)
|
||||
entry.$1 != blockingIndex) ||
|
||||
(!_isSubmittedStatus(
|
||||
statuses[entry.$1],
|
||||
) &&
|
||||
entry.$1 != activeIndex)
|
||||
? null
|
||||
: statuses[entry.$1] ==
|
||||
ScoringTaskStatus
|
||||
@ -267,15 +285,10 @@ class _HomePageState extends State<HomePage> {
|
||||
bool _isSubmittedStatus(String status) =>
|
||||
status == ScoringTaskStatus.submitted.label ||
|
||||
status == ScoringTaskStatus.submittedAnnotationPending.label ||
|
||||
status == ScoringTaskStatus.submittedLocal.label;
|
||||
status == ScoringTaskStatus.submittedLocal.label ||
|
||||
status == ScoringTaskStatus.correction.label;
|
||||
|
||||
List<JudgeSession> get _sessions {
|
||||
final seen = <String>{};
|
||||
return [
|
||||
for (final task in _remoteTasks)
|
||||
if (seen.add(task.session.sessionId)) task.session,
|
||||
];
|
||||
}
|
||||
List<JudgeSession> get _sessions => _allSessions;
|
||||
|
||||
List<JudgeSession> get _chiefSessions => _sessions
|
||||
.where(RemoteScoringAdapter.isChiefSession)
|
||||
@ -298,7 +311,11 @@ class _HomePageState extends State<HomePage> {
|
||||
final index = _remoteTasks.indexWhere(
|
||||
(item) => item.entry.sessionEntryId == entryId,
|
||||
);
|
||||
if (index >= 0) await _openTask(index, _remoteTasks[index]);
|
||||
if (index >= 0) {
|
||||
await _openTask(index, _remoteTasks[index]);
|
||||
} else {
|
||||
await _openCorrectionTask(entryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -318,7 +335,28 @@ class _HomePageState extends State<HomePage> {
|
||||
final index = _remoteTasks.indexWhere(
|
||||
(item) => item.entry.sessionEntryId == entryId,
|
||||
);
|
||||
if (index >= 0) await _openTask(index, _remoteTasks[index]);
|
||||
if (index >= 0) {
|
||||
await _openTask(index, _remoteTasks[index]);
|
||||
} else {
|
||||
await _openCorrectionTask(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCorrectionTaskCount(List<JudgeSession> sessions) async {
|
||||
try {
|
||||
final batches = await Future.wait(
|
||||
sessions.map(
|
||||
(session) => _api.loadCorrectionCandidates(session.sessionId),
|
||||
),
|
||||
);
|
||||
final count = batches
|
||||
.expand((items) => items)
|
||||
.where((item) => item.isAssignedTask)
|
||||
.length;
|
||||
if (mounted) setState(() => _correctionTaskCount = count);
|
||||
} on ApiException {
|
||||
// Badge refresh failure must not block the scoring task list.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openResults() async {
|
||||
@ -326,6 +364,34 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
final confirmed = await Get.dialog<bool>(
|
||||
AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.logout_rounded, color: Color(0xFFE04F5F)),
|
||||
SizedBox(width: 10),
|
||||
Text('确认退出登录?'),
|
||||
],
|
||||
),
|
||||
content: const SizedBox(
|
||||
width: 420,
|
||||
child: Text('退出后将返回登录页面,本机保存的其他裁判数据不会显示在当前账号中。'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Get.back(result: false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
AppGradientButton(
|
||||
onPressed: () => Get.back(result: true),
|
||||
minimumSize: const Size(116, 44),
|
||||
child: const Text('确认退出'),
|
||||
),
|
||||
],
|
||||
),
|
||||
barrierDismissible: true,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await _auth.clear();
|
||||
await _storage.settings.deleteAll([
|
||||
'judgeId',
|
||||
@ -408,7 +474,9 @@ class _HomePageState extends State<HomePage> {
|
||||
'已按最新评分表切换为${RemoteScoringAdapter.roleNameForType(sheet.header.judgeRoleType)} · ${sheet.header.judgeRole}',
|
||||
);
|
||||
}
|
||||
final continueNext = await Get.toNamed(AppRoutes.scoring) == true;
|
||||
final routeResult = await Get.toNamed(AppRoutes.scoring);
|
||||
final continueNext = routeResult == true;
|
||||
final correctionEntryId = routeResult is String ? routeResult : null;
|
||||
await _storage.settings.put(
|
||||
ScoringStorageKeys.apiResumeScoringSetting,
|
||||
false,
|
||||
@ -418,8 +486,10 @@ class _HomePageState extends State<HomePage> {
|
||||
await Get.delete<ScoringController>(force: true);
|
||||
}
|
||||
await _loadTasks();
|
||||
if (continueNext && mounted) {
|
||||
await _openNextTaskAfter(remoteTask);
|
||||
if (correctionEntryId != null && mounted) {
|
||||
await _openCorrectionTask(correctionEntryId);
|
||||
} else if (continueNext && mounted) {
|
||||
await _openNextTask();
|
||||
}
|
||||
} on ApiException catch (error) {
|
||||
Get.snackbar('无法打开评分表', error.message);
|
||||
@ -428,20 +498,38 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openNextTaskAfter(_RemoteTask completedTask) async {
|
||||
final sameSession = _remoteTasks.indexWhere(
|
||||
(candidate) =>
|
||||
candidate.session.sessionId == completedTask.session.sessionId &&
|
||||
candidate.entry.playOrder > completedTask.entry.playOrder &&
|
||||
!_isSubmittedStatus(_statusFor(candidate)),
|
||||
Future<void> _openCorrectionTask(String sessionEntryId) async {
|
||||
for (final indexedSession in _allSessions.indexed) {
|
||||
try {
|
||||
final entries = (await _api.loadEntries(
|
||||
indexedSession.$2.sessionId,
|
||||
)).data;
|
||||
for (final entry in entries) {
|
||||
if (entry.sessionEntryId != sessionEntryId) continue;
|
||||
await _openTask(
|
||||
0,
|
||||
_RemoteTask(
|
||||
session: indexedSession.$2,
|
||||
entry: entry,
|
||||
task: _taskFor(indexedSession.$2, entry),
|
||||
sessionOrder: indexedSession.$1,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} on ApiException {
|
||||
// Continue searching other assigned sessions.
|
||||
}
|
||||
}
|
||||
Get.snackbar('无法打开修正任务', '该任务已失效或后台已撤回,请刷新后重试');
|
||||
}
|
||||
|
||||
Future<void> _openNextTask() async {
|
||||
final nextIndex = TaskQueuePolicy.activeIndex(
|
||||
_remoteTasks.map(
|
||||
(candidate) => _isSubmittedStatus(_statusFor(candidate)),
|
||||
),
|
||||
);
|
||||
final anyNext = _remoteTasks.indexWhere(
|
||||
(candidate) =>
|
||||
candidate.entry.sessionEntryId !=
|
||||
completedTask.entry.sessionEntryId &&
|
||||
!_isSubmittedStatus(_statusFor(candidate)),
|
||||
);
|
||||
final nextIndex = sameSession >= 0 ? sameSession : anyNext;
|
||||
if (nextIndex < 0) {
|
||||
Get.snackbar('本轮任务已完成', '当前裁判席位没有更多待评分场次');
|
||||
return;
|
||||
@ -458,7 +546,7 @@ class _HomePageState extends State<HomePage> {
|
||||
orderNo: entry.playOrder,
|
||||
heatId: session.sessionId,
|
||||
fieldId: session.venueName,
|
||||
session: '${session.sessionName}-${session.venueName}',
|
||||
session: RemoteScoringAdapter.sessionVenueLabel(session),
|
||||
groupName: entry.groupName,
|
||||
teamId: entry.sessionEntryId,
|
||||
teamName: entry.participantName,
|
||||
@ -474,11 +562,13 @@ class _RemoteTask {
|
||||
required this.session,
|
||||
required this.entry,
|
||||
required this.task,
|
||||
required this.sessionOrder,
|
||||
});
|
||||
|
||||
final JudgeSession session;
|
||||
final JudgeEntry entry;
|
||||
final ScoringTask task;
|
||||
final int sessionOrder;
|
||||
}
|
||||
|
||||
class _LoadError extends StatelessWidget {
|
||||
@ -535,11 +625,11 @@ class _EmptyTasks extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'当前没有已分配的执裁场次',
|
||||
'当前没有待评分赛事任务',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('请确认赛事编排已完成,且裁判分配状态为有效。'),
|
||||
const Text('请确认赛事编排及裁判分配已完成,或等待下一场赛事开始。'),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRefresh,
|
||||
@ -586,6 +676,7 @@ class _TopBar extends StatelessWidget {
|
||||
required this.userName,
|
||||
required this.roleSummary,
|
||||
required this.unreadMessages,
|
||||
required this.correctionTaskCount,
|
||||
required this.onMessages,
|
||||
required this.onCorrections,
|
||||
required this.onResults,
|
||||
@ -596,6 +687,7 @@ class _TopBar extends StatelessWidget {
|
||||
final String userName;
|
||||
final String roleSummary;
|
||||
final int unreadMessages;
|
||||
final int correctionTaskCount;
|
||||
final VoidCallback onMessages;
|
||||
final VoidCallback onCorrections;
|
||||
final VoidCallback? onResults;
|
||||
@ -650,10 +742,16 @@ class _TopBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
IconButton.outlined(
|
||||
onPressed: onCorrections,
|
||||
tooltip: '成绩修正',
|
||||
icon: const Icon(Icons.edit_note_rounded, size: 21),
|
||||
Badge(
|
||||
isLabelVisible: correctionTaskCount > 0,
|
||||
label: Text(
|
||||
correctionTaskCount > 99 ? '99+' : '$correctionTaskCount',
|
||||
),
|
||||
child: IconButton.outlined(
|
||||
onPressed: onCorrections,
|
||||
tooltip: '成绩修正任务',
|
||||
icon: const Icon(Icons.edit_note_rounded, size: 21),
|
||||
),
|
||||
),
|
||||
if (onResults != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
@ -776,7 +874,7 @@ class _PageHeading extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
const Text(
|
||||
'今日打分任务',
|
||||
'当前赛事任务',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryDark,
|
||||
fontSize: 28,
|
||||
@ -787,7 +885,7 @@ class _PageHeading extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'任务已按比赛顺序排列,完成当前场次后可连续进入下一场。',
|
||||
'仅展示最近的待执裁赛事,请按赛事编排顺序依次完成。',
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 15),
|
||||
),
|
||||
],
|
||||
@ -829,12 +927,14 @@ class _TaskCard extends StatelessWidget {
|
||||
required this.roleLabel,
|
||||
required this.status,
|
||||
required this.isCurrent,
|
||||
required this.isLocked,
|
||||
required this.onTap,
|
||||
});
|
||||
final ScoringTask task;
|
||||
final String roleLabel;
|
||||
final String status;
|
||||
final bool isCurrent;
|
||||
final bool isLocked;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
@ -846,20 +946,27 @@ class _TaskCard extends StatelessWidget {
|
||||
taskStatus == ScoringTaskStatus.submittedLocal;
|
||||
final annotationPending =
|
||||
taskStatus == ScoringTaskStatus.submittedAnnotationPending;
|
||||
final correction = taskStatus == ScoringTaskStatus.correction;
|
||||
final saved =
|
||||
taskStatus == ScoringTaskStatus.saved ||
|
||||
taskStatus == ScoringTaskStatus.localDraft;
|
||||
final waiting =
|
||||
taskStatus == ScoringTaskStatus.waitingUpload ||
|
||||
taskStatus == ScoringTaskStatus.syncing;
|
||||
final color = annotationPending
|
||||
final color = correction
|
||||
? const Color(0xFFD58A00)
|
||||
: isLocked
|
||||
? const Color(0xFF94A3B8)
|
||||
: annotationPending
|
||||
? const Color(0xFFF59E0B)
|
||||
: submitted
|
||||
? AppColors.success
|
||||
: saved || waiting
|
||||
? const Color(0xFFF59E0B)
|
||||
: AppColors.primary;
|
||||
final statusIcon = annotationPending
|
||||
final statusIcon = correction
|
||||
? Icons.edit_note_rounded
|
||||
: annotationPending
|
||||
? Icons.cloud_upload_outlined
|
||||
: submitted
|
||||
? Icons.check_circle_outline
|
||||
@ -870,7 +977,11 @@ class _TaskCard extends StatelessWidget {
|
||||
: Icons.schedule_outlined;
|
||||
|
||||
return Material(
|
||||
color: Colors.white.withValues(alpha: .96),
|
||||
color: correction
|
||||
? const Color(0xFFFFFAE9).withValues(alpha: .98)
|
||||
: isLocked
|
||||
? const Color(0xFFF3F6FA).withValues(alpha: .96)
|
||||
: Colors.white.withValues(alpha: .96),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@ -879,10 +990,12 @@ class _TaskCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isCurrent
|
||||
color: correction
|
||||
? const Color(0xFFF2C45A)
|
||||
: isCurrent
|
||||
? AppColors.primary.withValues(alpha: .48)
|
||||
: const Color(0xFFD9E6F6),
|
||||
width: isCurrent ? 1.4 : 1,
|
||||
width: correction || isCurrent ? 1.4 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
@ -907,7 +1020,9 @@ class _TaskCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
submitted
|
||||
correction
|
||||
? Icons.edit_note_rounded
|
||||
: submitted
|
||||
? Icons.check_circle_outline
|
||||
: Icons.flag_outlined,
|
||||
color: color,
|
||||
@ -1003,21 +1118,13 @@ class _TaskCard extends StatelessWidget {
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Meta(
|
||||
icon: Icons.schedule_outlined,
|
||||
text: _formatTime(task.scheduledAt),
|
||||
),
|
||||
_Meta(
|
||||
icon: Icons.person_outline,
|
||||
text: task.athleteLabel,
|
||||
icon: Icons.groups_outlined,
|
||||
text: '组别:${task.groupName}',
|
||||
),
|
||||
_Meta(
|
||||
icon: Icons.location_on_outlined,
|
||||
text: '场次-场地:${task.session}',
|
||||
),
|
||||
_Meta(
|
||||
icon: Icons.groups_outlined,
|
||||
text: task.groupName,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -1035,10 +1142,20 @@ class _TaskCard extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(statusIcon, color: color, size: 16),
|
||||
Icon(
|
||||
isLocked && !correction
|
||||
? Icons.lock_clock_outlined
|
||||
: statusIcon,
|
||||
color: color,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
status,
|
||||
correction
|
||||
? '待修正'
|
||||
: isLocked
|
||||
? '等待前序完成'
|
||||
: status,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
@ -1051,7 +1168,11 @@ class _TaskCard extends StatelessWidget {
|
||||
const SizedBox(width: 14),
|
||||
AppGradientButton(
|
||||
onPressed: onTap,
|
||||
icon: annotationPending
|
||||
icon: correction
|
||||
? Icons.edit_rounded
|
||||
: isLocked
|
||||
? Icons.lock_outline_rounded
|
||||
: annotationPending
|
||||
? Icons.cloud_upload_outlined
|
||||
: submitted
|
||||
? Icons.history_outlined
|
||||
@ -1063,7 +1184,11 @@ class _TaskCard extends StatelessWidget {
|
||||
minimumSize: const Size(124, 44),
|
||||
borderRadius: 11,
|
||||
child: Text(
|
||||
annotationPending
|
||||
correction
|
||||
? '修改成绩'
|
||||
: isLocked
|
||||
? '等待前序'
|
||||
: annotationPending
|
||||
? '重试画板'
|
||||
: submitted
|
||||
? '查看记录'
|
||||
@ -1263,7 +1388,3 @@ class _Meta extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime value) =>
|
||||
'${value.hour.toString().padLeft(2, '0')}:'
|
||||
'${value.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
@ -1521,16 +1521,6 @@ class ScoringController extends GetxController {
|
||||
return item.currentValue ?? item.defaultValue;
|
||||
}
|
||||
|
||||
void beginCorrection() {
|
||||
isCorrectionMode.value = true;
|
||||
isSubmitted.value = false;
|
||||
operationMessages.insert(0, '进入成绩修正模式');
|
||||
_storage.settings.put(
|
||||
ScoringStorageKeys.taskStatus(profile, taskId: activeTask.taskId),
|
||||
ScoringTaskStatus.correction.label,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> enterRemoteCorrection(JudgeScoreSheet sheet) async {
|
||||
apiScoreSheet = sheet;
|
||||
scoreId = null;
|
||||
|
||||
@ -8,9 +8,10 @@ import '../../../app/routes/app_routes.dart';
|
||||
import '../../../common/widgets/app_gradient_button.dart';
|
||||
import '../../../core/models/elite_scoring_rule.dart';
|
||||
import '../../../core/models/judge_account.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_models.dart';
|
||||
import '../../../core/network/correction_error_presenter.dart';
|
||||
import '../../../core/network/judge_api_repository.dart';
|
||||
import '../../../core/network/remote_scoring_adapter.dart';
|
||||
import '../../../core/storage/local_storage_service.dart';
|
||||
import '../../../core/storage/scoring_storage_keys.dart';
|
||||
import '../controllers/scoring_controller.dart';
|
||||
@ -19,9 +20,44 @@ import '../widgets/drawing_board_dialog.dart';
|
||||
import '../widgets/record_button.dart';
|
||||
import '../widgets/scoring_dialogs.dart';
|
||||
|
||||
class ScoringPage extends GetView<ScoringController> {
|
||||
class ScoringPage extends StatefulWidget {
|
||||
const ScoringPage({super.key});
|
||||
|
||||
@override
|
||||
State<ScoringPage> createState() => _ScoringPageState();
|
||||
}
|
||||
|
||||
class _ScoringPageState extends State<ScoringPage> with WidgetsBindingObserver {
|
||||
ScoringController get controller => Get.find<ScoringController>();
|
||||
|
||||
List<_CorrectionTask> _correctionTasks = const [];
|
||||
Future<void>? _correctionLoadFuture;
|
||||
Timer? _correctionTimer;
|
||||
final Set<String> _announcedCorrectionIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadCorrections());
|
||||
_correctionTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _loadCorrections(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_correctionTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) _loadCorrections();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
@ -44,7 +80,8 @@ class ScoringPage extends GetView<ScoringController> {
|
||||
return _ReadyScoringPage(
|
||||
controller: controller,
|
||||
onBack: _backToTasks,
|
||||
onCorrection: _correct,
|
||||
onCorrection: _openCorrectionTasks,
|
||||
correctionTaskCount: _correctionTasks.length,
|
||||
onSubmit: _submit,
|
||||
onServerSubmissionConfirmed: () =>
|
||||
_afterSuccessfulSubmission(false),
|
||||
@ -121,73 +158,117 @@ class ScoringPage extends GetView<ScoringController> {
|
||||
await _returnToTasks();
|
||||
}
|
||||
|
||||
Future<void> _returnToTasks({bool continueNext = false}) async {
|
||||
Future<void> _returnToTasks({
|
||||
bool continueNext = false,
|
||||
String? correctionEntryId,
|
||||
}) async {
|
||||
final settings = LocalStorageService.instance.settings;
|
||||
await settings.put(ScoringStorageKeys.apiResumeScoringSetting, false);
|
||||
await settings.flush();
|
||||
final navigator = Get.key.currentState;
|
||||
if (navigator?.canPop() ?? false) {
|
||||
Get.back(result: continueNext);
|
||||
Get.back(result: correctionEntryId ?? continueNext);
|
||||
} else {
|
||||
Get.offAllNamed(AppRoutes.home);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _correct() async {
|
||||
final sheet = controller.apiScoreSheet;
|
||||
if (sheet != null && !sheet.isCorrection) {
|
||||
try {
|
||||
final candidates = await Get.find<JudgeApiRepository>()
|
||||
.loadCorrectionCandidates(sheet.sessionId);
|
||||
JudgeCorrectionCandidate? candidate;
|
||||
for (final item in candidates) {
|
||||
if (item.scoreSheetId == sheet.scoreSheetId) candidate = item;
|
||||
}
|
||||
if (candidate == null) {
|
||||
Get.snackbar('暂不可修正', '未找到当前已提交评分单');
|
||||
return;
|
||||
}
|
||||
if (!candidate.canApply) {
|
||||
Get.snackbar(
|
||||
'修正状态:${candidate.correctionStatusName}',
|
||||
candidate.approveRemark ?? '当前修正流程尚未结束',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final reason = await showCorrectionReasonDialog(
|
||||
profile: controller.profile,
|
||||
task: controller.activeTask,
|
||||
);
|
||||
if (reason == null) return;
|
||||
await Get.find<JudgeApiRepository>().applyCorrection(
|
||||
scoreSheetId: sheet.scoreSheetId,
|
||||
reason: reason,
|
||||
);
|
||||
final refreshed = await Get.find<JudgeApiRepository>().loadSheet(
|
||||
sheet.sessionEntryId,
|
||||
);
|
||||
if (refreshed.data.isCorrection && refreshed.data.canEdit) {
|
||||
await controller.enterRemoteCorrection(refreshed.data);
|
||||
Get.snackbar('可以开始修正', '平台已创建修正流程,请修改后重新提交');
|
||||
} else {
|
||||
Get.snackbar('申请已提交', '请等待赛事管理员审批,结果会发送到消息中心');
|
||||
await _returnToTasks();
|
||||
}
|
||||
} on Object catch (error) {
|
||||
Get.snackbar(
|
||||
'暂时无法申请修正',
|
||||
CorrectionErrorPresenter.messageFor(error),
|
||||
duration: const Duration(seconds: 5),
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
);
|
||||
Future<void> _loadCorrections() {
|
||||
final pending = _correctionLoadFuture;
|
||||
if (pending != null) return pending;
|
||||
final future = _performLoadCorrections();
|
||||
_correctionLoadFuture = future;
|
||||
future.whenComplete(() {
|
||||
if (identical(_correctionLoadFuture, future)) {
|
||||
_correctionLoadFuture = null;
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> _performLoadCorrections() async {
|
||||
try {
|
||||
final api = Get.find<JudgeApiRepository>();
|
||||
final sessions = (await api.loadSessions()).data;
|
||||
final batches = await Future.wait(
|
||||
sessions.map(
|
||||
(session) async => (
|
||||
session: session,
|
||||
candidates: await api.loadCorrectionCandidates(session.sessionId),
|
||||
),
|
||||
),
|
||||
);
|
||||
final tasks = <_CorrectionTask>[
|
||||
for (final batch in batches)
|
||||
for (final candidate in batch.candidates)
|
||||
if (candidate.isAssignedTask)
|
||||
_CorrectionTask(session: batch.session, candidate: candidate),
|
||||
]..sort(_compareCorrectionTasks);
|
||||
if (!mounted) return;
|
||||
final incoming = tasks.where(
|
||||
(task) => !_announcedCorrectionIds.contains(task.notificationKey),
|
||||
);
|
||||
final incomingCount = incoming.length;
|
||||
_announcedCorrectionIds.addAll(tasks.map((task) => task.notificationKey));
|
||||
setState(() => _correctionTasks = tasks);
|
||||
if (incomingCount > 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
Get.snackbar(
|
||||
'收到成绩修正任务',
|
||||
incomingCount == 1
|
||||
? '后台已下发1条成绩修正任务,请点击顶部黄色“修正任务”查看'
|
||||
: '后台已下发$incomingCount条成绩修正任务,请点击顶部黄色“修正任务”查看',
|
||||
icon: const Icon(Icons.edit_note_rounded, color: Color(0xFFD58A00)),
|
||||
);
|
||||
});
|
||||
}
|
||||
} on Object {
|
||||
// A correction badge failure must not interrupt the active scoring task.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openCorrectionTasks() async {
|
||||
if (controller.isSubmitPending.value) {
|
||||
Get.snackbar('正在等待平台确认', '当前成绩提交完成后才能切换到修正任务');
|
||||
return;
|
||||
}
|
||||
final selected = await showCorrectionDialog(
|
||||
profile: controller.profile,
|
||||
task: controller.activeTask,
|
||||
await _loadCorrections();
|
||||
if (!mounted) return;
|
||||
if (_correctionTasks.isEmpty) {
|
||||
Get.snackbar('暂无修正任务', '当前没有后台下发给您的成绩修正任务');
|
||||
return;
|
||||
}
|
||||
final selected = await Get.dialog<_CorrectionTask>(
|
||||
_CorrectionTaskDialog(tasks: _correctionTasks),
|
||||
barrierDismissible: true,
|
||||
);
|
||||
if (selected == true) controller.beginCorrection();
|
||||
if (selected == null || !mounted) return;
|
||||
final current = controller.apiScoreSheet;
|
||||
if (current?.sessionEntryId != selected.candidate.sessionEntryId) {
|
||||
if (!controller.isSubmitted.value) {
|
||||
await controller.saveLocalDraft();
|
||||
unawaited(controller.syncDraft(showNotification: false));
|
||||
}
|
||||
await _returnToTasks(
|
||||
correctionEntryId: selected.candidate.sessionEntryId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final refreshed = await Get.find<JudgeApiRepository>().loadSheet(
|
||||
selected.candidate.sessionEntryId,
|
||||
);
|
||||
if (!refreshed.data.isCorrection || !refreshed.data.canEdit) {
|
||||
Get.snackbar('任务暂不可修改', refreshed.data.lockReason ?? '请刷新后重试');
|
||||
await _loadCorrections();
|
||||
return;
|
||||
}
|
||||
await controller.enterRemoteCorrection(refreshed.data);
|
||||
Get.snackbar('已进入修正模式', '请按照后台给出的原因修改并重新提交成绩');
|
||||
} on ApiException catch (error) {
|
||||
Get.snackbar('无法打开修正任务', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showDrawingBoard() => showDrawingBoardDialog(
|
||||
@ -198,11 +279,180 @@ class ScoringPage extends GetView<ScoringController> {
|
||||
);
|
||||
}
|
||||
|
||||
class _CorrectionTask {
|
||||
const _CorrectionTask({required this.session, required this.candidate});
|
||||
|
||||
final JudgeSession session;
|
||||
final JudgeCorrectionCandidate candidate;
|
||||
|
||||
String get notificationKey =>
|
||||
candidate.correctionId ?? candidate.sessionEntryId;
|
||||
}
|
||||
|
||||
int _compareCorrectionTasks(_CorrectionTask a, _CorrectionTask b) {
|
||||
final aTime = a.session.startTime;
|
||||
final bTime = b.session.startTime;
|
||||
if (aTime != null && bTime != null) {
|
||||
final compared = aTime.compareTo(bTime);
|
||||
if (compared != 0) return compared;
|
||||
}
|
||||
final session = a.session.sessionName.compareTo(b.session.sessionName);
|
||||
if (session != 0) return session;
|
||||
return a.candidate.entryNo.compareTo(b.candidate.entryNo);
|
||||
}
|
||||
|
||||
class _CorrectionTaskDialog extends StatelessWidget {
|
||||
const _CorrectionTaskDialog({required this.tasks});
|
||||
|
||||
final List<_CorrectionTask> tasks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 90, vertical: 54),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 960, maxHeight: 650),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 22, 18, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryPale,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.edit_note_rounded,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'成绩修正任务',
|
||||
style: TextStyle(
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'仅展示后台下发给当前裁判的待处理任务',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: Get.back,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(22),
|
||||
itemCount: tasks.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (_, index) {
|
||||
final task = tasks[index];
|
||||
final candidate = task.candidate;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FBFF),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryPale,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
candidate.entryNo,
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
candidate.participantName,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${task.session.eventName} · ${task.session.sessionName} · ${RemoteScoringAdapter.roleLabelForSession(task.session)}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (candidate.applyReason?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'修正原因:${candidate.applyReason}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFD97706),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
AppGradientButton(
|
||||
onPressed: () => Get.back(result: task),
|
||||
minimumSize: const Size(126, 44),
|
||||
child: Text(
|
||||
candidate.correctionStatus == 30 ? '继续修改' : '修改成绩',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ReadyScoringPage extends StatefulWidget {
|
||||
const _ReadyScoringPage({
|
||||
required this.controller,
|
||||
required this.onBack,
|
||||
required this.onCorrection,
|
||||
required this.correctionTaskCount,
|
||||
required this.onSubmit,
|
||||
required this.onServerSubmissionConfirmed,
|
||||
required this.onDrawingBoard,
|
||||
@ -211,6 +461,7 @@ class _ReadyScoringPage extends StatefulWidget {
|
||||
final ScoringController controller;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onCorrection;
|
||||
final int correctionTaskCount;
|
||||
final VoidCallback onSubmit;
|
||||
final Future<void> Function() onServerSubmissionConfirmed;
|
||||
final VoidCallback onDrawingBoard;
|
||||
@ -323,6 +574,7 @@ class _ReadyScoringPageState extends State<_ReadyScoringPage>
|
||||
teamLabel: widget.controller.teamLabel,
|
||||
onBack: widget.onBack,
|
||||
onCorrection: widget.onCorrection,
|
||||
correctionTaskCount: widget.correctionTaskCount,
|
||||
onSubmit: widget.onSubmit,
|
||||
submitted: widget.controller.isSubmitted.value,
|
||||
correctionMode: widget.controller.isCorrectionMode.value,
|
||||
|
||||
@ -22,6 +22,7 @@ class CompetitionHeader extends StatelessWidget {
|
||||
this.submitting = false,
|
||||
this.submitPending = false,
|
||||
this.syncMessage = '',
|
||||
this.correctionTaskCount = 0,
|
||||
});
|
||||
|
||||
final JudgeAccount profile;
|
||||
@ -39,6 +40,7 @@ class CompetitionHeader extends StatelessWidget {
|
||||
final bool submitting;
|
||||
final bool submitPending;
|
||||
final String syncMessage;
|
||||
final int correctionTaskCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -146,21 +148,37 @@ class CompetitionHeader extends StatelessWidget {
|
||||
color: AppColors.primary,
|
||||
size: 30,
|
||||
),
|
||||
if (submitted) ...[
|
||||
OutlinedButton(
|
||||
Badge(
|
||||
isLabelVisible: correctionTaskCount > 0,
|
||||
backgroundColor: const Color(0xFFE9A400),
|
||||
label: Text(
|
||||
correctionTaskCount > 99 ? '99+' : '$correctionTaskCount',
|
||||
),
|
||||
child: OutlinedButton(
|
||||
onPressed: onCorrection,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(74, 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
foregroundColor: correctionTaskCount > 0
|
||||
? const Color(0xFF9A6500)
|
||||
: AppColors.textSecondary,
|
||||
backgroundColor: correctionTaskCount > 0
|
||||
? const Color(0xFFFFF6D9)
|
||||
: Colors.white,
|
||||
side: BorderSide(
|
||||
color: correctionTaskCount > 0
|
||||
? const Color(0xFFE9A400)
|
||||
: AppColors.border,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'成绩\n修正',
|
||||
'修正\n任务',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(height: 1.05),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppGradientButton(
|
||||
onPressed: submitted || submitting ? null : onSubmit,
|
||||
minimumSize: const Size(112, 48),
|
||||
|
||||
@ -3,7 +3,6 @@ import 'package:get/get.dart';
|
||||
|
||||
import '../../../app/theme/app_theme.dart';
|
||||
import '../../../common/widgets/app_gradient_button.dart';
|
||||
import '../../../core/models/judge_account.dart';
|
||||
import '../../../core/models/scoring_task.dart';
|
||||
import '../../../core/network/api_models.dart';
|
||||
|
||||
@ -1065,282 +1064,6 @@ Future<void> showServerSubmissionResult({
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showCorrectionDialog({
|
||||
required JudgeAccount profile,
|
||||
required ScoringTask task,
|
||||
}) {
|
||||
return Get.dialog<bool>(
|
||||
_ScoringDialogFrame(
|
||||
width: 960,
|
||||
height: 560,
|
||||
icon: Icons.edit_note_rounded,
|
||||
title: '成绩修正',
|
||||
subtitle: '选择当前已提交任务进入修正模式,修正内容将单独记录。',
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Colors.white, Color(0xFFF3F8FF)],
|
||||
),
|
||||
border: Border.all(color: const Color(0xFFBED2EE)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x120F5EBA),
|
||||
blurRadius: 14,
|
||||
offset: Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryButtonGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.assignment_turned_in_outlined,
|
||||
color: Colors.white,
|
||||
size: 25,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'当前已提交评分任务',
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'请确认以下任务信息,避免修正到其他组别。',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE8F7F0),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: const Color(0xFFAFE1CB)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: AppColors.success,
|
||||
size: 17,
|
||||
),
|
||||
SizedBox(width: 5),
|
||||
Text(
|
||||
'已提交',
|
||||
style: TextStyle(
|
||||
color: AppColors.success,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _CorrectionInfo(
|
||||
icon: Icons.flag_outlined,
|
||||
label: '赛事',
|
||||
text: task.eventName,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _CorrectionInfo(
|
||||
icon: Icons.sports_gymnastics_outlined,
|
||||
label: '比赛项目',
|
||||
text: task.project,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _CorrectionInfo(
|
||||
icon: Icons.groups_outlined,
|
||||
label: '参赛队伍',
|
||||
text: '${task.teamName} · ${task.groupName}',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _CorrectionInfo(
|
||||
icon: Icons.badge_outlined,
|
||||
label: '当前裁判席位',
|
||||
text: profile.roleName,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF8E7),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(color: const Color(0xFFF3D18A)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Color(0xFFD58A00),
|
||||
size: 21,
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'进入修正模式后,原提交记录不会被覆盖;修正结果将生成独立操作记录。',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF815A13),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
footer: _DialogActions(
|
||||
primaryLabel: '进入修正模式',
|
||||
primaryIcon: Icons.edit_rounded,
|
||||
onPrimary: () => Get.back(result: true),
|
||||
),
|
||||
),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showCorrectionReasonDialog({
|
||||
required JudgeAccount profile,
|
||||
required ScoringTask task,
|
||||
}) => Get.dialog<String>(
|
||||
_CorrectionReasonDialog(profile: profile, task: task),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
|
||||
class _CorrectionReasonDialog extends StatefulWidget {
|
||||
const _CorrectionReasonDialog({required this.profile, required this.task});
|
||||
|
||||
final JudgeAccount profile;
|
||||
final ScoringTask task;
|
||||
|
||||
@override
|
||||
State<_CorrectionReasonDialog> createState() =>
|
||||
_CorrectionReasonDialogState();
|
||||
}
|
||||
|
||||
class _CorrectionReasonDialogState extends State<_CorrectionReasonDialog> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ScoringDialogFrame(
|
||||
width: 860,
|
||||
height: 500,
|
||||
icon: Icons.edit_note_rounded,
|
||||
title: '申请成绩修正',
|
||||
subtitle: '原评分会完整保留,审批通过后生成新的评分单版本。',
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryPale,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
'${widget.task.eventName} · ${widget.task.teamName} · ${widget.profile.roleName}',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
maxLength: 512,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '修正原因',
|
||||
hintText: '请说明需要修正的评分项和原因',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
footer: _DialogActions(
|
||||
primaryLabel: '提交申请',
|
||||
primaryIcon: Icons.send_outlined,
|
||||
onPrimary: () {
|
||||
final value = _controller.text.trim();
|
||||
if (value.isEmpty) {
|
||||
Get.snackbar('请填写原因', '修正原因不能为空');
|
||||
return;
|
||||
}
|
||||
Get.back(result: value);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ScoringDialogFrame extends StatelessWidget {
|
||||
const _ScoringDialogFrame({
|
||||
required this.width,
|
||||
@ -1476,66 +1199,3 @@ class _DialogActions extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _CorrectionInfo extends StatelessWidget {
|
||||
const _CorrectionInfo({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.text,
|
||||
});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
height: 72,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(color: const Color(0xFFD6E3F3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySoft,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Icon(icon, color: AppColors.primary, size: 20),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ void main() {
|
||||
WidgetTester tester, {
|
||||
required Size size,
|
||||
required String eventLabel,
|
||||
int correctionTaskCount = 0,
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(size);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
@ -34,6 +35,7 @@ void main() {
|
||||
groupLabel: '青年男子组',
|
||||
sessionLabel: '决赛场地一第十二场',
|
||||
teamLabel: '跃动科技代表队',
|
||||
correctionTaskCount: correctionTaskCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -58,4 +60,17 @@ void main() {
|
||||
expect(find.byType(CompetitionHeader), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('后台下发修正任务后显示数量角标', (tester) async {
|
||||
await pumpHeader(
|
||||
tester,
|
||||
size: const Size(1400, 920),
|
||||
eventLabel: '市级赛',
|
||||
correctionTaskCount: 3,
|
||||
);
|
||||
|
||||
expect(find.text('修正\n任务'), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:yd_pattern_scoring_system/core/models/judge_account.dart';
|
||||
import 'package:yd_pattern_scoring_system/core/models/prescribed_execution_rule.dart';
|
||||
import 'package:yd_pattern_scoring_system/core/models/scoring_task.dart';
|
||||
import 'package:yd_pattern_scoring_system/modules/scoring/widgets/scoring_dialogs.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('成绩修正原因弹窗关闭动画期间不会使用已释放的输入控制器', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
GetMaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () {
|
||||
showCorrectionReasonDialog(profile: _profile, task: _task);
|
||||
},
|
||||
child: const Text('打开修正'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('打开修正'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), '测试修正原因');
|
||||
await tester.tap(find.text('提交申请'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('申请成绩修正'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
const _profile = JudgeAccount(
|
||||
account: 'REMOTE-judge-1-DJ1',
|
||||
name: '测试裁判',
|
||||
competition: CompetitionKind.individual,
|
||||
role: ScoreRole.difficulty,
|
||||
roleCode: 'DJ',
|
||||
seat: 1,
|
||||
);
|
||||
|
||||
final _task = ScoringTask(
|
||||
taskId: 'entry-1',
|
||||
eventId: 'event-1',
|
||||
eventName: '测试赛事',
|
||||
project: '个人花样(精英级)',
|
||||
taskVariant: 'individualSolo',
|
||||
orderNo: 1,
|
||||
heatId: 'session-1',
|
||||
fieldId: '1号场地',
|
||||
session: '第一场',
|
||||
groupName: '青年组',
|
||||
teamId: 'team-1',
|
||||
teamName: '测试队',
|
||||
athleteLabel: '测试运动员',
|
||||
ruleVersion: 'test-v1',
|
||||
scheduledAt: DateTime(2026, 9, 9),
|
||||
prescribedEntryType: PrescribedEntryType.individual,
|
||||
);
|
||||
@ -1,25 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yd_pattern_scoring_system/core/network/api_client.dart';
|
||||
import 'package:yd_pattern_scoring_system/core/network/correction_error_presenter.dart';
|
||||
|
||||
void main() {
|
||||
test('比赛状态限制转换为面向裁判的提示', () {
|
||||
const error = ApiException('比赛已结束,暂不能评分');
|
||||
expect(
|
||||
CorrectionErrorPresenter.messageFor(error),
|
||||
'当前比赛状态暂不允许申请成绩修正,请在赛事管理员开放评分或修正后重试。',
|
||||
);
|
||||
});
|
||||
|
||||
test('普通业务错误保留服务端具体原因', () {
|
||||
const error = ApiException('该评分单已有修正申请在处理中');
|
||||
expect(CorrectionErrorPresenter.messageFor(error), error.message);
|
||||
});
|
||||
|
||||
test('非接口异常不暴露调试信息', () {
|
||||
expect(
|
||||
CorrectionErrorPresenter.messageFor(StateError('internal details')),
|
||||
'暂时无法申请成绩修正,请稍后重试或联系赛事管理员。',
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yd_pattern_scoring_system/core/network/api_models.dart';
|
||||
|
||||
void main() {
|
||||
test('仅已批准和修改中的申请可直接进入修正,待审批不能绕过审批', () {
|
||||
test('仅后台已下发和修改中的记录属于裁判修正任务', () {
|
||||
for (final status in [-1, 0, 10, 20, 30, 40, 90]) {
|
||||
final candidate = JudgeCorrectionCandidate.fromJson({
|
||||
'scoreSheetId': 'sheet-1',
|
||||
@ -10,7 +10,7 @@ void main() {
|
||||
'correctionStatus': status,
|
||||
});
|
||||
expect(
|
||||
candidate.canResume,
|
||||
candidate.isAssignedTask,
|
||||
status == 10 || status == 30,
|
||||
reason: 'correctionStatus=$status',
|
||||
);
|
||||
|
||||
@ -150,6 +150,31 @@ void main() {
|
||||
);
|
||||
expect(RemoteScoringAdapter.variantFor(header), 'doubleDutch3');
|
||||
});
|
||||
|
||||
test('评分页场次场地展示完整且不重复项目和组别', () {
|
||||
final session = _session(id: 'session-dj', role: 'DJ1', roleType: 'DJ');
|
||||
final sheet = _sheet(role: 'DJ1', roleType: 'DJ');
|
||||
final task = RemoteScoringAdapter.taskFor(
|
||||
session,
|
||||
const JudgeEntry(
|
||||
sessionEntryId: 'entry-1',
|
||||
entryNo: '01',
|
||||
playOrder: 1,
|
||||
participantName: '测试队',
|
||||
groupName: '青年组',
|
||||
entryStatus: 0,
|
||||
isRematch: false,
|
||||
scoreStatus: 0,
|
||||
scoreStatusName: '待评分',
|
||||
isDisqualified: false,
|
||||
scoreSheetId: '',
|
||||
),
|
||||
sheet,
|
||||
);
|
||||
expect(task.session, '第二场-1号场地');
|
||||
expect(task.session, isNot(contains(task.project)));
|
||||
expect(task.session, isNot(contains(task.groupName)));
|
||||
});
|
||||
}
|
||||
|
||||
JudgeSession _session({
|
||||
@ -162,7 +187,7 @@ JudgeSession _session({
|
||||
eventName: '测试赛事',
|
||||
projectName: '个人花样(精英级)',
|
||||
ruleName: '2025-2028规则',
|
||||
sessionName: id,
|
||||
sessionName: '第二场',
|
||||
venueName: '1号场地',
|
||||
groupName: '青年组',
|
||||
startTime: DateTime(2026, 9, 9),
|
||||
|
||||
77
test/task_queue_policy_test.dart
Normal file
77
test/task_queue_policy_test.dart
Normal file
@ -0,0 +1,77 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yd_pattern_scoring_system/core/models/task_queue_policy.dart';
|
||||
|
||||
void main() {
|
||||
test('只展示最早存在未完成任务的赛事,并按场次和出场顺序排列', () {
|
||||
final values = TaskQueuePolicy.nearestPendingEvent([
|
||||
_candidate('old-done', 'old', 0, 1, true, 8),
|
||||
_candidate('next-2', 'next', 1, 2, false, 10),
|
||||
_candidate('later', 'later', 0, 1, false, 12),
|
||||
_candidate('next-1', 'next', 1, 1, false, 10),
|
||||
_candidate('next-done', 'next', 0, 1, true, 9),
|
||||
]);
|
||||
expect(values, ['next-done', 'next-1', 'next-2']);
|
||||
});
|
||||
|
||||
test('队列中只有第一个未完成任务可打分', () {
|
||||
expect(TaskQueuePolicy.activeIndex([true, false, false]), 1);
|
||||
expect(TaskQueuePolicy.activeIndex([false, false]), 0);
|
||||
expect(TaskQueuePolicy.activeIndex([true, true]), -1);
|
||||
});
|
||||
|
||||
test('秩序册全局顺序优先于组别场次顺序', () {
|
||||
final values = TaskQueuePolicy.nearestPendingEvent([
|
||||
_candidate('A-1', 'event', 0, 1, false, 9, scheduleOrder: 1),
|
||||
_candidate('A-2', 'event', 0, 2, false, 9, scheduleOrder: 3),
|
||||
_candidate('B-1', 'event', 1, 1, false, 9, scheduleOrder: 2),
|
||||
]);
|
||||
expect(values, ['A-1', 'B-1', 'A-2']);
|
||||
});
|
||||
|
||||
test('成绩修正任务保留展示但不阻塞正常任务', () {
|
||||
final values = TaskQueuePolicy.nearestPendingEvent([
|
||||
_candidate('old-correction', 'old', 0, 1, true, 8, attention: true),
|
||||
_candidate('next-normal', 'next', 0, 1, false, 9),
|
||||
_candidate('later-normal', 'later', 0, 1, false, 10),
|
||||
]);
|
||||
expect(values, ['old-correction', 'next-normal']);
|
||||
expect(TaskQueuePolicy.activeIndex([true, false]), 1);
|
||||
});
|
||||
|
||||
test('没有正常待评分任务时仍展示后台下发的修正任务', () {
|
||||
final values = TaskQueuePolicy.nearestPendingEvent([
|
||||
_candidate('correction', 'event', 0, 1, true, 9, attention: true),
|
||||
_candidate('done', 'event', 0, 2, true, 9),
|
||||
]);
|
||||
expect(values, ['correction']);
|
||||
});
|
||||
|
||||
test('所有赛事均完成时不再展示旧任务', () {
|
||||
expect(
|
||||
TaskQueuePolicy.nearestPendingEvent([
|
||||
_candidate('done-1', 'event', 0, 1, true, 9),
|
||||
]),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
TaskQueueCandidate<String> _candidate(
|
||||
String value,
|
||||
String event,
|
||||
int sessionOrder,
|
||||
int playOrder,
|
||||
bool submitted,
|
||||
int hour, {
|
||||
int scheduleOrder = 0,
|
||||
bool attention = false,
|
||||
}) => TaskQueueCandidate(
|
||||
value: value,
|
||||
eventId: event,
|
||||
sessionOrder: sessionOrder,
|
||||
playOrder: playOrder,
|
||||
submitted: submitted,
|
||||
startTime: DateTime(2026, 9, 20, hour),
|
||||
scheduleOrder: scheduleOrder,
|
||||
attention: attention,
|
||||
);
|
||||
Loading…
x
Reference in New Issue
Block a user