From a3bf1319a06105ac8b4f95109e9f08820230eb83 Mon Sep 17 00:00:00 2001 From: ltx <1006158920@qq.com> Date: Mon, 19 Oct 2026 15:44:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E4=B8=BB=E8=A3=81=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E8=AE=A1=E6=97=B6=E4=B8=8E=E4=BB=BB=E6=84=8F=E5=9C=BA?= =?UTF-8?q?=E6=AC=A1=E8=AF=84=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主裁评分页新增仅本机运行的正计时器,支持开始、暂停、继续、清零;宽屏位于评分区左侧,窄屏移到上方,切换任务后重新计时。计时数据不进入评分模型、持久化或上传请求。 移除任务列表仅允许第一条未完成场次打分的限制。维持赛事编排排序,将首个未完成场次标为推荐;当前展示赛事中的其他场次可直接进入。未确认提交及页面打开中的并发操作仍受保护。 补充中英文文案、任务队列策略测试与计时器交互和布局测试。验证:flutter analyze 无问题,flutter test 全部通过。 --- lib/app/localization/app_strings.dart | 11 ++ lib/core/models/task_queue_policy.dart | 13 +- lib/modules/home/views/home_page.dart | 41 ++--- lib/modules/scoring/views/scoring_page.dart | 40 ++++- .../widgets/chief_stopwatch_panel.dart | 169 ++++++++++++++++++ test/chief_stopwatch_panel_test.dart | 68 +++++++ test/task_queue_policy_test.dart | 39 +++- 7 files changed, 352 insertions(+), 29 deletions(-) create mode 100644 lib/modules/scoring/widgets/chief_stopwatch_panel.dart create mode 100644 test/chief_stopwatch_panel_test.dart diff --git a/lib/app/localization/app_strings.dart b/lib/app/localization/app_strings.dart index 7db03ae..8586d55 100644 --- a/lib/app/localization/app_strings.dart +++ b/lib/app/localization/app_strings.dart @@ -207,6 +207,8 @@ abstract final class AppStrings { '当前赛事任务': 'Current event tasks', '仅展示最近的待执裁赛事,请按赛事编排顺序依次完成。': 'Only the next pending event is shown. Complete tasks in the scheduled order.', + '仅展示最近的待执裁赛事,可选择任意场次评分。': + 'Only the next pending event is shown. Choose any session to score.', '正在加载裁判任务…': 'Loading judging tasks…', '暂时无法获取执裁任务': 'Could not load judging tasks', '重新加载': 'Reload', @@ -217,11 +219,14 @@ abstract final class AppStrings { '当前展示上一次同步的离线数据;恢复网络后下拉刷新。': 'Showing last synced offline data. Pull down to refresh when online.', '当前场次': 'Current session', + '推荐场次': 'Suggested session', '待修正': 'Correction needed', '等待前序完成': 'Waiting for previous task', + '等待当前提交': 'Waiting for pending submission', '修改成绩': 'Correct score', '继续修改': 'Continue correction', '等待前序': 'Waiting', + '等待提交': 'Submission pending', '重试画板': 'Retry annotation', '查看记录': 'View record', '重试提交': 'Retry submission', @@ -267,6 +272,12 @@ abstract final class AppStrings { '提交打分': 'Submit score', '参赛运动员:': 'Athletes:', '打分区域': 'Scoring area', + '正计时': 'Elapsed time', + '开始计时': 'Start', + '暂停计时': 'Pause', + '继续计时': 'Resume', + '清零': 'Reset', + '仅本机计时,不上传': 'Device-only timer; not uploaded', '规定元素打分区域': 'Required elements', '互动配合规定元素打分区域': 'Interaction required elements', '难度打分区域': 'Difficulty scoring', diff --git a/lib/core/models/task_queue_policy.dart b/lib/core/models/task_queue_policy.dart index c55e973..c2b3ff1 100644 --- a/lib/core/models/task_queue_policy.dart +++ b/lib/core/models/task_queue_policy.dart @@ -49,7 +49,8 @@ abstract final class TaskQueuePolicy { ]; } - /// Only the first unfinished item is scoreable. + /// The first unfinished item is suggested for the "continue" action only. + /// Other tasks in the visible event may be selected in any order. static int activeIndex(Iterable submitted) { var index = 0; for (final value in submitted) { @@ -59,6 +60,16 @@ abstract final class TaskQueuePolicy { return -1; } + /// An unconfirmed submission must be resolved before switching tasks, but + /// ordinary unfinished tasks do not lock later entries in the queue. + static bool canOpenTask({ + required int index, + required int pendingSubmissionIndex, + required bool opening, + }) => + !opening && + (pendingSubmissionIndex < 0 || index == pendingSubmissionIndex); + static int _compareCandidates( TaskQueueCandidate a, TaskQueueCandidate b, diff --git a/lib/modules/home/views/home_page.dart b/lib/modules/home/views/home_page.dart index 4a0ba65..f05843e 100644 --- a/lib/modules/home/views/home_page.dart +++ b/lib/modules/home/views/home_page.dart @@ -158,7 +158,7 @@ class _HomePageState extends State { status == ScoringTaskStatus.waitingUpload.label || status == ScoringTaskStatus.syncing.label, ); - final activeIndex = TaskQueuePolicy.activeIndex( + final suggestedIndex = TaskQueuePolicy.activeIndex( statuses.map(_isSubmittedStatus), ); return Scaffold( @@ -216,21 +216,16 @@ class _HomePageState extends State { _remoteTasks[entry.$1].session, ), status: statuses[entry.$1], - isCurrent: entry.$1 == activeIndex, + isSuggested: entry.$1 == suggestedIndex, isLocked: - !_isSubmittedStatus(statuses[entry.$1]) && - entry.$1 != activeIndex, + blockingIndex >= 0 && + entry.$1 != blockingIndex, onTap: - _openingEntryId == - _remoteTasks[entry.$1] - .entry - .sessionEntryId || - (blockingIndex >= 0 && - entry.$1 != blockingIndex) || - (!_isSubmittedStatus( - statuses[entry.$1], - ) && - entry.$1 != activeIndex) + !TaskQueuePolicy.canOpenTask( + index: entry.$1, + pendingSubmissionIndex: blockingIndex, + opening: _openingEntryId != null, + ) ? null : statuses[entry.$1] == ScoringTaskStatus @@ -858,7 +853,7 @@ class _PageHeading extends StatelessWidget { ), const SizedBox(height: 10), const AppText( - '仅展示最近的待执裁赛事,请按赛事编排顺序依次完成。', + '仅展示最近的待执裁赛事,可选择任意场次评分。', style: TextStyle(color: AppColors.textSecondary, fontSize: 15), ), ], @@ -900,7 +895,7 @@ class _TaskCard extends StatelessWidget { required this.sessionDisplayOrder, required this.roleLabel, required this.status, - required this.isCurrent, + required this.isSuggested, required this.isLocked, required this.onTap, }); @@ -908,7 +903,7 @@ class _TaskCard extends StatelessWidget { final int sessionDisplayOrder; final String roleLabel; final String status; - final bool isCurrent; + final bool isSuggested; final bool isLocked; final VoidCallback? onTap; @@ -967,10 +962,10 @@ class _TaskCard extends StatelessWidget { border: Border.all( color: correction ? const Color(0xFFF2C45A) - : isCurrent + : isSuggested ? AppColors.primary.withValues(alpha: .48) : const Color(0xFFD9E6F6), - width: correction || isCurrent ? 1.4 : 1, + width: correction || isSuggested ? 1.4 : 1, ), borderRadius: BorderRadius.circular(16), boxShadow: const [ @@ -1034,7 +1029,7 @@ class _TaskCard extends StatelessWidget { ), ), ), - if (isCurrent) ...[ + if (isSuggested) ...[ const SizedBox(width: 10), Container( padding: const EdgeInsets.symmetric( @@ -1046,7 +1041,7 @@ class _TaskCard extends StatelessWidget { borderRadius: BorderRadius.circular(7), ), child: const AppText( - '当前场次', + '推荐场次', style: TextStyle( color: AppColors.primary, fontSize: 11, @@ -1129,7 +1124,7 @@ class _TaskCard extends StatelessWidget { correction ? '待修正' : isLocked - ? '等待前序完成' + ? '等待当前提交' : status, style: TextStyle( color: color, @@ -1162,7 +1157,7 @@ class _TaskCard extends StatelessWidget { correction ? '修改成绩' : isLocked - ? '等待前序' + ? '等待提交' : annotationPending ? '重试画板' : submitted diff --git a/lib/modules/scoring/views/scoring_page.dart b/lib/modules/scoring/views/scoring_page.dart index 3f1adf7..42341b3 100644 --- a/lib/modules/scoring/views/scoring_page.dart +++ b/lib/modules/scoring/views/scoring_page.dart @@ -19,6 +19,7 @@ import '../../../core/storage/local_storage_service.dart'; import '../../../core/storage/scoring_storage_keys.dart'; import '../controllers/scoring_controller.dart'; import '../widgets/competition_header.dart'; +import '../widgets/chief_stopwatch_panel.dart'; import '../widgets/drawing_board_dialog.dart'; import '../widgets/record_button.dart'; import '../widgets/scoring_dialogs.dart'; @@ -1511,10 +1512,41 @@ class _ChiefPanel extends StatelessWidget { @override Widget build(BuildContext context) { - if (controller.requiredElementCounts.isEmpty) { - return _ChiefDeductionPanel(controller: controller); - } - return _ChiefRequiredDeductionPanel(controller: controller); + final scoringPanel = controller.requiredElementCounts.isEmpty + ? _ChiefDeductionPanel(controller: controller) + : _ChiefRequiredDeductionPanel(controller: controller); + final stopwatch = ChiefStopwatchPanel( + key: ValueKey('chief-stopwatch-${controller.activeTask.taskId}'), + ); + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 1250) { + return Column( + children: [ + SizedBox( + height: 78, + child: ChiefStopwatchPanel( + key: ValueKey( + 'chief-stopwatch-${controller.activeTask.taskId}', + ), + compact: true, + ), + ), + const SizedBox(height: 12), + Expanded(child: scoringPanel), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: 205, child: stopwatch), + const SizedBox(width: 14), + Expanded(child: scoringPanel), + ], + ); + }, + ); } } diff --git a/lib/modules/scoring/widgets/chief_stopwatch_panel.dart b/lib/modules/scoring/widgets/chief_stopwatch_panel.dart new file mode 100644 index 0000000..6d88f0b --- /dev/null +++ b/lib/modules/scoring/widgets/chief_stopwatch_panel.dart @@ -0,0 +1,169 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../app/localization/app_text.dart'; +import '../../../app/theme/app_theme.dart'; +import '../../../common/widgets/app_gradient_button.dart'; + +/// An on-screen aid for the chief judge. Elapsed time is never persisted or +/// included in a score-sheet request. +class ChiefStopwatchPanel extends StatefulWidget { + const ChiefStopwatchPanel({super.key, this.compact = false}); + + final bool compact; + + static String formatElapsed(Duration elapsed) { + final hours = elapsed.inHours.toString().padLeft(2, '0'); + final minutes = (elapsed.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (elapsed.inSeconds % 60).toString().padLeft(2, '0'); + return '$hours:$minutes:$seconds'; + } + + @override + State createState() => _ChiefStopwatchPanelState(); +} + +class _ChiefStopwatchPanelState extends State { + final Stopwatch _stopwatch = Stopwatch(); + Timer? _ticker; + + @override + void dispose() { + _ticker?.cancel(); + _stopwatch.stop(); + super.dispose(); + } + + void _toggle() { + setState(() { + if (_stopwatch.isRunning) { + _stopwatch.stop(); + _ticker?.cancel(); + _ticker = null; + } else { + _stopwatch.start(); + _ticker = Timer.periodic(const Duration(milliseconds: 250), (_) { + if (mounted) setState(() {}); + }); + } + }); + } + + void _reset() { + setState(() { + _ticker?.cancel(); + _ticker = null; + _stopwatch + ..stop() + ..reset(); + }); + } + + @override + Widget build(BuildContext context) { + final elapsed = ChiefStopwatchPanel.formatElapsed(_stopwatch.elapsed); + final action = _stopwatch.isRunning + ? '暂停计时' + : _stopwatch.elapsed == Duration.zero + ? '开始计时' + : '继续计时'; + final startButton = AppGradientButton( + onPressed: _toggle, + minimumSize: Size(widget.compact ? 116 : 145, 42), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + icon: widget.compact + ? _stopwatch.isRunning + ? Icons.pause_rounded + : Icons.play_arrow_rounded + : null, + child: AppText(action), + ); + final resetButton = OutlinedButton( + onPressed: _stopwatch.elapsed == Duration.zero ? null : _reset, + child: const AppText('清零'), + ); + return Container( + padding: EdgeInsets.all(widget.compact ? 12 : 18), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .97), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + boxShadow: const [ + BoxShadow( + color: Color(0x101767D2), + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), + child: widget.compact + ? Row( + children: [ + const Icon(Icons.timer_outlined, color: AppColors.primary), + const SizedBox(width: 8), + const AppText( + '正计时', + style: TextStyle(fontWeight: FontWeight.w700), + ), + const SizedBox(width: 18), + AppText( + elapsed, + style: const TextStyle( + color: AppColors.primaryDark, + fontSize: 25, + fontFeatures: [FontFeature.tabularFigures()], + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + startButton, + const SizedBox(width: 8), + resetButton, + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.timer_outlined, + color: AppColors.primary, + size: 38, + ), + const SizedBox(height: 8), + const AppText( + '正计时', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 12), + AppText( + elapsed, + style: const TextStyle( + color: AppColors.primaryDark, + fontSize: 27, + fontFeatures: [FontFeature.tabularFigures()], + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 8), + const AppText( + '仅本机计时,不上传', + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + const SizedBox(height: 22), + startButton, + const SizedBox(height: 8), + resetButton, + ], + ), + ); + } +} diff --git a/test/chief_stopwatch_panel_test.dart b/test/chief_stopwatch_panel_test.dart new file mode 100644 index 0000000..13bde4a --- /dev/null +++ b/test/chief_stopwatch_panel_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yd_pattern_scoring_system/app/localization/app_language_service.dart'; +import 'package:yd_pattern_scoring_system/modules/scoring/widgets/chief_stopwatch_panel.dart'; + +void main() { + test('正计时以小时、分钟、秒展示,不在 24 小时处归零', () { + expect(ChiefStopwatchPanel.formatElapsed(Duration.zero), '00:00:00'); + expect( + ChiefStopwatchPanel.formatElapsed( + const Duration(hours: 27, minutes: 2, seconds: 4), + ), + '27:02:04', + ); + }); + + testWidgets('主裁可开始、暂停、继续和清零本地计时', (tester) async { + AppLanguageService.instance = AppLanguageService.memory(initialCode: 'en'); + addTearDown(() => AppLanguageService.instance = null); + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 205, + height: 500, + child: ChiefStopwatchPanel(), + ), + ), + ), + ), + ); + + expect(find.text('00:00:00'), findsOneWidget); + expect(find.text('Start'), findsOneWidget); + await tester.tap(find.text('Start')); + await tester.pump(); + expect(find.text('Pause'), findsOneWidget); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1100)), + ); + await tester.tap(find.text('Pause')); + await tester.pump(); + expect(find.text('00:00:01'), findsOneWidget); + expect(find.text('Resume'), findsOneWidget); + await tester.tap(find.text('Reset')); + await tester.pump(); + expect(find.text('Start'), findsOneWidget); + expect(find.text('00:00:00'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('窄横屏计时栏不会挤出评分区域', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox( + width: 900, + height: 78, + child: ChiefStopwatchPanel(compact: true), + ), + ), + ), + ); + expect(find.text('开始计时'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/task_queue_policy_test.dart b/test/task_queue_policy_test.dart index bb9d292..ee8fd8b 100644 --- a/test/task_queue_policy_test.dart +++ b/test/task_queue_policy_test.dart @@ -13,12 +13,49 @@ void main() { expect(values, ['next-done', 'next-1', 'next-2']); }); - test('队列中只有第一个未完成任务可打分', () { + test('队列首个未完成任务只用于推荐下一场', () { expect(TaskQueuePolicy.activeIndex([true, false, false]), 1); expect(TaskQueuePolicy.activeIndex([false, false]), 0); expect(TaskQueuePolicy.activeIndex([true, true]), -1); }); + test('同一赛事可任意选择待评分场次,未确认提交仍需先处理', () { + for (final index in [0, 1, 2]) { + expect( + TaskQueuePolicy.canOpenTask( + index: index, + pendingSubmissionIndex: -1, + opening: false, + ), + isTrue, + ); + } + expect( + TaskQueuePolicy.canOpenTask( + index: 2, + pendingSubmissionIndex: 1, + opening: false, + ), + isFalse, + ); + expect( + TaskQueuePolicy.canOpenTask( + index: 1, + pendingSubmissionIndex: 1, + opening: false, + ), + isTrue, + ); + expect( + TaskQueuePolicy.canOpenTask( + index: 1, + pendingSubmissionIndex: -1, + opening: true, + ), + isFalse, + ); + }); + test('秩序册全局顺序优先于组别场次顺序', () { final values = TaskQueuePolicy.nearestPendingEvent([ _candidate('A-1', 'event', 0, 1, false, 9, scheduleOrder: 1),