Browse Source

Submission detail UI update! Pretty nice!

ryp 2 weeks ago
parent
commit
2b1fa930b2

+ 6 - 2
app/controllers/submission.php

@@ -183,10 +183,10 @@
 <?php if ($should_show_content): ?>
 	<?php
 		if ($submission['problem_id'] == 260328) {
-			echoSubmissionContent($submission, json_decode('[{"name":"answer","type":"source code","file_name":"answer.code"}]', true));
+			echoSubmissionContent($submission, json_decode('[{"name":"answer","type":"source code","file_name":"answer.code"}]', true), array('collapsible' => true));
 		}
 		else
-			echoSubmissionContent($submission, getProblemSubmissionRequirement($problem));
+			echoSubmissionContent($submission, getProblemSubmissionRequirement($problem), array('collapsible' => true));
 	?>
 	<?php if ($hackable): ?>
 		<p class="text-center">
@@ -211,10 +211,14 @@
 			<h4 class="card-title"><?= UOJLocale::get('details') ?></h4>
 		</div>
 		<div class="card-body">
+			<?php if (isset($submission_result['final_result'])): ?>
+				<h6 class="text-muted"><?= UOJLocale::get('pretest') ?></h6>
+			<?php endif ?>
 			<?php echoJudgementDetails($submission_result['details'], $styler, 'details') ?>
 			<?php if ($should_show_details_to_me): ?>
 				<?php if (isset($submission_result['final_result'])): ?>
 					<hr />
+					<h6 class="text-muted"><?= UOJLocale::get('final test') ?></h6>
 					<?php echoSubmissionDetails($submission_result['final_result']['details'], 'final_details') ?>
 				<?php endif ?>
 				<?php if ($styler->fade_all_details): ?>

+ 373 - 179
app/libs/uoj-html-lib.php

@@ -641,7 +641,10 @@ HTML;
 	$zip_file->close();
 }
 
