feat: 优化裁判任务分页与连续评分流程
任务列表改为按页加载和按账号缓存,增加右侧场次范围分页栏、10/20/50 条切换、相邻页预取及返回列表即时恢复。 通过按出场 ID 查询和服务端下一任务定位,避免跨页逐场加载;下一场严格沿编排顺序向后查找,修正任务独立处理。 主裁失误次数可继续累计但扣分系数封顶;个人花样规定元素由难度等级自动统计并禁止手改。补充接口模型、英文文案及对应测试。 依赖后台新增 /api/judge-scoring/tasks 分页、单项及 next 接口;仅本地提交,不推送远程。
This commit is contained in:
parent
9ad8f955e0
commit
926d549f15
@ -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',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
() {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user