Compare commits

..

2 Commits

Author SHA1 Message Date
c22c788cfa feat(ui): 判题结果页新增输出对比视图
- 新增 parse_diff_blocks 系列函数,将 runtimeinfo 解析为结构化的测试点对比块,支持 simple 表格和 diff -y 两种格式
- 在判题结果页插入"输出对比"区块,逐测试点并列展示期望输出与用户输出,差异行高亮
- 补充 diff-section 相关样式,移动端切换为单列布局
2026-07-08 21:13:13 +08:00
90010113dd feat(ui): 判题结果页 AI 点评区支持折叠
- 为 LLM 点评标题增加点击折叠/展开交互,减少页面占用空间
- 新增标题悬停反馈与图标旋转动画样式
- 移除快捷操作区中的"下载测试数据"按钮入口
2026-07-08 21:07:56 +08:00
2 changed files with 314 additions and 19 deletions

View File

@@ -203,6 +203,9 @@ if($ok){
$view_reinfo = $view_reinfo_summary;
// 尝试解析为结构化的 diff 数据(用于 WA/PE/AC 时的对比展示)
$diff_blocks = parse_diff_blocks($raw_error);
// 管理员或 source_browser 永远看完整原文
$show_raw = $is_admin_session || $is_source_browser;
if ($show_raw) {
@@ -212,6 +215,111 @@ if($ok){
}
}
/**
* 把 runtimeinfo.error 解析为结构化的 diff blocks
* 返回 [['name' => 'test1.out', 'expected' => [...], 'yours' => [...], 'full_mode' => bool], ...]
* 解析失败返回空数组
*/
function parse_diff_blocks($text) {
$blocks = array();
if (empty($text)) return $blocks;
// 先按 "\n\n" 切分simple mode 每个测试点之间有空行)
// 但要避免切分 full mode 里的内容
$chunks = preg_split('/\n\s*\n/', $text);
foreach ($chunks as $chunk) {
$chunk = trim($chunk);
if ($chunk === '') continue;
// full mode: 形如 "========[test1.out]=========" 开头
if (preg_match('/^=+\[([^\]]+)\]=+$/m', $chunk, $m_head)) {
$name = $m_head[1];
// 提取 "------diff out 200 lines-----" 后面的内容
if (preg_match('/------diff out[^\n]*-+\n(.*?)\n=+\s*$/s', $chunk, $m_diff)) {
$diff_text = trim($m_diff[1]);
} else {
continue; // 没找到 diff 段,跳过
}
$lines = parse_diff_side_by_side($diff_text);
if (!empty($lines)) {
$blocks[] = array(
'name' => $name,
'expected' => $lines['expected'],
'yours' => $lines['yours'],
'full_mode' => true,
);
}
continue;
}
// simple mode: 形如 "test1.out\n--\n|Expected|Yours\n|--|--\n|..."
if (preg_match('/^([^\n|]+)\n--\n\|Expected\|Yours\n\|--\|--\n(.*)$/s', $chunk, $m)) {
$name = trim($m[1]);
$table = $m[2];
$lines = parse_pipe_diff_table($table);
if (!empty($lines)) {
$blocks[] = array(
'name' => $name,
'expected' => $lines['expected'],
'yours' => $lines['yours'],
'full_mode' => false,
);
}
}
}
return $blocks;
}
/**
* 解析 "|expected|yours\n" 格式的表格
* 已知边角:每行以 | 开头和 | 分隔;空字段表示没有该行
*/
function parse_pipe_diff_table($text) {
$expected = array();
$yours = array();
$lines = explode("\n", $text);
foreach ($lines as $line) {
if (strlen($line) < 2 || $line[0] !== '|') continue;
// 形如 "|a|b" 拆出 a 和 b
$rest = substr($line, 1);
// 找第一个 |
$pos = strpos($rest, '|');
if ($pos === false) continue;
$left = substr($rest, 0, $pos);
$right = substr($rest, $pos + 1);
$expected[] = $left;
$yours[] = $right;
}
if (empty($expected)) return array();
return array('expected' => $expected, 'yours' => $yours);
}
/**
* 解析 `diff -y` 格式: " < expected > yours"
* 中间用 | 或 / 分隔(取决于 GNU diff 版本)
*/
function parse_diff_side_by_side($text) {
$expected = array();
$yours = array();
$lines = explode("\n", $text);
foreach ($lines as $line) {
// 找中间的 | 或 / GNU diff -y 格式)
// 形如 " line1 | line2 " 或 " line1 < line2 "
if (preg_match('/^(.*?)\s+[<>|]\s+(.*)$/', $line, $m)) {
$expected[] = $m[1];
$yours[] = $m[2];
} elseif (trim($line) === '') {
// 空行:两边都空
} else {
// 单边(一边空):整行属于另一边
$expected[] = $line;
$yours[] = '';
}
}
if (empty($expected)) return array();
return array('expected' => $expected, 'yours' => $yours);
}
// 不是本人的提交,且不是 source_browser
else{
if($spj[0][0]!=2) $view_errors = $MSG_WARNING_ACCESS_DENIED;

View File

@@ -101,6 +101,20 @@
#llm-section .ui.header {
margin-top: 0;
}
#llm-section .llm-section-header {
transition: background-color 0.15s;
padding: 4px 8px;
margin-left: -8px;
margin-right: -8px;
border-radius: 4px;
}
#llm-section .llm-section-header:hover {
background-color: rgba(33,150,243,0.06);
}
#llm-section .llm-toggle-icon {
transition: transform 0.2s;
color: #888;
}
#llm-review-result {
word-break: break-word;
}
@@ -119,6 +133,99 @@ i.icon.spinning {
color: #2185d0;
font-weight: 600;
}
/* Diff 对比样式 */
#diff-section {
margin-bottom: 18px;
}
.diff-card {
margin-bottom: 14px;
border-left: 4px solid #fbbd08;
padding: 12px 16px;
}
.diff-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
font-weight: 600;
color: #555;
}
.diff-name {
font-family: 'Fira Mono', 'Cascadia Code', Consolas, monospace;
font-size: 0.95em;
}
.diff-table {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 768px) {
.diff-table { grid-template-columns: 1fr; }
}
.diff-side {
background: #fafafa;
border-radius: 6px;
overflow: hidden;
border: 1px solid #e0e0e0;
}
.diff-side-header {
padding: 6px 10px;
font-size: 0.85em;
font-weight: 600;
color: #fff;
}
.diff-expected .diff-side-header {
background: #21ba45;
}
.diff-yours .diff-side-header {
background: #db2828;
}
.diff-content {
margin: 0 !important;
padding: 8px 0 !important;
background: #fff !important;
font-family: 'Fira Mono', 'Cascadia Code', Consolas, monospace;
font-size: 0.85em;
line-height: 1.5;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
.diff-line {
display: flex;
align-items: flex-start;
padding: 0;
white-space: pre;
}
.diff-line-mismatch {
background: rgba(255, 230, 100, 0.25);
}
.diff-expected .diff-line-mismatch {
background: rgba(33, 186, 69, 0.12);
}
.diff-yours .diff-line-mismatch {
background: rgba(219, 40, 40, 0.10);
}
.diff-lineno {
display: inline-block;
min-width: 38px;
padding: 0 8px;
text-align: right;
color: #999;
background: #f5f5f5;
border-right: 1px solid #e8e8e8;
user-select: none;
flex-shrink: 0;
}
.diff-text {
padding: 0 10px;
flex: 1;
word-break: break-all;
white-space: pre-wrap;
}
</style>
<div class="padding">
@@ -129,6 +236,8 @@ i.icon.spinning {
$verdict_memory = isset($row['memory']) ? intval($row['memory']) : 0;
$verdict_time = isset($row['time']) ? intval($row['time']) : 0;
$verdict_mark = isset($mark) ? $mark : (isset($row['pass_rate']) ? round($row['pass_rate']*100, 1) : 0);
// 兜底:防止 PHP notice
$diff_blocks = isset($diff_blocks) && is_array($diff_blocks) ? $diff_blocks : array();
?>
<!-- 1. 顶部判题结果横幅 -->
@@ -157,33 +266,58 @@ i.icon.spinning {
</div>
<!-- 3. AI 点评区(直接显示按钮,提到原始错误上面 -->
<!-- 3. AI 点评区(标题可点击折叠/展开 -->
<?php if (isset($OJ_LLM_ENABLED) && $OJ_LLM_ENABLED) { ?>
<div class="ui raised segment" id="llm-section">
<h3 class="ui header">
<h3 class="ui header llm-section-header" id="llm-toggle" style="cursor: pointer; user-select: none;">
<i class="magic icon"></i>
<div class="content">
AI 智能点评
<div class="sub header"><?php echo isset($MSG_AI_REVIEW_DESC) ? $MSG_AI_REVIEW_DESC : '基于你的代码和判题结果分析'; ?></div>
</div>
<i class="dropdown icon llm-toggle-icon" style="margin-left: auto; font-size: 1em;"></i>
</h3>
<button class="ui labeled icon primary button" id="llm-review-btn" onclick="fetchLLMReview()">
<i class="magic icon"></i> 获取 AI 点评
</button>
<div id="llm-review-loading" class="ui active inline text loader" style="display:none; margin-left: 1em;">
AI 正在分析你的代码,请稍候...
</div>
<div id="llm-review-result" style="display:none; margin-top: 15px;">
<div class="ui existing segment" id="llm-review-content"></div>
</div>
<div id="llm-review-error" style="display:none; margin-top: 15px;">
<div class="ui negative message">
<p id="llm-review-error-msg"></p>
<div id="llm-section-body">
<button class="ui labeled icon primary button" id="llm-review-btn" onclick="fetchLLMReview()">
<i class="magic icon"></i> 获取 AI 点评
</button>
<div id="llm-review-loading" class="ui active inline text loader" style="display:none; margin-left: 1em;">
AI 正在分析你的代码,请稍候...
</div>
<div id="llm-review-result" style="display:none; margin-top: 15px;">
<div class="ui existing segment" id="llm-review-content"></div>
</div>
<div id="llm-review-error" style="display:none; margin-top: 15px;">
<div class="ui negative message">
<p id="llm-review-error-msg"></p>
</div>
</div>
</div>
</div>
<script>
// 折叠/展开 AI 点评区
(function(){
var header = document.getElementById('llm-toggle');
var body = document.getElementById('llm-section-body');
var icon = header ? header.querySelector('.llm-toggle-icon') : null;
if (!header || !body) return;
header.addEventListener('click', function(e){
// 如果用户点击的是按钮本身,不要触发展开(按钮事件自己处理)
if (e.target.closest('button')) return;
var collapsed = body.style.display === 'none';
body.style.display = collapsed ? '' : 'none';
if (icon) {
icon.className = collapsed
? 'dropdown icon llm-toggle-icon'
: 'dropdown icon llm-toggle-icon active';
// active class 让 Semantic UI 的箭头自动旋转 90°但我们手写也行
icon.style.transform = collapsed ? 'rotate(0deg)' : 'rotate(-90deg)';
}
});
})();
function fetchLLMReview(){
var btn = $('#llm-review-btn');
btn.prop('disabled', true).addClass('loading');
@@ -291,6 +425,63 @@ i.icon.spinning {
</script>
<?php } ?>
<!-- 3.5. 对比详情WA/PE 时显示diff 视图) -->
<?php if (!empty($diff_blocks) && is_array($diff_blocks)): ?>
<div id="diff-section">
<h3 class="ui header" style="margin-top: 0;">
<i class="exchange icon"></i>
<div class="content">
输出对比
<div class="sub header">逐测试点对比期望输出和你的输出</div>
</div>
</h3>
<?php foreach ($diff_blocks as $idx => $block): ?>
<div class="ui segment diff-card">
<div class="diff-header">
<i class="file outline icon"></i>
<span class="diff-name"><?php echo htmlspecialchars($block['name'], ENT_QUOTES, 'UTF-8'); ?></span>
</div>
<div class="diff-table">
<div class="diff-side diff-expected">
<div class="diff-side-header">
<i class="check icon"></i> 期望输出
</div>
<pre class="diff-content"><?php
$max = max(count($block['expected']), count($block['yours']));
$lines_html = array();
for ($li = 0; $li < $max; $li++) {
$e = isset($block['expected'][$li]) ? $block['expected'][$li] : '';
$y = isset($block['yours'][$li]) ? $block['yours'][$li] : '';
$diff_class = ($e !== $y) ? ' diff-line-mismatch' : '';
$line_no = $li + 1;
$lines_html[] = '<div class="diff-line' . $diff_class . '" data-line="' . $line_no . '"><span class="diff-lineno">' . $line_no . '</span><span class="diff-text">' . htmlspecialchars($e, ENT_QUOTES, 'UTF-8') . '</span></div>';
}
echo implode('', $lines_html);
?></pre>
</div>
<div class="diff-side diff-yours">
<div class="diff-side-header">
<i class="bug icon"></i> 你的输出
</div>
<pre class="diff-content"><?php
$max = max(count($block['expected']), count($block['yours']));
$lines_html = array();
for ($li = 0; $li < $max; $li++) {
$e = isset($block['expected'][$li]) ? $block['expected'][$li] : '';
$y = isset($block['yours'][$li]) ? $block['yours'][$li] : '';
$diff_class = ($e !== $y) ? ' diff-line-mismatch' : '';
$line_no = $li + 1;
$lines_html[] = '<div class="diff-line' . $diff_class . '" data-line="' . $line_no . '"><span class="diff-lineno">' . $line_no . '</span><span class="diff-text">' . htmlspecialchars($y, ENT_QUOTES, 'UTF-8') . '</span></div>';
}
echo implode('', $lines_html);
?></pre>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- 4. 原始错误折叠(默认收起) -->
<div class="ui styled accordion" id="raw-info-accordion">
<div class="title">
@@ -338,11 +529,7 @@ i.icon.spinning {
<a class="ui button" href="problem.php?id=<?php echo intval($row['problem_id']) ?>">
<i class="info circle icon"></i> <?php echo isset($MSG_VIEW_PROBLEM) ? $MSG_VIEW_PROBLEM : '查看题目'; ?>
</a>
<?php if (isset($OJ_DOWNLOAD) && $OJ_DOWNLOAD && ($isRE || $row['result']==6)) { ?>
<a class="ui orange button" href="download.php?sid=<?php echo intval($id) ?>">
<i class="download icon"></i> <?php echo isset($MSG_DOWNLOAD_DATA) ? $MSG_DOWNLOAD_DATA : '下载测试数据'; ?>
</a>
<?php } ?>
</div>
</div>