update:bug修复

This commit is contained in:
ltx 2026-09-22 09:01:27 +08:00
parent 9b914062d1
commit a419e3da30
19 changed files with 816 additions and 764 deletions

View File

@ -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),
),
];
}

View 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);
}
}

View File

@ -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',

View File

@ -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(),

View File

@ -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);
}
}

View File

@ -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 {

View File

@ -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') {

View File

@ -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('提交申请'),
),
],
);
}

View File

@ -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')}';

View File

@ -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;

View File

@ -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,

View File

@ -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),

View File

@ -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,
),
),
],
),
),
],
),
);
}

View File

@ -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);
});
}

View File

@ -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,
);

View File

@ -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')),
'暂时无法申请成绩修正,请稍后重试或联系赛事管理员。',
);
});
}

View File

@ -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',
);

View File

@ -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),

View 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,
);