diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 0616816..94028be 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -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), ), ]; } diff --git a/lib/core/models/task_queue_policy.dart b/lib/core/models/task_queue_policy.dart new file mode 100644 index 0000000..c55e973 --- /dev/null +++ b/lib/core/models/task_queue_policy.dart @@ -0,0 +1,84 @@ +/// A task decorated with the minimum scheduling data needed by the home queue. +class TaskQueueCandidate { + 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 nearestPendingEvent( + Iterable> candidates, + ) { + final ordered = candidates.toList(growable: false) + ..sort(_compareCandidates); + TaskQueueCandidate? 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 submitted) { + var index = 0; + for (final value in submitted) { + if (!value) return index; + index++; + } + return -1; + } + + static int _compareCandidates( + TaskQueueCandidate a, + TaskQueueCandidate 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); + } +} diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index b7df84b..8960dac 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -167,18 +167,6 @@ class ApiClient { .toList(growable: false); } - Future 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> getMessages({bool? onlyUnread}) async { final data = await _requestList( 'GET', diff --git a/lib/core/network/api_models.dart b/lib/core/network/api_models.dart index f17f4f9..2d85d50 100644 --- a/lib/core/network/api_models.dart +++ b/lib/core/network/api_models.dart @@ -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 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 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 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 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 json) => JudgeCorrectionCandidate( correctionId: json['correctionId']?.toString(), diff --git a/lib/core/network/correction_error_presenter.dart b/lib/core/network/correction_error_presenter.dart deleted file mode 100644 index c6186d8..0000000 --- a/lib/core/network/correction_error_presenter.dart +++ /dev/null @@ -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); - } -} diff --git a/lib/core/network/judge_api_repository.dart b/lib/core/network/judge_api_repository.dart index c6892b9..195544a 100644 --- a/lib/core/network/judge_api_repository.dart +++ b/lib/core/network/judge_api_repository.dart @@ -103,11 +103,6 @@ class JudgeApiRepository { String sessionId, ) => _client.getCorrectionCandidates(sessionId); - Future applyCorrection({ - required String scoreSheetId, - required String reason, - }) => _client.applyCorrection(scoreSheetId: scoreSheetId, reason: reason); - Future>> loadMessages({bool? onlyUnread}) async { final key = _key(onlyUnread == true ? 'messages-unread' : 'messages'); try { diff --git a/lib/core/network/remote_scoring_adapter.dart b/lib/core/network/remote_scoring_adapter.dart index 4c1b0aa..cd15a3f 100644 --- a/lib/core/network/remote_scoring_adapter.dart +++ b/lib/core/network/remote_scoring_adapter.dart @@ -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') { diff --git a/lib/modules/home/views/corrections_page.dart b/lib/modules/home/views/corrections_page.dart index 12069b3..efa6210 100644 --- a/lib/modules/home/views/corrections_page.dart +++ b/lib/modules/home/views/corrections_page.dart @@ -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 { 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 { } } - Future _apply(JudgeCorrectionCandidate candidate) async { - final reason = await Get.dialog( - _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 { ), ), Text( - '申请、审批状态与修正记录', + '后台下发给当前裁判的待处理任务', style: TextStyle(color: AppColors.textSecondary), ), ], @@ -178,7 +145,7 @@ class _CorrectionsPageState extends State { : _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 { 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 { ], ), ), - 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 { ); } } - -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('提交申请'), - ), - ], - ); -} diff --git a/lib/modules/home/views/home_page.dart b/lib/modules/home/views/home_page.dart index 8134896..6e017b9 100644 --- a/lib/modules/home/views/home_page.dart +++ b/lib/modules/home/views/home_page.dart @@ -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 { AuthSessionStore get _auth => Get.find(); final List<_RemoteTask> _remoteTasks = []; + final List _allSessions = []; bool _loading = true; bool _fromCache = false; String? _error; @@ -42,6 +44,7 @@ class _HomePageState extends State { late final Worker _syncStatusWorker; Timer? _messageTimer; int _unreadMessages = 0; + int _correctionTaskCount = 0; @override void initState() { @@ -51,10 +54,12 @@ class _HomePageState extends State { }); _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 { ); }), ); - 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 { 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 { 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 { 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 { ), 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 { 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 get _sessions { - final seen = {}; - return [ - for (final task in _remoteTasks) - if (seen.add(task.session.sessionId)) task.session, - ]; - } + List get _sessions => _allSessions; List get _chiefSessions => _sessions .where(RemoteScoringAdapter.isChiefSession) @@ -298,7 +311,11 @@ class _HomePageState extends State { 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 { 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 _loadCorrectionTaskCount(List 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 _openResults() async { @@ -326,6 +364,34 @@ class _HomePageState extends State { } Future _logout() async { + final confirmed = await Get.dialog( + 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 { '已按最新评分表切换为${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 { await Get.delete(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 { } } - Future _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 _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 _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 { 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')}'; diff --git a/lib/modules/scoring/controllers/scoring_controller.dart b/lib/modules/scoring/controllers/scoring_controller.dart index bfe5127..5dd1e5d 100644 --- a/lib/modules/scoring/controllers/scoring_controller.dart +++ b/lib/modules/scoring/controllers/scoring_controller.dart @@ -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 enterRemoteCorrection(JudgeScoreSheet sheet) async { apiScoreSheet = sheet; scoreId = null; diff --git a/lib/modules/scoring/views/scoring_page.dart b/lib/modules/scoring/views/scoring_page.dart index 5e59e9f..bdd65d3 100644 --- a/lib/modules/scoring/views/scoring_page.dart +++ b/lib/modules/scoring/views/scoring_page.dart @@ -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 { +class ScoringPage extends StatefulWidget { const ScoringPage({super.key}); + @override + State createState() => _ScoringPageState(); +} + +class _ScoringPageState extends State with WidgetsBindingObserver { + ScoringController get controller => Get.find(); + + List<_CorrectionTask> _correctionTasks = const []; + Future? _correctionLoadFuture; + Timer? _correctionTimer; + final Set _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 { 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 { await _returnToTasks(); } - Future _returnToTasks({bool continueNext = false}) async { + Future _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 _correct() async { - final sheet = controller.apiScoreSheet; - if (sheet != null && !sheet.isCorrection) { - try { - final candidates = await Get.find() - .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().applyCorrection( - scoreSheetId: sheet.scoreSheetId, - reason: reason, - ); - final refreshed = await Get.find().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 _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 _performLoadCorrections() async { + try { + final api = Get.find(); + 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 _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().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 _showDrawingBoard() => showDrawingBoardDialog( @@ -198,11 +279,180 @@ class ScoringPage extends GetView { ); } +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 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, diff --git a/lib/modules/scoring/widgets/competition_header.dart b/lib/modules/scoring/widgets/competition_header.dart index e91ca19..3ddce89 100644 --- a/lib/modules/scoring/widgets/competition_header.dart +++ b/lib/modules/scoring/widgets/competition_header.dart @@ -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), diff --git a/lib/modules/scoring/widgets/scoring_dialogs.dart b/lib/modules/scoring/widgets/scoring_dialogs.dart index d63f38a..be6e028 100644 --- a/lib/modules/scoring/widgets/scoring_dialogs.dart +++ b/lib/modules/scoring/widgets/scoring_dialogs.dart @@ -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 showServerSubmissionResult({ ); } -Future showCorrectionDialog({ - required JudgeAccount profile, - required ScoringTask task, -}) { - return Get.dialog( - _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 showCorrectionReasonDialog({ - required JudgeAccount profile, - required ScoringTask task, -}) => Get.dialog( - _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, - ), - ), - ], - ), - ), - ], - ), - ); -} diff --git a/test/competition_header_test.dart b/test/competition_header_test.dart index f83d0ce..d0d585e 100644 --- a/test/competition_header_test.dart +++ b/test/competition_header_test.dart @@ -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); + }); } diff --git a/test/correction_dialog_test.dart b/test/correction_dialog_test.dart deleted file mode 100644 index afba088..0000000 --- a/test/correction_dialog_test.dart +++ /dev/null @@ -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, -); diff --git a/test/correction_error_presenter_test.dart b/test/correction_error_presenter_test.dart deleted file mode 100644 index 8f886cf..0000000 --- a/test/correction_error_presenter_test.dart +++ /dev/null @@ -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')), - '暂时无法申请成绩修正,请稍后重试或联系赛事管理员。', - ); - }); -} diff --git a/test/correction_resume_test.dart b/test/correction_resume_test.dart index 5f334c5..21f8d6b 100644 --- a/test/correction_resume_test.dart +++ b/test/correction_resume_test.dart @@ -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', ); diff --git a/test/remote_scoring_adapter_test.dart b/test/remote_scoring_adapter_test.dart index 029abc1..3eceba6 100644 --- a/test/remote_scoring_adapter_test.dart +++ b/test/remote_scoring_adapter_test.dart @@ -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), diff --git a/test/task_queue_policy_test.dart b/test/task_queue_policy_test.dart new file mode 100644 index 0000000..bb9d292 --- /dev/null +++ b/test/task_queue_policy_test.dart @@ -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 _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, +);