-function echoSubmissionContent($submission, $requirement) {
+function echoSubmissionContent($submission, $requirement, $options = array()) {
+	static $uoj_submission_content_seq = 0;
+	$collapsible = isset($options['collapsible']) && $options['collapsible'];
+
 	$zip_file = new ZipArchive();
 	$submission_content = json_decode($submission['content'], true);
 	$zip_file->open(UOJContext::storagePath().$submission_content['file_name']);
@@ -686,7 +689,31 @@ function echoSubmissionContent($submission, $requirement) {
 			echo '<h4 class="card-title">'.$req['name'].'</h4>';
 			echo '</div>';
 			echo '<div class="card-body">';
-			echo '<pre><code class="'.$sh_class.'">'.$file_content."\n".'</code></pre>';
+			if ($collapsible) {
+				$uoj_submission_content_seq++;
+				$fold_id = 'uoj-code-'.$submission['id'].'-'.$uoj_submission_content_seq;
+				echo '<button type="button" class="btn btn-sm btn-outline-secondary uoj-code-toggle" id="', $fold_id, '_toggle" aria-haspopup="dialog">', '查看源代码', '</button>';
+				echo '<div class="uoj-modal" id="', $fold_id, '_modal" hidden>';
+				echo '<div class="uoj-modal-backdrop" data-uoj-modal-close></div>';
+				echo '<div class="uoj-modal-window" role="dialog" aria-modal="true" aria-label="', '源代码', '">';
+				echo '<button type="button" class="uoj-modal-close" data-uoj-modal-close aria-label="', '关闭', '">×</button>';
+				echo '<div class="uoj-modal-body">';
+				echo '<div class="text-muted uoj-code-meta">'.$footer_text.'</div>';
+				echo '<pre><code class="'.$sh_class.'">'.$file_content."\n".'</code></pre>';
+				echo '</div>';
+				echo '</div>';
+				echo '</div>';
+				echoUOJModalHelper();
+				echo '<script type="text/javascript">';
+				echo '(function(){var b=document.getElementById(', json_encode($fold_id.'_toggle'), '),m=document.getElementById(', json_encode($fold_id.'_modal'), ');';
+				echo 'if(!b||!m||!window.uojModalInit){return;}';
+				echo 'var api=window.uojModalInit(m);';
+				echo 'b.addEventListener("click",function(){api.open();});';
+				echo '})();';
+				echo '</script>';
+			} else {
+				echo '<pre><code class="'.$sh_class.'">'.$file_content."\n".'</code></pre>';
+			}
 			echo '</div>';
 			echo '<div class="card-footer">'.$footer_text.'</div>';
 			echo '</div>';
@@ -710,14 +737,90 @@ function echoSubmissionContent($submission, $requirement) {
 	$zip_file->close();
 }
 
+// Shared vanilla-JS modal helper. Emitted at most once per request, and it is
+// also guarded in JS, so both the test-detail modal and the source-code modal
+// can use it independently (a page with only one of them still works).
+function echoUOJModalHelper() {
+	static $emitted = false;
+	if ($emitted) {
+		return;
+	}
+	$emitted = true;
+	echo <<<'UOJMODAL'
+<script type="text/javascript">
+if (typeof window.uojModalInit !== 'function') {
+	window.uojModalInit = function (modal) {
+		var win = modal.querySelector('.uoj-modal-window');
+		var api = {
+			lastFocus: null,
+			body: modal.querySelector('.uoj-modal-body'),
+			open: function () {
+				if (!modal.hidden) {
+					return;
+				}
+				if (window.uojModalCurrent && window.uojModalCurrent !== api) {
+					window.uojModalCurrent.close();
+				}
+				api.lastFocus = document.activeElement;
+				modal.hidden = false;
+				document.body.classList.add('uoj-modal-open');
+				window.uojModalCurrent = api;
+				var btn = modal.querySelector('.uoj-modal-close');
+				if (btn && btn.focus) {
+					btn.focus();
+				}
+			},
+			close: function () {
+				if (modal.hidden) {
+					return;
+				}
+				modal.hidden = true;
+				if (window.uojModalCurrent === api) {
+					window.uojModalCurrent = null;
+				}
+				if (!window.uojModalCurrent) {
+					document.body.classList.remove('uoj-modal-open');
+				}
+				if (api.lastFocus && api.lastFocus.focus) {
+					api.lastFocus.focus();
+				}
+			}
+		};
+		modal.addEventListener('click', function (e) {
+			if (e.target === modal || (e.target.getAttribute && e.target.getAttribute('data-uoj-modal-close') !== null)) {
+				api.close();
+			}
+		});
+		document.addEventListener('keydown', function (e) {
+			if ((e.key === 'Escape' || e.keyCode === 27) && !modal.hidden) {
+				api.close();
+			}
+		});
+		return api;
+	};
+}
+</script>
+UOJMODAL;
+}
+
 
 class JudgementDetailsPrinter {
 	private $name;
 	private $styler;
 	private $dom;
-	
+
 	private $subtask_num;
 
+	private $prefix;
+	private $blocks = array();
+	private $pending_tests = array();
+	private $bodies = array();
+	private $body_seq = 0;
+	private $rect_seq = 0;
+	private $auto_select_id = null;
+
+	private static $instance_seq = 0;
+
 	private function _print_c($node) {
 		foreach ($node->childNodes as $child) {
 			if ($child->nodeName == '#text') {
@@ -732,205 +835,287 @@ class JudgementDetailsPrinter {
 			echo "<pre>\n";
 			$this->_print_c($node);
 			echo "\n</pre>";
-		} elseif ($node->nodeName == 'tests') {
-			echo '<div id="', $this->name, '_details_accordion">';
-			if ($this->styler->show_small_tip) {
-				echo '<div class="text-right text-muted">', '小提示:点击横条可展开更详细的信息', '</div>';
-			} elseif ($this->styler->ioi_contest_is_running) {
-				echo '<div class="text-right text-muted">', 'IOI 赛制比赛中不支持显示详细信息', '</div>';
-			}
+		} elseif ($node->nodeName == 'in') {
+			echo "<h4>标准输入:</h4><pre>\n";
 			$this->_print_c($node);
-			echo '</div>';
-		} elseif ($node->nodeName == 'subtask') {
-			$subtask_num = $node->getAttribute('num');
-			$subtask_score = $node->getAttribute('score');
-			$subtask_info = $node->getAttribute('info');
-			
-			echo '<div class="card ', $this->styler->getTestInfoClass($subtask_info), ' mb-3">';
-			
-			$accordion_parent = "{$this->name}_details_accordion";
-			$accordion_collapse =  "{$accordion_parent}_collapse_subtask_{$subtask_num}";
-			$accordion_collapse_accordion =  "{$accordion_collapse}_accordion";
-			echo 	'<div class="card-header" data-toggle="collapse" data-parent="#', $accordion_parent, '" data-target="#', $accordion_collapse, '">';
-			
-			echo 		'<div class="row">';
-			echo 			'<div class="col-sm-4">';
-			echo 				'<h3 class="card-title">', 'Subtask #', $subtask_num, ': ', '</h3>';
-			echo 			'</div>';
-			
-			if ($this->styler->show_score) {
-				echo 		'<div class="col-sm-4">';
-				echo 			'得分:', $subtask_score;
-				echo 		'</div>';
-				echo 		'<div class="col-sm-4">';
-				echo 			htmlspecialchars($subtask_info);
-				echo 		'</div>';
-			} else {
-				echo 		'<div class="col-sm-8">';
-				echo 			htmlspecialchars($subtask_info);
-				echo 		'</div>';
+			echo "\n</pre>";
+		} elseif ($node->nodeName == 'out') {
+			echo "<h4>标准输出:</h4><pre>\n";
+			$this->_print_c($node);
+			echo "\n</pre>";
+		} elseif ($node->nodeName == 'res') {
+			echo "<h4>检验结果:</h4><pre>\n";
+			$this->_print_c($node);
+			echo "\n</pre>";
+		} else {
+			echo '<', $node->nodeName;
+			foreach ($node->attributes as $attr) {
+				echo ' ', $attr->name, '="', htmlspecialchars($attr->value), '"';
 			}
-
-			echo 		'</div>';
-			echo 	'</div>';
-			
-			echo 	'<div id="', $accordion_collapse, '" class="card-collapse collapse">';
-			echo 		'<div class="card-body">';
-
-			echo 			'<div id="', $accordion_collapse_accordion, '">';
-			$this->subtask_num = $subtask_num;
+			echo '>';
 			$this->_print_c($node);
-			$this->subtask_num = null;
-			echo 			'</div>';
+			echo '</', $node->nodeName, '>';
+		}
+	}
 
-			echo 		'</div>';
-			echo 	'</div>';
-			echo '</div>';
-		} elseif ($node->nodeName == 'test') {
-			$test_info = $node->getAttribute('info');
-			$test_num = $node->getAttribute('num');
-			$test_score = $node->getAttribute('score');
-			$test_time = $node->getAttribute('time');
-			$test_memory = $node->getAttribute('memory');
-
-			echo '<div class="card ', $this->styler->getTestInfoClass($test_info), ' mb-3">';
-			
-			$accordion_parent = "{$this->name}_details_accordion";
-			if ($this->subtask_num != null) {
-				$accordion_parent .= "_collapse_subtask_{$this->subtask_num}_accordion";
+	private function getVerdictAbbr($info) {
+		$info = trim($info);
+		$map = array(
+			'Accepted' => 'AC',
+			'Extra Test Passed' => 'AC',
+			'Success' => 'AC',
+			'Acceptable Answer' => 'AC',
+			'Wrong Answer' => 'WA',
+			'Time Limit Exceeded' => 'TLE',
+			'Memory Limit Exceeded' => 'MLE',
+			'Runtime Error' => 'RE',
+			'Output Limit Exceeded' => 'OLE',
+			'Dangerous Syscalls' => 'DGS',
+			'Judgement Failed' => 'JGF',
+			'No Comment' => '?',
+		);
+		if ($info === '') {
+			return '?';
+		}
+		if (isset($map[$info])) {
+			return $map[$info];
+		}
+		$words = preg_split('/\s+/', $info, -1, PREG_SPLIT_NO_EMPTY);
+		if (count($words) > 1) {
+			$abbr = '';
+			foreach ($words as $word) {
+				$abbr .= strtoupper(substr($word, 0, 1));
+				if (strlen($abbr) >= 4) {
+					break;
+				}
 			}
-			$accordion_collapse = "{$accordion_parent}_collapse_test_{$test_num}";
-			if (!$this->styler->shouldFadeDetails($test_info)) {
-				echo '<div class="card-header" data-toggle="collapse" data-parent="#', $accordion_parent, '" data-target="#', $accordion_collapse, '">';
-			} else {
-				echo '<div class="card-header">';
+			if ($abbr !== '') {
+				return substr($abbr, 0, 4);
 			}
-			echo '<div class="row">';
-			echo '<div class="col-sm-4">';
-			if ($test_num > 0) {
-				echo '<h4 class="card-title">', 'Test #', $test_num, ': ', '</h4>';
-			} else {
-				echo '<h4 class="card-title">', 'Extra Test:', '</h4>';
+		}
+		if (function_exists('mb_substr')) {
+			return strtoupper(mb_substr($info, 0, 4, 'UTF-8'));
+		}
+		return strtoupper(substr($info, 0, 4));
+	}
+	private function getVerdictClass($info) {
+		$info = trim($info);
+		if ($info == 'Accepted' || $info == 'Extra Test Passed' || $info == 'Success' || $info == 'Acceptable Answer') {
+			return 'uoj-test-rect-ac';
+		}
+		if ($info == 'Wrong Answer') {
+			return 'uoj-test-rect-wa';
+		}
+		return 'uoj-test-rect-other';
+	}
+
+	private function collectBlocks($node) {
+		foreach ($node->childNodes as $child) {
+			if ($child->nodeType != XML_ELEMENT_NODE) {
+				continue;
 			}
-			echo '</div>';
-				
-			if ($this->styler->show_score) {
-				echo '<div class="col-sm-4">';
-				echo '得分:', $test_score;
-				echo '</div>';
-				echo '<div class="col-sm-4">';
-				echo htmlspecialchars($test_info);
-				echo '</div>';
+			if ($child->nodeName == 'subtask') {
+				$this->flushPendingTests();
+				$this->blocks[] = array('type' => 'subtask', 'node' => $child);
+			} elseif ($child->nodeName == 'test' || $child->nodeName == 'custom-test') {
+				$this->pending_tests[] = $child;
+			} elseif ($child->nodeName == 'error') {
+				$this->flushPendingTests();
+				$this->blocks[] = array('type' => 'error', 'node' => $child);
 			} else {
-				echo '<div class="col-sm-8">';
-				echo htmlspecialchars($test_info);
-				echo '</div>';
+				$this->collectBlocks($child);
 			}
-				
-			echo '<div class="col-sm-4">';
-			if ($test_time >= 0) {
-				echo '时间:', $test_time, 'ms';
-			}
-			echo '</div>';
+		}
+	}
+	private function flushPendingTests() {
+		if (count($this->pending_tests)) {
+			$this->blocks[] = array('type' => 'tests', 'nodes' => $this->pending_tests);
+			$this->pending_tests = array();
+		}
+	}
+	private function collectRoot($node) {
+		if ($node->nodeName == 'subtask') {
+			$this->blocks[] = array('type' => 'subtask', 'node' => $node);
+		} elseif ($node->nodeName == 'test' || $node->nodeName == 'custom-test') {
+			$this->pending_tests[] = $node;
+		} elseif ($node->nodeName == 'error') {
+			$this->blocks[] = array('type' => 'error', 'node' => $node);
+		} else {
+			$this->collectBlocks($node);
+		}
+	}
 
-			echo '<div class="col-sm-4">';
-			if ($test_memory >= 0) {
-				echo '内存:', $test_memory, 'kb';
-			}
-			echo '</div>';
+	private function renderRect($node) {
+		$info = trim($node->getAttribute('info'));
+		$num = $node->getAttribute('num');
+		$time = $node->getAttribute('time');
+		$memory = $node->getAttribute('memory');
 
-			echo '</div>';
-			echo '</div>';
+		$this->rect_seq++;
 
-			if (!$this->styler->shouldFadeDetails($test_info)) {
-				$accordion_collapse_class = 'card-collapse collapse';
-				if ($this->styler->collapse_in) {
-					$accordion_collapse_class .= ' in';
-				}
-				echo '<div id="', $accordion_collapse, '" class="', $accordion_collapse_class, '">';
-				echo '<div class="card-body">';
+		if ($num !== '' && is_numeric($num)) {
+			$num_text = ((int)$num > 0) ? ('#' . (int)$num) : 'Extra';
+		} else {
+			$num_text = '#' . $this->rect_seq;
+		}
 
-				$this->_print_c($node);
+		// The verdict abbreviation is always the label. UOJ stores a per-test
+		// point value in score="..." (e.g. 10 on every test of an ACM problem),
+		// so it is NOT a partial-credit signal and must never replace the
+		// verdict (see the subtask header for the per-subtask score instead).
+		$label = $this->getVerdictAbbr($info);
 
-				echo '</div>';
-				echo '</div>';
-			}
+		$meta = array();
+		if ($time !== '' && is_numeric($time) && $time >= 0) {
+			$meta[] = $time . 'ms';
+		}
+		if ($memory !== '' && is_numeric($memory) && $memory >= 0) {
+			$meta[] = $memory . 'kb';
+		}
 
-			echo '</div>';
-		} elseif ($node->nodeName == 'custom-test') {
-			$test_info = $node->getAttribute('info');
-			$test_time = $node->getAttribute('time');
-			$test_memory = $node->getAttribute('memory');
+		$class = $this->getVerdictClass($info);
+		$title = (strlen($num_text) && $num_text[0] === '#') ? trim($num_text . ' ' . $info) : $info;
 
-			echo '<div class="card ', $this->styler->getTestInfoClass($test_info), ' mb-3">';
-			
-			$accordion_parent = "{$this->name}_details_accordion";
-			$accordion_collapse = "{$accordion_parent}_collapse_custom_test";
-			if (!$this->styler->shouldFadeDetails($test_info)) {
-				echo '<div class="card-header" data-toggle="collapse" data-parent="#', $accordion_parent, '" data-target="#', $accordion_collapse, '">';
-			} else {
-				echo '<div class="card-header">';
-			}
-			echo '<div class="row">';
-			echo '<div class="col-sm-2">';
-			echo '<h4 class="card-title">', '自定义测试:', '</h4>';
-			echo '</div>';
-				
-			echo '<div class="col-sm-4">';
-			echo htmlspecialchars($test_info);
-			echo '</div>';
-				
-			echo '<div class="col-sm-3">';
-			if ($test_time >= 0) {
-				echo '时间:', $test_time, 'ms';
+		if ($this->styler->shouldFadeDetails($info)) {
+			echo '<div class="uoj-test-rect ', $class, ' is-disabled" title="', htmlspecialchars($title), '">';
+		} else {
+			$body_id = $this->prefix . '_body_' . (++$this->body_seq);
+			$this->bodies[$body_id] = $node;
+			$auto_attr = '';
+			if ($this->styler->collapse_in && $this->auto_select_id === null) {
+				$this->auto_select_id = $body_id;
+				$auto_attr = ' data-auto="1"';
 			}
-			echo '</div>';
+			echo '<div class="uoj-test-rect ', $class, '" role="button" tabindex="0" data-target="', $body_id, '"', $auto_attr, ' title="', htmlspecialchars($title), '">';
+		}
+		echo '<span class="uoj-test-rect-index">', htmlspecialchars($num_text), '</span>';
+		echo '<span class="uoj-test-rect-verdict">', htmlspecialchars($label), '</span>';
+		foreach ($meta as $meta_part) {
+			echo '<span class="uoj-test-rect-meta">', htmlspecialchars($meta_part), '</span>';
+		}
+		echo '</div>';
+	}
 
-			echo '<div class="col-sm-3">';
-			if ($test_memory >= 0) {
-				echo '空间:', $test_memory, 'kb';
+	private function renderSubtask($node) {
+		$num = $node->getAttribute('num');
+		$score = $node->getAttribute('score');
+		$tests = array();
+		$sum = 0;
+		$has_test_score = false;
+		foreach ($node->childNodes as $child) {
+			if ($child->nodeType != XML_ELEMENT_NODE) {
+				continue;
 			}
-			echo '</div>';
-
-			echo '</div>';
-			echo '</div>';
-
-			if (!$this->styler->shouldFadeDetails($test_info)) {
-				$accordion_collapse_class = 'card-collapse collapse';
-				if ($this->styler->collapse_in) {
-					$accordion_collapse_class .= ' in';
-				}
-				echo '<div id="', $accordion_collapse, '" class="', $accordion_collapse_class, '">';
-				echo '<div class="card-body">';
-
-				$this->_print_c($node);
-
-				echo '</div>';
+			if ($child->nodeName != 'test' && $child->nodeName != 'custom-test') {
+				continue;
+			}
+			$tests[] = $child;
+			$test_score = $child->getAttribute('score');
+			if ($test_score !== '' && is_numeric($test_score)) {
+				$sum += $test_score + 0;
+				$has_test_score = true;
+			}
+		}
+		echo '<div class="uoj-test-group uoj-test-group-subtask">';
+		echo '<div class="uoj-test-subtask-head">Subtask #', htmlspecialchars($num);
+		if ($this->styler->show_score) {
+			if ($score !== '') {
+				echo ' · ', htmlspecialchars($score);
+			}
+			if ($has_test_score) {
+				echo ' / ', htmlspecialchars(rtrim(rtrim(sprintf('%.2f', $sum), '0'), '.'));
+			}
+		}
+		echo '</div>';
+		echo '<div class="uoj-test-rects">';
+		foreach ($tests as $test) {
+			$this->renderRect($test);
+		}
+		echo '</div>';
+		echo '</div>';
+	}
+
+	private function renderGrid() {
+		echo '<div class="uoj-details-wrap" id="', $this->prefix, '_wrap">';
+		// Only once per page: a two-phase submission (pretest + final test) runs
+		// this printer twice, and showing the same tip above both grids was
+		// confusing.  static so it spans every instance in this request.
+		static $tip_shown = false;
+		if (!$tip_shown && $this->styler->show_small_tip) {
+			$tip_shown = true;
+			echo '<div class="text-right text-muted uoj-details-tip">', '小提示:点击方块,在弹出的窗口中查看该测试点的详细信息', '</div>';
+		} elseif (!$tip_shown && !empty($this->styler->ioi_contest_is_running)) {
+			$tip_shown = true;
+			echo '<div class="text-right text-muted uoj-details-tip">', 'IOI 赛制比赛中不支持显示详细信息', '</div>';
+		}
+		echo '<div class="uoj-test-grid">';
+		foreach ($this->blocks as $block) {
+			if ($block['type'] == 'error') {
+				echo '<div class="uoj-details-error">';
+				$this->_print($block['node']);
 				echo '</div>';
-
+			} elseif ($block['type'] == 'subtask') {
+				$this->renderSubtask($block['node']);
+			} else {
+				echo '<div class="uoj-test-group">';
+				foreach ($block['nodes'] as $test) {
+					$this->renderRect($test);
+				}
 				echo '</div>';
 			}
-		} elseif ($node->nodeName == 'in') {
-			echo "<h4>标准输入:</h4><pre>\n";
-			$this->_print_c($node);
-			echo "\n</pre>";
-		} elseif ($node->nodeName == 'out') {
-			echo "<h4>标准输出:</h4><pre>\n";
-			$this->_print_c($node);
-			echo "\n</pre>";
-		} elseif ($node->nodeName == 'res') {
-			echo "<h4>检验结果:</h4><pre>\n";
-			$this->_print_c($node);
-			echo "\n</pre>";
-		} else {
-			echo '<', $node->nodeName;
-			foreach ($node->attributes as $attr) {
-				echo ' ', $attr->name, '="', htmlspecialchars($attr->value), '"';
+		}
+		echo '</div>';
+		if (count($this->bodies)) {
+			echo '<div class="uoj-modal" id="', $this->prefix, '_modal" hidden>';
+			echo '<div class="uoj-modal-backdrop" data-uoj-modal-close></div>';
+			echo '<div class="uoj-modal-window" role="dialog" aria-modal="true" aria-label="', '测试点详细信息', '">';
+			echo '<button type="button" class="uoj-modal-close" data-uoj-modal-close aria-label="', '关闭', '">×</button>';
+			echo '<div class="uoj-modal-body" id="', $this->prefix, '_modal_body">';
+			foreach ($this->bodies as $body_id => $test) {
+				echo '<div class="uoj-test-body" id="', $body_id, '" hidden>';
+				$this->_print_c($test);
+				echo '</div>';
 			}
-			echo '>';
-			$this->_print_c($node);
-			echo '</', $node->nodeName, '>';
+			echo '</div>';
+			echo '</div>';
+			echo '</div>';
 		}
+		echo '</div>';
+	}
+
+	private function renderScript() {
+		if (!count($this->bodies)) {
+			return;
+		}
+		echoUOJModalHelper();
+		$wrap = $this->prefix . '_wrap';
+		$modal = $this->prefix . '_modal';
+		echo '<script type="text/javascript">';
+		echo '(function(){';
+		echo 'var wrap=document.getElementById(', json_encode($wrap), ');';
+		echo 'var modal=document.getElementById(', json_encode($modal), ');';
+		echo 'if(!wrap||!modal||!window.uojModalInit){return;}';
+		echo 'var api=window.uojModalInit(modal);';
+		echo 'var body=api.body;';
+		echo 'var rects=wrap.querySelectorAll(".uoj-test-rect[data-target]");';
+		echo 'function show(rect){';
+		echo 'var target=document.getElementById(rect.getAttribute("data-target"));';
+		echo 'if(!target){return;}';
+		echo 'var bodies=body.querySelectorAll(".uoj-test-body");';
+		echo 'for(var i=0;i<bodies.length;i++){bodies[i].hidden=true;}';
+		echo 'target.hidden=false;';
+		echo 'for(var i=0;i<rects.length;i++){';
+		echo 'if(rects[i]===rect){rects[i].classList.add("is-selected");}else{rects[i].classList.remove("is-selected");}';
+		echo '}';
+		echo '}';
+		echo 'for(var i=0;i<rects.length;i++){(function(rect){';
+		echo 'rect.addEventListener("click",function(){show(rect);api.open();});';
+		echo 'rect.addEventListener("keydown",function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();show(rect);api.open();}});';
+		echo '})(rects[i]);}';
+		echo 'var auto=wrap.querySelector(".uoj-test-rect[data-auto=\"1\"]");';
+		echo 'if(auto){show(auto);api.open();}';
+		echo '})();';
+		echo '</script>';
 	}
 
 	public function __construct($details, $styler, $name) {
@@ -945,7 +1130,16 @@ class JudgementDetailsPrinter {
 	}
 	public function printHTML() {
 		$this->subtask_num = null;
-		$this->_print($this->dom->documentElement);
+		$this->prefix = $this->name . '_' . (++self::$instance_seq);
+		$root = $this->dom->documentElement;
+		if ($root->nodeName == 'error') {
+			$this->_print($root);
+			return;
+		}
+		$this->collectRoot($root);
+		$this->flushPendingTests();
+		$this->renderGrid();
+		$this->renderScript();
 	}
 }
 

+ 2 - 0
app/locale/basic/en.php

@@ -32,6 +32,8 @@ return [
 	'browse' => 'Browse',
 	'score range' => 'Score range',
 	'details' => 'Details',
+	'pretest' => 'Pretest',
+	'final test' => 'Final test',
 	'hours' => function($h) {
 		return "$h ".($h <= 1 ? 'hour' : 'hours');
 	},

+ 2 - 0
app/locale/basic/zh-cn.php

@@ -32,6 +32,8 @@ return [
 	'browse' => '浏览',
 	'score range' => '分数范围',
 	'details' => '详细',
+	'pretest' => '预测试',
+	'final test' => '最终测试',
 	'hours' => function($h) {
 		return "$h 小时";
 	},

+ 1 - 1
app/views/page-header.php

@@ -47,7 +47,7 @@
 		<?= HTML::css_link('/css/bootstrap-glyphicons.min.css?v=2019.5.31') ?>
 
 		<!-- Custom styles for this template -->
-		<?= HTML::css_link('/css/uoj-theme.css?v=3.2223') ?>
+		<?= HTML::css_link('/css/uoj-theme.css?v=3.2226') ?>
 		
 		<!-- jQuery (necessary for Bootstrap\'s JavaScript plugins) -->
 		<?= HTML::js_src('/js/jquery.min.js') ?>

+ 235 - 0
css/uoj-theme.css

@@ -635,4 +635,239 @@ pre {
 	max-height: none;
 }
 
+/* --- submission details: test-point squares + detail/code modal dialog --- */
+.uoj-test-grid {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.uoj-test-group {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+
+.uoj-test-group-subtask {
+	display: block;
+	padding-bottom: 8px;
+	border-bottom: 1px solid #e9ecef;
+}
+
+.uoj-test-group-subtask:last-child {
+	border-bottom: none;
+	padding-bottom: 0;
+}
+
+.uoj-test-subtask-head {
+	font-size: 0.85rem;
+	color: #495057;
+	margin-bottom: 6px;
+}
+
+.uoj-test-rects {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+
+.uoj-test-rect {
+	box-sizing: border-box;
+	width: 96px;
+	height: 96px;
+	padding: 6px 4px;
+	border: 1px solid rgba(0, 0, 0, 0.12);
+	border-radius: 4px;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	gap: 1px;
+	line-height: 1.1;
+	text-align: center;
+	overflow: hidden;
+	cursor: pointer;
+	-webkit-user-select: none;
+	user-select: none;
+}
+
+.uoj-test-rect-index {
+	font-size: 12px;
+	opacity: 0.8;
+	white-space: nowrap;
+}
+
+.uoj-test-rect-verdict {
+	font-size: 18px;
+	font-weight: 700;
+	line-height: 1.1;
+	white-space: nowrap;
+}
+
+.uoj-test-rect-meta {
+	font-size: 10px;
+	opacity: 0.9;
+	white-space: nowrap;
+}
+
+.uoj-test-rect-ac {
+	background-color: #00ee00;
+	border-color: #2bff4f;
+	color: #fff;
+}
+
+.uoj-test-rect-wa {
+	background-color: #dd0000;
+	border-color: #ff0000;
+	color: #fff;
+}
+
+.uoj-test-rect-other {
+	background-color: #e6e61e;
+	border-color: #ffff2b;
+	color: #333;
+}
+
+.uoj-test-rect[data-target]:hover {
+	filter: brightness(1.08);
+}
+
+.uoj-test-rect.is-disabled {
+	cursor: default;
+	opacity: 0.75;
+}
+
+.uoj-test-rect.is-selected {
+	outline: 2px solid #333;
+	outline-offset: 1px;
+}
+
+.uoj-test-rect-ac.is-selected {
+	outline-color: #00ee00;
+}
 
+.uoj-test-rect-wa.is-selected {
+	outline-color: #dd0000;
+}
+
+.uoj-test-rect-other.is-selected {
+	outline-color: #e6e61e;
+}
+
+.uoj-test-body>h4 {
+	font-size: 1rem;
+	margin-top: 8px;
+}
+
+.uoj-details-error>pre {
+	margin-top: 8px;
+}
+
+.uoj-code-toggle {
+	margin-bottom: 2px;
+}
+
+.uoj-code-meta {
+	margin-bottom: 8px;
+	font-size: 0.85rem;
+}
+
+.uoj-modal {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 1060;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	padding: 16px;
+	background-color: rgba(0, 0, 0, 0.5);
+	overflow: auto;
+}
+
+.uoj-modal[hidden] {
+	display: none;
+}
+
+.uoj-modal-backdrop {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+}
+
+.uoj-modal-window {
+	position: relative;
+	z-index: 1;
+	width: min(1100px, 88vw);
+	height: min(900px, 80vh);
+	max-width: 100%;
+	max-height: 100%;
+	display: flex;
+	flex-direction: column;
+	background-color: #fff;
+	border: 1px solid #dee2e6;
+	border-radius: 4px;
+	box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
+	overflow: hidden;
+}
+
+.uoj-modal-body {
+	flex: 1 1 auto;
+	overflow: auto;
+	padding: 12px 14px;
+}
+
+.uoj-modal-close {
+	position: absolute;
+	top: 6px;
+	right: 8px;
+	z-index: 2;
+	width: 30px;
+	height: 30px;
+	padding: 0;
+	border: 0;
+	border-radius: 4px;
+	background-color: transparent;
+	color: #495057;
+	font-size: 22px;
+	line-height: 1;
+	cursor: pointer;
+}
+
+.uoj-modal-close:hover {
+	background-color: #f1f3f5;
+	color: #000;
+}
+
+body.uoj-modal-open {
+	overflow: hidden;
+}
+
+@media (max-width: 480px) {
+	.uoj-test-group,
+	.uoj-test-rects {
+		gap: 6px;
+	}
+
+	.uoj-test-rect {
+		width: 80px;
+		height: 80px;
+		padding: 4px 2px;
+	}
+
+	.uoj-test-rect-index {
+		font-size: 11px;
+	}
+
+	.uoj-test-rect-verdict {
+		font-size: 16px;
+	}
+
+	.uoj-test-rect-meta {
+		font-size: 9px;
+	}
+}