From 926d549f15966306a3b606b40caa8e2e6614a191 Mon Sep 17 00:00:00 2001 From: ltx <1006158920@qq.com> Date: Wed, 28 Oct 2026 14:38:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=A3=81=E5=88=A4?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=86=E9=A1=B5=E4=B8=8E=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E8=AF=84=E5=88=86=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务列表改为按页加载和按账号缓存,增加右侧场次范围分页栏、10/20/50 条切换、相邻页预取及返回列表即时恢复。 通过按出场 ID 查询和服务端下一任务定位,避免跨页逐场加载;下一场严格沿编排顺序向后查找,修正任务独立处理。 主裁失误次数可继续累计但扣分系数封顶;个人花样规定元素由难度等级自动统计并禁止手改。补充接口模型、英文文案及对应测试。 依赖后台新增 /api/judge-scoring/tasks 分页、单项及 next 接口;仅本地提交,不推送远程。 --- lib/app/localization/app_strings.dart | 11 + lib/core/models/deduction_rule_item.dart | 1 + lib/core/models/task_queue_policy.dart | 24 + lib/core/network/api_client.dart | 34 + lib/core/network/api_models.dart | 96 +++ lib/core/network/judge_api_repository.dart | 48 ++ .../competition_rule_calculator.dart | 28 +- lib/modules/home/views/home_page.dart | 654 +++++++++++++----- .../controllers/scoring_controller.dart | 51 +- lib/modules/scoring/views/scoring_page.dart | 49 +- test/api_models_test.dart | 36 + test/app_localization_test.dart | 14 + test/competition_rule_calculator_test.dart | 29 + test/task_queue_policy_test.dart | 44 ++ 14 files changed, 905 insertions(+), 214 deletions(-) diff --git a/lib/app/localization/app_strings.dart b/lib/app/localization/app_strings.dart index 532a209..a852a19 100644 --- a/lib/app/localization/app_strings.dart +++ b/lib/app/localization/app_strings.dart @@ -71,6 +71,12 @@ abstract final class AppStrings { if (level != null) return 'Level ${level.group(1)}'; final plainSession = RegExp(r'^第(\d+)场$').firstMatch(source); if (plainSession != null) return 'Session ${plainSession.group(1)}'; + final pageNumber = RegExp(r'^第(\d+)页$').firstMatch(source); + if (pageNumber != null) return 'Page ${pageNumber.group(1)}'; + final sessionRange = RegExp(r'^第(\d+)–(\d+)场$').firstMatch(source); + if (sessionRange != null) { + return 'Sessions ${sessionRange.group(1)}–${sessionRange.group(2)}'; + } final judgeDifficulty = RegExp(r'^(.+)裁判难度分$').firstMatch(source); if (judgeDifficulty != null) { return '${judgeDifficulty.group(1)} judge difficulty score'; @@ -315,6 +321,11 @@ abstract final class AppStrings { '次数': 'Times', '失误次数': 'Error count', '设置数量': 'Set count', + '每页任务': 'Tasks per page', + '上一页': 'Previous page', + '下一页': 'Next page', + '切换页面失败': 'Could not change page', + '自动识别': 'Auto counted', '失误': 'Error', '间歇': 'Break', '违例': 'Violation', diff --git a/lib/core/models/deduction_rule_item.dart b/lib/core/models/deduction_rule_item.dart index c680d55..a263ede 100644 --- a/lib/core/models/deduction_rule_item.dart +++ b/lib/core/models/deduction_rule_item.dart @@ -42,6 +42,7 @@ abstract final class DeductionRuleCatalog { label: '失误', standard: '第1次扣5%,第2次扣7.5%,其后每次扣10%', method: DeductionMethod.progressiveMistake, + maximum: 50, ); static const _violation = DeductionRuleItem( diff --git a/lib/core/models/task_queue_policy.dart b/lib/core/models/task_queue_policy.dart index fd9febe..75d6abe 100644 --- a/lib/core/models/task_queue_policy.dart +++ b/lib/core/models/task_queue_policy.dart @@ -22,6 +22,14 @@ class TaskQueueCandidate { } abstract final class TaskQueuePolicy { + /// Full arrangement order for continuing across event boundaries. The home + /// list may intentionally show only one event, so it cannot drive "next". + static List orderedValues(Iterable> candidates) { + final ordered = candidates.toList(growable: false) + ..sort(_compareCandidates); + return [for (final candidate in ordered) candidate.value]; + } + /// Keeps the nearest unfinished event and every non-blocking attention task. static List nearestPendingEvent( Iterable> candidates, @@ -60,6 +68,22 @@ abstract final class TaskQueuePolicy { return -1; } + /// Continue strictly forward in arrangement order. An earlier skipped task + /// remains available from the list, but must not become the next task after + /// scoring a later entry. Correction tasks belong to a separate workflow. + static int nextPendingAfter({ + required int currentIndex, + required List submitted, + List? corrections, + }) { + for (var index = currentIndex + 1; index < submitted.length; index++) { + if (!submitted[index] && (corrections == null || !corrections[index])) { + return index; + } + } + return -1; + } + /// Stable task availability for button appearance. A brief navigation /// operation must not make unrelated task buttons look disabled. static bool isTaskActionAvailable({ diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 891a409..9030638 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -102,6 +102,40 @@ class ApiClient { .toList(growable: false); } + Future getTaskPage({ + required int page, + required int pageSize, + }) async => JudgeTaskPage.fromJson( + await _requestMap( + 'GET', + '/judge-scoring/tasks', + queryParameters: {'page': page, 'pageSize': pageSize}, + ), + ); + + Future getTaskByEntry( + String sessionEntryId, { + required int pageSize, + }) async => JudgeTaskItem.fromJson( + await _requestMap( + 'GET', + '/judge-scoring/tasks/$sessionEntryId', + queryParameters: {'pageSize': pageSize}, + ), + ); + + Future getNextTask( + String afterEntryId, { + required int pageSize, + }) async { + final value = await _request( + 'GET', + '/judge-scoring/tasks/next', + queryParameters: {'afterEntryId': afterEntryId, 'pageSize': pageSize}, + ); + return value == null ? null : JudgeTaskItem.fromJson(_asMap(value)); + } + Future getSheet(String sessionEntryId) async { final data = await _requestMap( 'GET', diff --git a/lib/core/network/api_models.dart b/lib/core/network/api_models.dart index 59410ce..2d57e2a 100644 --- a/lib/core/network/api_models.dart +++ b/lib/core/network/api_models.dart @@ -250,6 +250,102 @@ class JudgeEntry { }; } +class JudgeTaskPageRange { + const JudgeTaskPageRange({ + required this.page, + required this.firstSessionOrder, + required this.lastSessionOrder, + }); + final int page; + final int firstSessionOrder; + final int lastSessionOrder; + + factory JudgeTaskPageRange.fromJson(Map json) => + JudgeTaskPageRange( + page: _int(json['page']), + firstSessionOrder: _int(json['firstSessionOrder']), + lastSessionOrder: _int(json['lastSessionOrder']), + ); + Map toJson() => { + 'page': page, + 'firstSessionOrder': firstSessionOrder, + 'lastSessionOrder': lastSessionOrder, + }; +} + +class JudgeTaskItem { + const JudgeTaskItem({ + required this.page, + required this.session, + required this.entry, + }); + final int page; + final JudgeSession session; + final JudgeEntry entry; + + factory JudgeTaskItem.fromJson(Map json) => JudgeTaskItem( + page: _int(json['page']), + session: JudgeSession.fromJson( + (json['session'] as Map).cast(), + ), + entry: JudgeEntry.fromJson((json['entry'] as Map).cast()), + ); + Map toJson() => { + 'page': page, + 'session': session.toJson(), + 'entry': entry.toJson(), + }; +} + +class JudgeTaskPage { + const JudgeTaskPage({ + required this.eventId, + required this.page, + required this.pageSize, + required this.totalCount, + required this.totalPages, + required this.pageRanges, + required this.items, + }); + final String eventId; + final int page; + final int pageSize; + final int totalCount; + final int totalPages; + final List pageRanges; + final List items; + + factory JudgeTaskPage.fromJson(Map json) => JudgeTaskPage( + eventId: _string(json['eventId']), + page: _int(json['page']), + pageSize: _int(json['pageSize']), + totalCount: _int(json['totalCount']), + totalPages: _int(json['totalPages']), + pageRanges: ((json['pageRanges'] as List?) ?? const []) + .map( + (item) => JudgeTaskPageRange.fromJson( + (item as Map).cast(), + ), + ) + .toList(growable: false), + items: ((json['items'] as List?) ?? const []) + .map( + (item) => + JudgeTaskItem.fromJson((item as Map).cast()), + ) + .toList(growable: false), + ); + Map toJson() => { + 'eventId': eventId, + 'page': page, + 'pageSize': pageSize, + 'totalCount': totalCount, + 'totalPages': totalPages, + 'pageRanges': pageRanges.map((item) => item.toJson()).toList(), + 'items': items.map((item) => item.toJson()).toList(), + }; +} + class JudgeScoreSheet { const JudgeScoreSheet({ required this.sessionEntryId, diff --git a/lib/core/network/judge_api_repository.dart b/lib/core/network/judge_api_repository.dart index 1f5946d..ede91e8 100644 --- a/lib/core/network/judge_api_repository.dart +++ b/lib/core/network/judge_api_repository.dart @@ -18,6 +18,54 @@ class JudgeApiRepository { final LocalStorageService _storage; final AuthSessionStore _sessionStore; + /// Show the current judge's last task list immediately while the network + /// refresh runs. Cache keys already include the authenticated user ID. + List? cachedSessions() => + _readList(_key('sessions'), JudgeSession.fromJson); + + List? cachedEntries(String sessionId) => + _readList(_key('entries-$sessionId'), JudgeEntry.fromJson); + + JudgeTaskPage? cachedTaskPage({required int page, required int pageSize}) { + final source = + _storage.settings.get(_key('tasks-$pageSize-$page')) as String?; + if (source == null) return null; + try { + return JudgeTaskPage.fromJson( + (jsonDecode(source) as Map).cast(), + ); + } on Object { + return null; + } + } + + Future> loadTaskPage({ + required int page, + required int pageSize, + }) async { + final key = _key('tasks-$pageSize-$page'); + try { + final data = await _client.getTaskPage(page: page, pageSize: pageSize); + await _putJson(key, data.toJson()); + return ApiLoad(data, fromCache: false); + } on ApiException catch (error) { + if (error.isUnauthorized) rethrow; + final cached = cachedTaskPage(page: page, pageSize: pageSize); + if (cached != null) return ApiLoad(cached, fromCache: true); + rethrow; + } + } + + Future loadTaskByEntry( + String sessionEntryId, { + required int pageSize, + }) => _client.getTaskByEntry(sessionEntryId, pageSize: pageSize); + + Future loadNextTask( + String afterEntryId, { + required int pageSize, + }) => _client.getNextTask(afterEntryId, pageSize: pageSize); + Future login({ required String mobile, required String password, diff --git a/lib/core/rule_engine/calculators/competition_rule_calculator.dart b/lib/core/rule_engine/calculators/competition_rule_calculator.dart index 2c06e73..7cb966d 100644 --- a/lib/core/rule_engine/calculators/competition_rule_calculator.dart +++ b/lib/core/rule_engine/calculators/competition_rule_calculator.dart @@ -54,11 +54,7 @@ class CompetitionRuleCalculator { plusCounts.values.any((count) => count > 0) || minusCounts.values.any((count) => count > 0); if (!hasInput) return 0; - return performanceRaw( - plusCounts, - minusCounts: minusCounts, - errors: errors, - ); + return performanceRaw(plusCounts, minusCounts: minusCounts, errors: errors); } static double mistakePenalty(int count) { @@ -86,6 +82,24 @@ class CompetitionRuleCalculator { required int level2AndBelow, }) => math.min(4, level3AndAbove + level2AndBelow * .5).toDouble(); + /// Individual difficulty counts are the source of truth for the two + /// required-element bands; interval level 0 is not an action. + static ({int level3AndAbove, int level2AndBelow}) + individualRequiredFromDifficulty(Map counts) { + var high = 0; + var low = 0; + for (final entry in counts.entries) { + final level = double.tryParse(entry.key.replaceAll(RegExp(r'[+-]$'), '')); + if (level == null || level <= 0 || entry.value <= 0) continue; + if (level >= 3) { + high += entry.value; + } else { + low += entry.value; + } + } + return (level3AndAbove: high, level2AndBelow: low); + } + static double individualRequiredMissing({ required int level3AndAbove, required int level2AndBelow, @@ -123,8 +137,8 @@ class CompetitionRuleCalculator { final numericLevel = level.endsWith('+') ? baseLevel + .5 : level.endsWith('-') - ? baseLevel - .25 - : baseLevel; + ? baseLevel - .25 + : baseLevel; return double.parse(math.pow(1.5, numericLevel).toStringAsFixed(4)); } } diff --git a/lib/modules/home/views/home_page.dart b/lib/modules/home/views/home_page.dart index aa79a3d..891b5de 100644 --- a/lib/modules/home/views/home_page.dart +++ b/lib/modules/home/views/home_page.dart @@ -41,6 +41,11 @@ class _HomePageState extends State { final List<_RemoteTask> _remoteTasks = []; final List _allSessions = []; + JudgeTaskPage? _taskPage; + int _page = 1; + int _pageSize = 10; + int _loadVersion = 0; + bool _changingPage = false; bool _loading = true; bool _fromCache = false; String? _error; @@ -55,6 +60,7 @@ class _HomePageState extends State { _syncStatusWorker = ever(Get.find().revision, (_) { if (mounted) setState(() {}); }); + _restoreCachedTasks(); _loadTasks(); _loadUnreadMessages(); _messageTimer = Timer.periodic(const Duration(seconds: 30), (_) { @@ -62,6 +68,41 @@ class _HomePageState extends State { }); } + void _restoreCachedTasks() { + final userId = _auth.session?.user.id; + if (userId == null) return; + final storedSize = _storage.settings.get('task-page-size-$userId'); + _pageSize = storedSize is int && const [10, 20, 50].contains(storedSize) + ? storedSize + : 10; + final storedPage = _storage.settings.get('task-page-$userId'); + _page = storedPage is int && storedPage > 0 ? storedPage : 1; + final sessions = _api.cachedSessions(); + if (sessions != null) _allSessions.addAll(sessions); + final cached = _api.cachedTaskPage(page: _page, pageSize: _pageSize); + if (cached == null) return; + _applyTaskPage(cached); + _fromCache = true; + _loading = false; + } + + void _applyTaskPage(JudgeTaskPage page) { + _taskPage = page; + _page = page.page; + _remoteTasks + ..clear() + ..addAll( + page.items.map( + (item) => _RemoteTask( + session: item.session, + entry: item.entry, + task: _taskFor(item.session, item.entry), + sessionOrder: item.entry.sessionOrder, + ), + ), + ); + } + @override void dispose() { _syncStatusWorker.dispose(); @@ -69,61 +110,42 @@ class _HomePageState extends State { super.dispose(); } - Future _loadTasks() async { + Future _loadTasks({bool refreshSessions = true}) async { + final version = ++_loadVersion; + final requestedPage = _page; + final requestedSize = _pageSize; setState(() { - _loading = _remoteTasks.isEmpty; + _loading = _taskPage == null; _error = null; }); try { - final sessionsResult = await _api.loadSessions(); - final batches = await Future.wait( - sessionsResult.data.map((session) async { - final entriesResult = await _api.loadEntries(session.sessionId); - return ( - session: session, - entries: entriesResult.data, - fromCache: entriesResult.fromCache, - ); - }), + final sessionsFuture = refreshSessions || _allSessions.isEmpty + ? _api.loadSessions() + : Future.value( + ApiLoad( + List.of(_allSessions), + fromCache: _fromCache, + ), + ); + final pageFuture = _api.loadTaskPage( + page: requestedPage, + pageSize: requestedSize, ); - 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; + final loaded = await Future.wait([sessionsFuture, pageFuture]); + final sessionsResult = loaded[0] as ApiLoad>; + final pageResult = loaded[1] as ApiLoad; + if (!mounted || version != _loadVersion) return; setState(() { _allSessions ..clear() ..addAll(sessionsResult.data); - _remoteTasks - ..clear() - ..addAll(tasks); - _fromCache = - sessionsResult.fromCache || batches.any((item) => item.fromCache); + _applyTaskPage(pageResult.data); + _fromCache = sessionsResult.fromCache || pageResult.fromCache; _loading = false; }); + unawaited(_prefetchNextPage()); } on ApiException catch (error) { - if (!mounted) return; + if (!mounted || version != _loadVersion) return; setState(() { _loading = false; _error = error.message; @@ -131,6 +153,65 @@ class _HomePageState extends State { } } + Future _prefetchNextPage() async { + final next = _page + 1; + if (next > (_taskPage?.totalPages ?? 0)) return; + await _prefetchPage(next); + } + + Future _prefetchPage(int page) async { + if (_api.cachedTaskPage(page: page, pageSize: _pageSize) != null) return; + try { + await _api.loadTaskPage(page: page, pageSize: _pageSize); + } on ApiException { + // Prefetch is optional and must not affect scoring. + } + } + + Future _changePage(int page, {int? pageSize}) async { + final size = pageSize ?? _pageSize; + if (_changingPage || + page < 1 || + (pageSize == null && page > (_taskPage?.totalPages ?? 0))) { + return; + } + final userId = _auth.session?.user.id; + if (userId == null) return; + final cached = _api.cachedTaskPage(page: page, pageSize: size); + if (cached != null) { + ++_loadVersion; + setState(() { + _pageSize = size; + _applyTaskPage(cached); + _fromCache = true; + _error = null; + }); + await _storage.settings.put('task-page-size-$userId', size); + await _storage.settings.put('task-page-$userId', _page); + unawaited(_loadTasks(refreshSessions: false)); + return; + } + setState(() => _changingPage = true); + try { + final result = await _api.loadTaskPage(page: page, pageSize: size); + if (!mounted || _auth.session?.user.id != userId) return; + ++_loadVersion; + setState(() { + _pageSize = size; + _applyTaskPage(result.data); + _fromCache = result.fromCache; + _error = null; + }); + await _storage.settings.put('task-page-size-$userId', size); + await _storage.settings.put('task-page-$userId', _page); + unawaited(_prefetchNextPage()); + } on ApiException catch (error) { + AppNotice.show('切换页面失败', error.message); + } finally { + if (mounted) setState(() => _changingPage = false); + } + } + @override Widget build(BuildContext context) { if (_loading && _remoteTasks.isEmpty) { @@ -143,7 +224,6 @@ class _HomePageState extends State { final chiefSessions = _chiefSessions; final tasks = _remoteTasks.map((item) => item.task).toList(growable: false); final statuses = _remoteTasks.map(_statusFor).toList(growable: false); - final completed = statuses.where(_isSubmittedStatus).length; final saved = statuses .where( (status) => @@ -176,90 +256,129 @@ class _HomePageState extends State { onLogout: _logout, ), Expanded( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? _LoadError(message: _error!, onRetry: _loadTasks) - : _remoteTasks.isEmpty - ? _EmptyTasks(onRefresh: _loadTasks) - : RefreshIndicator( - onRefresh: _loadTasks, - child: ListView( - padding: const EdgeInsets.fromLTRB(42, 28, 42, 44), - children: [ - _PageHeading(roleSummary: roleSummary), - if (_fromCache) ...[ - const SizedBox(height: 16), - const _OfflineCacheNotice(), - ], - if (_remoteTasks.isNotEmpty) ...[ - const SizedBox(height: 22), - _QueueSummary( - total: tasks.length, - completed: completed, - saved: saved, - ), - ], - const SizedBox(height: 24), - ...tasks.indexed.map( - (entry) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _TaskCard( - task: entry.$2, - sessionDisplayOrder: - RemoteScoringAdapter.sessionDisplayOrder( - _remoteTasks[entry.$1].session, - _remoteTasks[entry.$1].entry, - ), - roleLabel: - RemoteScoringAdapter.roleLabelForSession( - _remoteTasks[entry.$1].session, - ), - status: statuses[entry.$1], - isSuggested: entry.$1 == suggestedIndex, - isLocked: - blockingIndex >= 0 && - entry.$1 != blockingIndex, - onTap: - !TaskQueuePolicy.isTaskActionAvailable( - index: entry.$1, - pendingSubmissionIndex: blockingIndex, - ) - ? null - : () { - if (!TaskQueuePolicy.canOpenTask( - index: entry.$1, - pendingSubmissionIndex: blockingIndex, - opening: _openingEntryId != null, - )) { - return; - } - if (statuses[entry.$1] == - ScoringTaskStatus - .submittedAnnotationPending - .label) { - unawaited( - _retryAnnotation( - _remoteTasks[entry.$1] - .entry - .sessionEntryId, - ), - ); - } else { - unawaited( - _openTask( - entry.$1, - _remoteTasks[entry.$1], - ), - ); - } - }, + child: Row( + children: [ + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null && _taskPage == null + ? _LoadError(message: _error!, onRetry: _loadTasks) + : _remoteTasks.isEmpty && + (_taskPage?.totalCount ?? 0) == 0 + ? _EmptyTasks(onRefresh: _loadTasks) + : RefreshIndicator( + onRefresh: _loadTasks, + child: ListView.builder( + key: ValueKey('task-page-$_pageSize-$_page'), + padding: const EdgeInsets.fromLTRB( + 42, + 28, + 42, + 44, ), + itemCount: tasks.length + 1, + itemBuilder: (context, itemIndex) { + if (itemIndex == 0) { + return Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + _PageHeading(roleSummary: roleSummary), + if (_fromCache || _error != null) ...[ + const SizedBox(height: 16), + const _OfflineCacheNotice(), + ], + const SizedBox(height: 22), + _QueueSummary( + total: + _taskPage?.totalCount ?? + tasks.length, + completed: _allSessions.fold( + 0, + (sum, item) => sum + item.scoredCount, + ), + saved: saved, + ), + const SizedBox(height: 24), + ], + ); + } + final index = itemIndex - 1; + final remoteTask = _remoteTasks[index]; + return Padding( + key: ValueKey( + remoteTask.entry.sessionEntryId, + ), + padding: const EdgeInsets.only(bottom: 16), + child: _TaskCard( + task: tasks[index], + sessionDisplayOrder: + RemoteScoringAdapter.sessionDisplayOrder( + remoteTask.session, + remoteTask.entry, + ), + roleLabel: + RemoteScoringAdapter.roleLabelForSession( + remoteTask.session, + ), + status: statuses[index], + isSuggested: index == suggestedIndex, + isLocked: + blockingIndex >= 0 && + index != blockingIndex, + onTap: + !TaskQueuePolicy.isTaskActionAvailable( + index: index, + pendingSubmissionIndex: blockingIndex, + ) + ? null + : () { + if (!TaskQueuePolicy.canOpenTask( + index: index, + pendingSubmissionIndex: + blockingIndex, + opening: _openingEntryId != null, + )) { + return; + } + if (statuses[index] == + ScoringTaskStatus + .submittedAnnotationPending + .label) { + unawaited( + _retryAnnotation( + remoteTask + .entry + .sessionEntryId, + ), + ); + } else { + unawaited( + _openTask(index, remoteTask), + ); + } + }, + ), + ); + }, ), ), - ], + ), + if ((_taskPage?.totalPages ?? 0) > 0) + SizedBox( + width: 156, + child: _TaskPageRail( + page: _page, + pageSize: _pageSize, + loading: _changingPage, + ranges: _taskPage!.pageRanges, + onPage: (value) => unawaited(_changePage(value)), + onPageSize: (value) => + unawaited(_changePage(1, pageSize: value)), ), ), + ], + ), ), ], ), @@ -408,37 +527,98 @@ class _HomePageState extends State { }) async { _openingEntryId = remoteTask.entry.sessionEntryId; try { - // Show a complete route immediately. Its loading surface remains visible - // until the sheet, local context and role-specific controller are ready. - final preparation = _prepareScoringTask(index, remoteTask, correction); - final routeResult = await Get.toNamed( - AppRoutes.scoring, - arguments: preparation, - ); - final continueNext = routeResult == true; - final correctionEntryId = routeResult is String ? routeResult : null; - // Get.toNamed completes when the route starts popping. Keep storage and - // task-list refresh work off the 200 ms reverse transition. - await Future.delayed(const Duration(milliseconds: 220)); - if (!mounted || _auth.session == null) return; - await _storage.settings.put( - ScoringStorageKeys.apiResumeScoringSetting, - false, - ); - await _storage.settings.flush(); - if (Get.isRegistered()) { - await Get.delete(force: true); - } - await _loadTasks(); - if (correctionEntryId != null && mounted) { - await _openCorrectionTask(correctionEntryId); - } else if (continueNext && mounted) { - await _openNextTask(); + var currentIndex = index; + var currentTask = remoteTask; + while (mounted) { + // Prepare only the selected sheet. Refreshing every session before + // choosing the next entry made long task lists feel unresponsive. + final preparation = _prepareScoringTask( + currentIndex, + currentTask, + correction, + ); + final routeResult = await Get.toNamed( + AppRoutes.scoring, + arguments: preparation, + ); + final continueNext = routeResult == true; + final correctionEntryId = routeResult is String ? routeResult : null; + // The reverse transition needs to finish before controller teardown. + await Future.delayed(const Duration(milliseconds: 220)); + if (!mounted || _auth.session == null) return; + if (_storage.settings.get(ScoringStorageKeys.apiResumeScoringSetting) == + true) { + await _storage.settings.put( + ScoringStorageKeys.apiResumeScoringSetting, + false, + ); + await _storage.settings.flush(); + } + if (Get.isRegistered()) { + await Get.delete(force: true); + } + if (continueNext && !correction) { + JudgeTaskItem? next; + try { + next = await _api.loadNextTask( + currentTask.entry.sessionEntryId, + pageSize: _pageSize, + ); + } on ApiException catch (error) { + AppNotice.show('下一场暂不可用', error.message); + } + if (next != null) { + currentIndex = 0; + currentTask = _RemoteTask( + session: next.session, + entry: next.entry, + task: _taskFor(next.session, next.entry), + sessionOrder: next.entry.sessionOrder, + ); + _page = next.page; + final userId = _auth.session?.user.id; + if (userId != null) { + unawaited(_storage.settings.put('task-page-$userId', _page)); + } + unawaited(_prefetchPage(_page)); + _openingEntryId = currentTask.entry.sessionEntryId; + continue; + } + } + if (correctionEntryId != null && mounted) { + await _openCorrectionTask(correctionEntryId); + if (mounted) unawaited(_loadTasks()); + } else { + if (_taskPage?.page != _page) { + final cached = _api.cachedTaskPage( + page: _page, + pageSize: _pageSize, + ); + if (cached != null) { + _applyTaskPage(cached); + _fromCache = true; + } else { + _taskPage = null; + _remoteTasks.clear(); + } + } + // A successful submit is already reflected by the local sync status. + // Release the tap guard before refreshing every session in the + // background, otherwise a large queue looks disabled for seconds. + _openingEntryId = null; + if (mounted) setState(() {}); + unawaited(_loadTasks()); + if (continueNext && mounted) { + AppNotice.show('本轮任务已完成', '当前场次后没有待评分任务,先前跳过的场次可从列表进入'); + } + } + return; } } on ApiException catch (error) { AppNotice.show('无法打开评分表', error.message); } finally { _openingEntryId = null; + if (mounted) setState(() {}); } } @@ -523,43 +703,24 @@ class _HomePageState extends State { } 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, - ), - correction: true, - ); - return; - } - } on ApiException { - // Continue searching other assigned sessions. - } + try { + final item = await _api.loadTaskByEntry( + sessionEntryId, + pageSize: _pageSize, + ); + await _openTask( + 0, + _RemoteTask( + session: item.session, + entry: item.entry, + task: _taskFor(item.session, item.entry), + sessionOrder: item.entry.sessionOrder, + ), + correction: true, + ); + } on ApiException catch (error) { + AppNotice.show('无法打开修正任务', error.message); } - AppNotice.show('无法打开修正任务', '该任务已失效或后台已撤回,请刷新后重试'); - } - - Future _openNextTask() async { - final nextIndex = TaskQueuePolicy.activeIndex( - _remoteTasks.map( - (candidate) => _isSubmittedStatus(_statusFor(candidate)), - ), - ); - if (nextIndex < 0) { - AppNotice.show('本轮任务已完成', '当前裁判席位没有更多待评分场次'); - return; - } - await _openTask(nextIndex, _remoteTasks[nextIndex]); } ScoringTask _taskFor(JudgeSession session, JudgeEntry entry) => ScoringTask( @@ -932,6 +1093,129 @@ class _PageHeading extends StatelessWidget { ); } +class _TaskPageRail extends StatelessWidget { + const _TaskPageRail({ + required this.page, + required this.pageSize, + required this.loading, + required this.ranges, + required this.onPage, + required this.onPageSize, + }); + final int page; + final int pageSize; + final bool loading; + final List ranges; + final ValueChanged onPage; + final ValueChanged onPageSize; + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.fromLTRB(0, 20, 20, 20), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .95), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + const AppText('每页任务', style: TextStyle(fontWeight: FontWeight.w700)), + DropdownButton( + value: pageSize, + isExpanded: true, + items: const [10, 20, 50] + .map( + (count) => DropdownMenuItem( + value: count, + child: Center(child: Text('$count')), + ), + ) + .toList(), + onChanged: loading + ? null + : (value) { + if (value != null) onPageSize(value); + }, + ), + if (loading) + const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + IconButton( + tooltip: AppStrings.of(context, '上一页'), + onPressed: !loading && page > 1 ? () => onPage(page - 1) : null, + icon: const Icon(Icons.keyboard_arrow_up_rounded), + ), + Expanded( + child: ListView.builder( + itemCount: ranges.length, + itemBuilder: (context, index) { + final range = ranges[index]; + final selected = range.page == page; + final label = range.firstSessionOrder == range.lastSessionOrder + ? '第${range.firstSessionOrder}场' + : '第${range.firstSessionOrder}–${range.lastSessionOrder}场'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: selected ? AppColors.primary : AppColors.primaryPale, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: selected || loading + ? null + : () => onPage(range.page), + borderRadius: BorderRadius.circular(10), + child: SizedBox( + height: 58, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + AppStrings.of(context, '第${range.page}页'), + style: TextStyle( + color: selected + ? Colors.white + : AppColors.primaryDark, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + Text( + AppStrings.of(context, label), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected + ? Colors.white + : AppColors.primaryDark, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + IconButton( + tooltip: AppStrings.of(context, '下一页'), + onPressed: !loading && page < ranges.length + ? () => onPage(page + 1) + : null, + icon: const Icon(Icons.keyboard_arrow_down_rounded), + ), + ], + ), + ); +} + class _TaskCard extends StatelessWidget { const _TaskCard({ required this.task, diff --git a/lib/modules/scoring/controllers/scoring_controller.dart b/lib/modules/scoring/controllers/scoring_controller.dart index c975bdd..ceb2403 100644 --- a/lib/modules/scoring/controllers/scoring_controller.dart +++ b/lib/modules/scoring/controllers/scoring_controller.dart @@ -58,8 +58,9 @@ class ScoringController extends GetxController { final _reviewedRequiredKeys = {}; String? _legacyRequiredNotice; bool _preserveLegacyLocalDraft = false; - String? get requiredMigrationNotice => - _legacyRequiredNotice ?? apiScoreSheet?.ruleMigrationNotice; + String? get requiredMigrationNotice => automaticallyCountsIndividualRequired + ? null + : _legacyRequiredNotice ?? apiScoreSheet?.ruleMigrationNotice; final performanceErrorCount = 0.obs; final requiredElementFactor = 1.0.obs; final disqualified = false.obs; @@ -110,6 +111,23 @@ class ScoringController extends GetxController { } String get _groupName => activeTask.groupName; + bool get automaticallyCountsIndividualRequired => + profile.competition == CompetitionKind.individual && + profile.role == ScoreRole.difficulty; + + void _syncIndividualRequiredFromDifficulty() { + if (!automaticallyCountsIndividualRequired) return; + final counts = CompetitionRuleCalculator.individualRequiredFromDifficulty( + difficultyCounts, + ); + requiredElementCounts[EliteScoringRuleCatalog.requiredLevel3AndAbove] = + counts.level3AndAbove; + requiredElementCounts[EliteScoringRuleCatalog.requiredLevel2AndBelow] = + counts.level2AndBelow; + _reviewedRequiredKeys.addAll(requiredElementCounts.keys); + requiredElementFactor.value = _requiredFactor(); + } + String get _sessionName => activeTask.session; String get competitionLabel => activeTask.project; String get athleteLabel => activeTask.athleteLabel; @@ -325,6 +343,7 @@ class ScoringController extends GetxController { for (final item in deductionRules) item.key: 0, }); } + _syncIndividualRequiredFromDifficulty(); requiredElementFactor.value = _requiredFactor(); } @@ -455,6 +474,7 @@ class ScoringController extends GetxController { } } if (usesShowDifficultyMatrix) _syncShowDifficultyTotals(); + _syncIndividualRequiredFromDifficulty(); if (profile.role == ScoreRole.presentation) { // 服务端只保存裁判手动录入的减号;失误产生的自动减号由服务端重算。 _syncAllPerformanceItemScores(); @@ -641,8 +661,10 @@ class ScoringController extends GetxController { } _syncAllPerformanceItemScores(); } - requiredElementFactor.value = - (raw['requiredElementFactor'] as num?)?.toDouble() ?? 1; + _syncIndividualRequiredFromDifficulty(); + requiredElementFactor.value = automaticallyCountsIndividualRequired + ? _requiredFactor() + : (raw['requiredElementFactor'] as num?)?.toDouble() ?? 1; disqualified.value = raw['disqualified'] as bool? ?? false; selectedDisqualifyReasonCode = raw['disqualifyReasonCode'] as String?; disqualifyRemark = raw['disqualifyRemark'] as String?; @@ -726,6 +748,7 @@ class ScoringController extends GetxController { void setDifficultyCount(String level, int count) { difficultyCounts[level] = count; + _syncIndividualRequiredFromDifficulty(); recalculate(); } @@ -908,6 +931,7 @@ class ScoringController extends GetxController { } void setRequiredElementCount(String item, int count) { + if (automaticallyCountsIndividualRequired) return; _reviewedRequiredKeys.add(item); requiredElementCounts[item] = profile.competition == CompetitionKind.wheel ? count.clamp(0, 4) @@ -917,10 +941,27 @@ class ScoringController extends GetxController { } void setDeductionCount(String item, int count) { - deductionCounts[item] = count; + deductionCounts[item] = count < 0 ? 0 : count; recalculate(); } + int deductionCountMaximum(String key) { + final sheet = apiScoreSheet; + if (sheet != null) { + for (final item in sheet.items) { + if (_itemUiKeys[item.code] == key && + (item.itemType == 4 || item.scoreDimension == 4) && + item.maxValue > 0) { + return item.maxValue.floor(); + } + } + } + for (final rule in deductionRules) { + if (rule.key == key) return rule.maximum; + } + return 50; + } + ScoreInput get input => ScoreInput( difficultyElements: difficultyCounts.entries .map( diff --git a/lib/modules/scoring/views/scoring_page.dart b/lib/modules/scoring/views/scoring_page.dart index 4869cf0..0a6ff1a 100644 --- a/lib/modules/scoring/views/scoring_page.dart +++ b/lib/modules/scoring/views/scoring_page.dart @@ -1239,21 +1239,34 @@ List _individualRequiredElementRows( ), ), _NumberText(entry.value.toString()), - _ActionButton( - label: '设置数量', - onTap: controller.isReadOnly - ? null - : () async { - final value = await showQuantityPicker( - current: entry.value, - includeZero: true, - maximum: 8, - ); - if (value != null) { - controller.setRequiredElementCount(entry.key, value); - } - }, - ), + controller.automaticallyCountsIndividualRequired + ? const AppText( + '自动识别', + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ) + : _ActionButton( + label: '设置数量', + onTap: controller.isReadOnly + ? null + : () async { + final value = await showQuantityPicker( + current: entry.value, + includeZero: true, + maximum: 8, + ); + if (value != null) { + controller.setRequiredElementCount( + entry.key, + value, + ); + } + }, + ), ], ), ) @@ -1749,7 +1762,7 @@ class _ChiefRequiredDeductionPanel extends StatelessWidget { final value = await showQuantityPicker( current: count, includeZero: true, - maximum: rule.maximum, + maximum: controller.deductionCountMaximum(rule.key), ); if (value != null) { controller.setDeductionCount(rule.key, value); @@ -1961,7 +1974,9 @@ class _ChiefDeductionPanel extends StatelessWidget { final value = await showQuantityPicker( current: count, includeZero: true, - maximum: rule.maximum, + maximum: controller.deductionCountMaximum( + rule.key, + ), ); if (value != null) { controller.setDeductionCount(rule.key, value); diff --git a/test/api_models_test.dart b/test/api_models_test.dart index 3d411fd..5e9cf4f 100644 --- a/test/api_models_test.dart +++ b/test/api_models_test.dart @@ -2,6 +2,42 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:yd_pattern_scoring_system/core/network/api_models.dart'; void main() { + test('分页任务保留实际场次范围和角色,不包含最终成绩', () { + final page = JudgeTaskPage.fromJson({ + 'eventId': 10, + 'page': 2, + 'pageSize': 10, + 'totalCount': 21, + 'totalPages': 3, + 'pageRanges': [ + {'page': 1, 'firstSessionOrder': 1, 'lastSessionOrder': 10}, + {'page': 2, 'firstSessionOrder': 11, 'lastSessionOrder': 20}, + ], + 'items': [ + { + 'page': 2, + 'session': { + 'sessionId': 5, + 'eventId': 10, + 'judgeRole': 'P1', + 'judgeRoleType': 'P', + 'judgeRoleName': '表现裁判', + }, + 'entry': { + 'sessionEntryId': 21, + 'sessionOrder': 11, + 'playOrder': 1, + 'scoreStatus': 0, + }, + }, + ], + }); + expect(page.pageRanges[1].firstSessionOrder, 11); + expect(page.items.single.session.judgeRole, 'P1'); + expect(page.items.single.entry.sessionEntryId, '21'); + expect(JudgeTaskPage.fromJson(page.toJson()).items.single.page, 2); + }); + test('规定元素计数即使名称含难度等级或维度配置错误,也不是难度分', () { final difficulty = JudgeScoreItem.fromJson({ 'itemCode': 'DIFF_LEVEL_3', diff --git a/test/app_localization_test.dart b/test/app_localization_test.dart index 508c9c6..4aa0e3b 100644 --- a/test/app_localization_test.dart +++ b/test/app_localization_test.dart @@ -10,6 +10,20 @@ import 'package:yd_pattern_scoring_system/app/localization/app_text.dart'; import 'package:yd_pattern_scoring_system/app/localization/language_switch_button.dart'; void main() { + test( + 'pagination rail labels translate while preserving business session numbers', + () { + const english = Locale('en'); + expect(AppStrings.translate('第2页', locale: english), 'Page 2'); + expect( + AppStrings.translate('第11–20场', locale: english), + 'Sessions 11–20', + ); + expect(AppStrings.translate('第11场', locale: english), 'Session 11'); + expect(AppStrings.translate('每页任务', locale: english), 'Tasks per page'); + }, + ); + test( 'English translates scoring labels without changing unknown event data', () { diff --git a/test/competition_rule_calculator_test.dart b/test/competition_rule_calculator_test.dart index 5698148..7ec5bbe 100644 --- a/test/competition_rule_calculator_test.dart +++ b/test/competition_rule_calculator_test.dart @@ -1,4 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:yd_pattern_scoring_system/core/models/deduction_rule_item.dart'; +import 'package:yd_pattern_scoring_system/core/models/judge_account.dart'; import 'package:yd_pattern_scoring_system/core/rule_engine/calculators/competition_rule_calculator.dart'; void main() { @@ -134,6 +136,18 @@ void main() { expect(CompetitionRuleCalculator.mistakePenalty(4), .325); }); + test('主裁失误累计可超过扣分归零点,系数最低为0', () { + final mistake = DeductionRuleCatalog.forCompetition( + CompetitionKind.individual, + ).first; + expect(mistake.maximum, greaterThan(11)); + expect(CompetitionRuleCalculator.mistakePenalty(11), greaterThan(1)); + expect( + (1 - CompetitionRuleCalculator.mistakePenalty(20)).clamp(0, 1).toDouble(), + 0, + ); + }); + test('规定元素缺失按项目系数计算', () { expect(CompetitionRuleCalculator.requiredFromMissing(3, .025), .925); expect(CompetitionRuleCalculator.requiredFromMissing(2, .05), .9); @@ -161,6 +175,21 @@ void main() { ); }); + test('个人花样规定元素从难度等级数量自动统计,0级间歇不计入', () { + final counts = CompetitionRuleCalculator.individualRequiredFromDifficulty( + const {'0': 9, '0.5': 2, '1': 3, '2': 1, '3': 2, '4': 1, '8': 0}, + ); + expect(counts.level3AndAbove, 3); + expect(counts.level2AndBelow, 6); + expect( + CompetitionRuleCalculator.individualRequiredCompleted( + level3AndAbove: counts.level3AndAbove, + level2AndBelow: counts.level2AndBelow, + ), + 4, + ); + }); + test('个人花样规定元素完成数量最多按目标4计算', () { expect( CompetitionRuleCalculator.individualRequiredCompleted( diff --git a/test/task_queue_policy_test.dart b/test/task_queue_policy_test.dart index b166809..9b3a239 100644 --- a/test/task_queue_policy_test.dart +++ b/test/task_queue_policy_test.dart @@ -19,6 +19,50 @@ void main() { expect(TaskQueuePolicy.activeIndex([true, true]), -1); }); + test('下一场只沿编排顺序向后,不回跳到先前跳过的场次', () { + expect( + TaskQueuePolicy.nextPendingAfter( + currentIndex: 2, + submitted: [true, false, true, false, false], + ), + 3, + ); + expect( + TaskQueuePolicy.nextPendingAfter( + currentIndex: 3, + submitted: [true, false, true, true], + ), + -1, + ); + expect( + TaskQueuePolicy.nextPendingAfter( + currentIndex: 0, + submitted: [true, false, false, false], + corrections: [false, true, false, false], + ), + 2, + ); + }); + + test('下一场可跨越首页未展示的后续赛事', () { + final candidates = [ + _candidate('event-2', 'first', 0, 2, false, 9), + _candidate('later-1', 'later', 0, 1, false, 10), + _candidate('event-1', 'first', 0, 1, false, 9), + ]; + expect(TaskQueuePolicy.nearestPendingEvent(candidates), [ + 'event-1', + 'event-2', + ]); + final ordered = TaskQueuePolicy.orderedValues(candidates); + expect(ordered, ['event-1', 'event-2', 'later-1']); + final next = TaskQueuePolicy.nextPendingAfter( + currentIndex: ordered.indexOf('event-2'), + submitted: [false, true, false], + ); + expect(ordered[next], 'later-1'); + }); + test('同一赛事可任意选择待评分场次,未确认提交仍需先处理', () { for (final index in [0, 1, 2]) { expect(