feat: 优化裁判任务分页与连续评分流程

任务列表改为按页加载和按账号缓存,增加右侧场次范围分页栏、10/20/50 条切换、相邻页预取及返回列表即时恢复。

通过按出场 ID 查询和服务端下一任务定位,避免跨页逐场加载;下一场严格沿编排顺序向后查找,修正任务独立处理。

主裁失误次数可继续累计但扣分系数封顶;个人花样规定元素由难度等级自动统计并禁止手改。补充接口模型、英文文案及对应测试。

依赖后台新增 /api/judge-scoring/tasks 分页、单项及 next 接口;仅本地提交,不推送远程。
This commit is contained in:
ltx 2026-10-28 14:38:30 +08:00
parent 9ad8f955e0
commit 926d549f15
14 changed files with 905 additions and 214 deletions

View File

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

View File

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

View File

@ -22,6 +22,14 @@ class TaskQueueCandidate<T> {
}
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<T> orderedValues<T>(Iterable<TaskQueueCandidate<T>> 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<T> nearestPendingEvent<T>(
Iterable<TaskQueueCandidate<T>> 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<bool> submitted,
List<bool>? 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({

View File

@ -102,6 +102,40 @@ class ApiClient {
.toList(growable: false);
}
Future<JudgeTaskPage> getTaskPage({
required int page,
required int pageSize,
}) async => JudgeTaskPage.fromJson(
await _requestMap(
'GET',
'/judge-scoring/tasks',
queryParameters: {'page': page, 'pageSize': pageSize},
),
);
Future<JudgeTaskItem> getTaskByEntry(
String sessionEntryId, {
required int pageSize,
}) async => JudgeTaskItem.fromJson(
await _requestMap(
'GET',
'/judge-scoring/tasks/$sessionEntryId',
queryParameters: {'pageSize': pageSize},
),
);
Future<JudgeTaskItem?> 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<JudgeScoreSheet> getSheet(String sessionEntryId) async {
final data = await _requestMap(
'GET',

View File

@ -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<String, dynamic> json) =>
JudgeTaskPageRange(
page: _int(json['page']),
firstSessionOrder: _int(json['firstSessionOrder']),
lastSessionOrder: _int(json['lastSessionOrder']),
);
Map<String, dynamic> 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<String, dynamic> json) => JudgeTaskItem(
page: _int(json['page']),
session: JudgeSession.fromJson(
(json['session'] as Map).cast<String, dynamic>(),
),
entry: JudgeEntry.fromJson((json['entry'] as Map).cast<String, dynamic>()),
);
Map<String, dynamic> 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<JudgeTaskPageRange> pageRanges;
final List<JudgeTaskItem> items;
factory JudgeTaskPage.fromJson(Map<String, dynamic> 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<String, dynamic>(),
),
)
.toList(growable: false),
items: ((json['items'] as List?) ?? const [])
.map(
(item) =>
JudgeTaskItem.fromJson((item as Map).cast<String, dynamic>()),
)
.toList(growable: false),
);
Map<String, dynamic> 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,

View File

@ -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<JudgeSession>? cachedSessions() =>
_readList(_key('sessions'), JudgeSession.fromJson);
List<JudgeEntry>? 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<String, dynamic>(),
);
} on Object {
return null;
}
}
Future<ApiLoad<JudgeTaskPage>> 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<JudgeTaskItem> loadTaskByEntry(
String sessionEntryId, {
required int pageSize,
}) => _client.getTaskByEntry(sessionEntryId, pageSize: pageSize);
Future<JudgeTaskItem?> loadNextTask(
String afterEntryId, {
required int pageSize,
}) => _client.getNextTask(afterEntryId, pageSize: pageSize);
Future<AuthSession> login({
required String mobile,
required String password,

View File

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

View File

@ -41,6 +41,11 @@ class _HomePageState extends State<HomePage> {
final List<_RemoteTask> _remoteTasks = [];
final List<JudgeSession> _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<HomePage> {
_syncStatusWorker = ever(Get.find<ScoreSyncRepository>().revision, (_) {
if (mounted) setState(() {});
});
_restoreCachedTasks();
_loadTasks();
_loadUnreadMessages();
_messageTimer = Timer.periodic(const Duration(seconds: 30), (_) {
@ -62,6 +68,41 @@ class _HomePageState extends State<HomePage> {
});
}
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<HomePage> {
super.dispose();
}
Future<void> _loadTasks() async {
Future<void> _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<JudgeSession>.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<Object>([sessionsFuture, pageFuture]);
final sessionsResult = loaded[0] as ApiLoad<List<JudgeSession>>;
final pageResult = loaded[1] as ApiLoad<JudgeTaskPage>;
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<HomePage> {
}
}
Future<void> _prefetchNextPage() async {
final next = _page + 1;
if (next > (_taskPage?.totalPages ?? 0)) return;
await _prefetchPage(next);
}
Future<void> _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<void> _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<HomePage> {
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<HomePage> {
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<int>(
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<HomePage> {
}) 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<void>.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<ScoringController>()) {
await Get.delete<ScoringController>(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<void>.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<ScoringController>()) {
await Get.delete<ScoringController>(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<HomePage> {
}
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,
),
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<void> _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<JudgeTaskPageRange> ranges;
final ValueChanged<int> onPage;
final ValueChanged<int> 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<int>(
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,

View File

@ -58,8 +58,9 @@ class ScoringController extends GetxController {
final _reviewedRequiredKeys = <String>{};
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(

View File

@ -1239,21 +1239,34 @@ List<Widget> _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);

View File

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

View File

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

View File

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

View File

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