first-update

This commit is contained in:
2026-03-17 14:36:31 +08:00
parent 72f08aee7c
commit 4eddf05e79
516 changed files with 115270 additions and 1 deletions

View File

@@ -0,0 +1,140 @@
'use client';
import { Box, Paper, Typography, Chip, Grid, Divider } from '@mui/material';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import { detailStyles } from '../detailStyles';
import { useTranslation } from 'react-i18next';
import { getModelIcon } from '@/lib/util/modelIcon';
export default function EvalHeader({ task, stats, filterCorrect, onFilterCorrectSelect }) {
const { t } = useTranslation();
if (!task) return null;
const { modelInfo, createAt, status, detail } = task;
const score = detail?.finalScore || 0;
const isPass = score >= 60;
const totalTime = task.endTime ? Math.floor((new Date(task.endTime) - new Date(task.createAt)) / 1000) : 0;
const incorrectCount = (stats?.totalQuestions || 0) - (stats?.correctCount || 0);
// 获取教师模型信息
const judgeModelId = detail?.judgeModelId;
const judgeProviderId = detail?.judgeProviderId;
const hasJudgeModel = judgeModelId && judgeProviderId;
return (
<Paper sx={detailStyles.headerCard}>
<Box sx={detailStyles.headerContent}>
{/* 左侧:模型信息 */}
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
<Box
sx={{
width: 60,
height: 60,
borderRadius: 2,
bgcolor: 'transparent',
border: '2px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<img
src={getModelIcon(modelInfo?.modelName || modelInfo?.modelId)}
alt={modelInfo?.modelId || 'model'}
style={{ width: 44, height: 44, objectFit: 'contain' }}
/>
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>
{modelInfo?.providerName || modelInfo?.providerId} / {modelInfo?.modelName || modelInfo?.modelId}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary', flexWrap: 'wrap' }}>
{hasJudgeModel && (
<Chip
label={`${t('evalTasks.judgeModel')}: ${judgeProviderId} / ${judgeModelId}`}
size="small"
variant="outlined"
color="secondary"
sx={{ borderRadius: 1 }}
/>
)}
<Box sx={{ display: 'flex', alignItems: 'center', fontSize: '0.875rem' }}>
<AccessTimeIcon sx={{ fontSize: 16, mr: 0.5 }} />
{new Date(createAt).toLocaleString()}
{totalTime > 0 && ` ${t('evalTasks.durationFormat', { time: totalTime })}`}
</Box>
</Box>
</Box>
</Box>
{/* 中间:统计概览 (增加点击筛选) */}
<Box sx={{ display: 'flex', gap: 2, mx: 4 }}>
<Box
onClick={() => onFilterCorrectSelect(null)}
sx={{
...detailStyles.statBox,
cursor: 'pointer',
bgcolor: filterCorrect === null ? 'rgba(25, 118, 210, 0.08)' : 'background.default',
border: filterCorrect === null ? '1px solid' : '1px solid transparent',
borderColor: 'primary.main',
transition: 'all 0.2s'
}}
>
<Typography variant="h4" color="primary.main" fontWeight="bold">
{stats?.totalQuestions || 0}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('evalTasks.totalQuestionsLabel')}
</Typography>
</Box>
<Box
onClick={() => onFilterCorrectSelect(true)}
sx={{
...detailStyles.statBox,
cursor: 'pointer',
bgcolor: filterCorrect === true ? 'rgba(46, 125, 50, 0.08)' : 'background.default',
border: filterCorrect === true ? '1px solid' : '1px solid transparent',
borderColor: 'success.main',
transition: 'all 0.2s'
}}
>
<Typography variant="h4" color="success.main" fontWeight="bold">
{stats?.correctCount || 0}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('evalTasks.correctLabel')}
</Typography>
</Box>
<Box
onClick={() => onFilterCorrectSelect(false)}
sx={{
...detailStyles.statBox,
cursor: 'pointer',
bgcolor: filterCorrect === false ? 'rgba(211, 47, 47, 0.08)' : 'background.default',
border: filterCorrect === false ? '1px solid' : '1px solid transparent',
borderColor: 'error.main',
transition: 'all 0.2s'
}}
>
<Typography variant="h4" color="error.main" fontWeight="bold">
{incorrectCount}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('evalTasks.incorrectLabel')}
</Typography>
</Box>
</Box>
{/* 右侧:分数印章 */}
<Box sx={detailStyles.scoreStamp(score, isPass)}>
<Typography sx={detailStyles.scoreValue}>{score.toFixed(1)}</Typography>
<Typography sx={detailStyles.scoreLabel}>SCORE</Typography>
</Box>
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { Box, Grid, Typography, LinearProgress } from '@mui/material';
import { detailStyles } from '../detailStyles';
import { useTranslation } from 'react-i18next';
const QUESTION_TYPE_LABELS = {
true_false: 'eval.questionTypes.true_false',
single_choice: 'eval.questionTypes.single_choice',
multiple_choice: 'eval.questionTypes.multiple_choice',
short_answer: 'eval.questionTypes.short_answer',
open_ended: 'eval.questionTypes.open_ended'
};
export default function EvalStats({ stats, currentFilter, onFilterSelect }) {
const { t } = useTranslation();
if (!stats?.byType || Object.keys(stats.byType).length === 0) return null;
return (
<Box sx={{ mb: 4 }}>
<Grid container spacing={2}>
{Object.entries(stats.byType).map(([type, typeStats]) => {
const accuracy = typeStats.total > 0 ? (typeStats.correct / typeStats.total) * 100 : 0;
const isSelected = currentFilter === type;
return (
<Grid item xs={12} sm={6} md={2.4} key={type}>
<Box
onClick={() => onFilterSelect(isSelected ? null : type)}
sx={{
...detailStyles.typeStatsItem,
cursor: 'pointer',
transition: 'all 0.2s',
bgcolor: isSelected ? 'primary.light' : '#fff',
borderColor: isSelected ? 'primary.main' : '#eee',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
borderColor: 'primary.main'
},
'& *': {
color: isSelected ? 'primary.contrastText' : undefined
}
}}
>
<Typography
sx={{
...detailStyles.typeStatsLabel,
color: isSelected ? 'inherit' : 'text.secondary'
}}
>
{t(QUESTION_TYPE_LABELS[type] || type)}
</Typography>
<Typography
sx={{
...detailStyles.typeStatsScore,
color: isSelected ? 'inherit' : 'text.primary'
}}
>
{typeStats.correct} / {typeStats.total}
</Typography>
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress
variant="determinate"
value={accuracy}
sx={{
flex: 1,
height: 4,
borderRadius: 2,
bgcolor: isSelected ? 'rgba(255,255,255,0.3)' : undefined,
'& .MuiLinearProgress-bar': {
bgcolor: isSelected ? 'white' : undefined
}
}}
color={isSelected ? 'inherit' : accuracy >= 60 ? 'success' : 'error'}
/>
<Typography
sx={{
...detailStyles.typeStatsPercent,
color: isSelected ? 'inherit' : 'text.secondary'
}}
>
{accuracy.toFixed(0)}%
</Typography>
</Box>
</Box>
</Grid>
);
})}
</Grid>
</Box>
);
}

View File

@@ -0,0 +1,362 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Box, Typography, Chip, Paper, Button } from '@mui/material';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import ReactMarkdown from 'react-markdown';
import { detailStyles } from '../detailStyles';
import { useTranslation } from 'react-i18next';
import 'github-markdown-css/github-markdown-light.css';
// 答题状态常量
const EVAL_STATUS = {
SUCCESS: 0,
FORMAT_ERROR: 1,
API_ERROR: 2
};
// 状态标签配置
const STATUS_CONFIG = {
[EVAL_STATUS.SUCCESS]: { label: 'evalTasks.statusSuccess', color: 'success' },
[EVAL_STATUS.FORMAT_ERROR]: { label: 'evalTasks.statusFormatError', color: 'warning' },
[EVAL_STATUS.API_ERROR]: { label: 'evalTasks.statusApiError', color: 'error' }
};
export default function QuestionCard({ result, index, task }) {
const { t } = useTranslation();
const {
evalDataset,
modelAnswer,
isCorrect,
score,
judgeResponse,
duration = 0,
status = 0,
errorMessage = ''
} = result;
const { question, questionType, options, correctAnswer } = evalDataset;
const [isExpanded, setIsExpanded] = useState(false);
const [shouldShowExpand, setShouldShowExpand] = useState(false);
const contentRef = useRef(null);
const [isCorrectExpanded, setIsCorrectExpanded] = useState(false);
const [shouldShowCorrectExpand, setShouldShowCorrectExpand] = useState(false);
const correctContentRef = useRef(null);
// 检查内容是否超过高度限制
useEffect(() => {
if (contentRef.current) {
const hasOverflow = contentRef.current.scrollHeight > 200;
setShouldShowExpand(hasOverflow);
}
}, [modelAnswer]);
useEffect(() => {
if (correctContentRef.current) {
const hasOverflow = correctContentRef.current.scrollHeight > 200;
setShouldShowCorrectExpand(hasOverflow);
}
}, [correctAnswer]);
// 解析选项
let parsedOptions = [];
if (questionType === 'single_choice' || questionType === 'multiple_choice') {
try {
parsedOptions = JSON.parse(options);
} catch (e) {
parsedOptions = options ? [options] : [];
}
} else if (questionType === 'true_false') {
parsedOptions = ['True', 'False'];
}
// 格式化答案显示
const formatAnswer = ans => {
if (!ans) return '-';
return String(ans);
};
// 判断选项状态
const getOptionStatus = (optionText, idx) => {
const letter = String.fromCharCode(65 + idx);
const normModelAns = String(modelAnswer).trim();
const normCorrectAns = String(correctAnswer).trim();
let isSelected = false;
let isCorrectOption = false;
if (questionType === 'true_false') {
// 判断题A 对应 ✅/TrueB 对应 ❌/False
const isTrueOption = idx === 0;
const isFalseOption = idx === 1;
isSelected =
(isTrueOption && (normModelAns === '✅' || normModelAns.toUpperCase() === 'TRUE')) ||
(isFalseOption && (normModelAns === '❌' || normModelAns.toUpperCase() === 'FALSE'));
isCorrectOption =
(isTrueOption && (normCorrectAns === '✅' || normCorrectAns.toUpperCase() === 'TRUE')) ||
(isFalseOption && (normCorrectAns === '❌' || normCorrectAns.toUpperCase() === 'FALSE'));
} else {
// 选择题逻辑
const normModelAnsUpper = normModelAns.toUpperCase();
const normCorrectAnsUpper = normCorrectAns.toUpperCase();
const normOptionText = String(optionText).toUpperCase();
isSelected = normModelAnsUpper.includes(letter) || normModelAnsUpper.includes(normOptionText);
isCorrectOption = normCorrectAnsUpper.includes(letter) || normCorrectAnsUpper.includes(normOptionText);
}
return { isSelected, isCorrectOption };
};
// 解析 AI 点评内容
const getJudgeDisplayContent = content => {
if (!content) return '';
try {
// 尝试从 markdown 代码块中提取 JSON
const jsonMatch = content.match(/\{[\s\S]*?\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed.reason) return parsed.reason;
}
// 尝试直接解析
const parsed = JSON.parse(content);
if (parsed.reason) return parsed.reason;
} catch (e) {
// 解析失败,返回原内容
}
return content;
};
return (
<Box sx={detailStyles.questionCard(isCorrect)}>
{/* 判卷标记 (红勾/红叉) - 绝对定位 */}
<Box sx={detailStyles.markIcon(isCorrect)}>
{isCorrect ? <CheckIcon fontSize="inherit" /> : <CloseIcon fontSize="inherit" />}
</Box>
{/* 题号与类型标签 */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1.5, flexWrap: 'wrap' }}>
<Box
sx={{
...detailStyles.questionIndex,
position: 'relative', // 改为相对定位
top: 'auto',
left: 'auto',
flexShrink: 0
}}
>
{index + 1}
</Box>
<Chip
label={t(`eval.questionTypes.${questionType}`)}
size="small"
variant="outlined"
color="primary"
sx={{ borderRadius: 1 }}
/>
{/* 答题耗时 */}
{duration > 0 && (
<Chip
icon={<AccessTimeIcon sx={{ fontSize: 14 }} />}
label={duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`}
size="small"
variant="outlined"
sx={{ height: 24, '& .MuiChip-label': { px: 0.75, fontSize: '0.75rem' } }}
/>
)}
{/* 答题状态 */}
{status !== EVAL_STATUS.SUCCESS && (
<Chip
icon={<ErrorOutlineIcon sx={{ fontSize: 14 }} />}
label={t(
STATUS_CONFIG[status]?.label || 'evalTasks.statusUnknown',
status === EVAL_STATUS.FORMAT_ERROR ? t('evalTasks.statusFormatError') : t('evalTasks.statusApiError')
)}
size="small"
color={STATUS_CONFIG[status]?.color || 'default'}
variant="outlined"
sx={{ height: 24, '& .MuiChip-label': { px: 0.75, fontSize: '0.75rem' } }}
/>
)}
</Box>
{/* 题目内容 */}
<Box>
<Typography sx={detailStyles.questionContent}>{question}</Typography>
</Box>
{/* 选项区域 (仅选择题/判断题) */}
{parsedOptions.length > 0 && (
<Box sx={detailStyles.optionsContainer}>
{parsedOptions.map((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
const { isSelected, isCorrectOption } = getOptionStatus(opt, idx);
return (
<Box key={idx} sx={detailStyles.optionItem(isSelected, isCorrectOption)}>
<Typography sx={{ fontWeight: 600, minWidth: 24 }}>{letter}.</Typography>
<Typography>{opt}</Typography>
</Box>
);
})}
</Box>
)}
{/* 答案对比区域 */}
<Box sx={detailStyles.answerSection}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
{t('evalTasks.modelAnswer')}
</Typography>
<Box ref={contentRef} sx={detailStyles.markdownContainer(isExpanded)}>
{questionType === 'open_ended' || questionType === 'short_answer' ? (
<div className="markdown-body">
<ReactMarkdown>{modelAnswer || ''}</ReactMarkdown>
</div>
) : (
<Typography
variant="body1"
sx={{
color: isCorrect ? 'success.main' : 'error.main',
fontWeight: 600,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap'
}}
>
{formatAnswer(modelAnswer)}
</Typography>
)}
{/* 展开/收起 遮罩和按钮 */}
{shouldShowExpand && !isExpanded && (
<Box sx={detailStyles.expandMask}>
<Button
size="small"
onClick={() => setIsExpanded(true)}
startIcon={<ExpandMoreIcon />}
sx={detailStyles.expandButton}
>
{t('common.expand', '展开全部')}
</Button>
</Box>
)}
</Box>
{isExpanded && shouldShowExpand && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
<Button
size="small"
onClick={() => setIsExpanded(false)}
startIcon={<ExpandLessIcon />}
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
>
{t('common.collapse', '收起内容')}
</Button>
</Box>
)}
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
{t('evalTasks.correctAnswer')}
</Typography>
<Box ref={correctContentRef} sx={detailStyles.markdownContainer(isCorrectExpanded)}>
{questionType === 'open_ended' || questionType === 'short_answer' ? (
<div className="markdown-body">
<ReactMarkdown>{correctAnswer || ''}</ReactMarkdown>
</div>
) : (
<Typography
variant="body1"
sx={{ fontFamily: 'monospace', color: 'text.primary', whiteSpace: 'pre-wrap' }}
>
{formatAnswer(correctAnswer)}
</Typography>
)}
{/* 展开/收起 遮罩和按钮 */}
{shouldShowCorrectExpand && !isCorrectExpanded && (
<Box sx={detailStyles.expandMask}>
<Button
size="small"
onClick={() => setIsCorrectExpanded(true)}
startIcon={<ExpandMoreIcon />}
sx={detailStyles.expandButton}
>
{t('common.expand', '展开全部')}
</Button>
</Box>
)}
</Box>
{isCorrectExpanded && shouldShowCorrectExpand && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
<Button
size="small"
onClick={() => setIsCorrectExpanded(false)}
startIcon={<ExpandLessIcon />}
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
>
{t('common.collapse', '收起内容')}
</Button>
</Box>
)}
</Box>
</Box>
{/* 错误信息显示 */}
{errorMessage && (
<Box
sx={{
mt: 1.5,
p: 1.5,
bgcolor: 'error.lighter',
borderRadius: 1,
border: '1px solid',
borderColor: 'error.light'
}}
>
<Typography variant="body2" color="error.main" sx={{ fontSize: '0.8rem' }}>
{errorMessage}
</Typography>
</Box>
)}
{/* 教师点评 (气泡样式) */}
{judgeResponse && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Box sx={detailStyles.judgeComment}>
<Typography sx={detailStyles.judgeLabel}>{t('evalTasks.judgeComment')}</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{getJudgeDisplayContent(judgeResponse)}
</Typography>
{/* 得分显示(如果是主观题) */}
{(questionType === 'short_answer' || questionType === 'open_ended') && (
<Typography
sx={{
mt: 1,
textAlign: 'right',
fontWeight: 'bold',
fontSize: '1.2rem',
borderTop: '1px dashed #d32f2f',
pt: 0.5
}}
>
{(score * 100).toFixed(0)} {t('evalTasks.scoreUnit')}
</Typography>
)}
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,281 @@
export const detailStyles = {
// 页面背景
pageContainer: {
py: 4,
minHeight: '100vh',
bgcolor: '#f5f7fa'
},
// 头部概览卡片
headerCard: {
mb: 3,
borderRadius: 3,
overflow: 'hidden',
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
border: 'none'
},
headerContent: {
p: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 2
},
// 分数印章效果
scoreStamp: (score, isPass) => ({
width: 110,
height: 110,
borderRadius: '50%',
border: `4px double ${isPass ? '#2e7d32' : '#d32f2f'}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: isPass ? '#2e7d32' : '#d32f2f',
transform: 'rotate(-15deg)',
maskImage:
'url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZmlsdGVyIGlkPSJub2lzZSI+PGZlVHVyYnVsZW5jZSB0eXBlPSJmcmFjdGFsTm9pc2UiIGJhc2VGcmVxdWVuY3k9IjAuNSIgbnVtT2N0YXZlcz0iMyIgc3RpdGNoVGlsZXM9InN0aXRjaCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCNub2lzZSkiIG9wYWNpdHk9IjAuNSIvPjwvc3ZnPg==")', // 简单的噪点遮罩模拟印章纹理(可选)
opacity: 0.9,
boxShadow: 'inset 0 0 10px rgba(0,0,0,0.1)',
flexShrink: 0
}),
scoreValue: {
fontSize: '2.2rem',
fontWeight: 900,
lineHeight: 1.1,
fontFamily: '"Comic Sans MS", "Chalkboard SE", sans-serif',
mb: 0.2
},
scoreLabel: {
fontSize: '0.75rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1
},
// 统计卡片
statBox: {
textAlign: 'center',
p: 2,
borderRadius: 2,
bgcolor: 'background.default',
minWidth: 100
},
// 试卷主体
paperContainer: {
width: '100%',
mx: 'auto',
bgcolor: '#fff',
boxShadow: '0 8px 30px rgba(0,0,0,0.08)',
borderRadius: 2,
overflow: 'hidden',
position: 'relative',
border: '1px solid #e0e0e0'
},
paperHeader: {
p: 4,
borderBottom: '2px solid #000',
textAlign: 'center',
position: 'relative',
bgcolor: '#fff'
},
paperTitle: {
fontSize: '1.75rem',
fontWeight: 700,
mb: 1,
fontFamily: '"Songti SC", "SimSun", serif' // 宋体增强试卷感
},
paperSubTitle: {
color: 'text.secondary',
fontSize: '0.9rem'
},
// 题目部分
questionSection: {
p: 0
},
questionCard: isCorrect => ({
p: 3,
height: '100%', // 确保在Grid中高度撑满
borderBottom: '1px solid #f0f0f0', // 减淡边框颜色
position: 'relative',
transition: 'all 0.2s ease',
'&:hover': {
bgcolor: '#fafafa'
}
}),
questionIndex: {
position: 'absolute',
left: 20,
top: 24,
width: 32,
height: 32,
borderRadius: '50%', // 圆形题号
border: '1px solid #ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
color: 'text.secondary',
bgcolor: '#fff',
zIndex: 1,
fontSize: '0.875rem'
},
// 判卷标记(红勾/红叉)
markIcon: isCorrect => ({
position: 'absolute',
right: 20,
top: 20,
fontSize: '3rem',
color: isCorrect ? '#2e7d32' : '#d32f2f',
opacity: 0.8,
transform: 'rotate(10deg)',
fontFamily: '"Comic Sans MS", "Chalkboard SE", sans-serif'
}),
// 题目内容
questionContent: {
fontSize: '1.1rem',
fontWeight: 500,
lineHeight: 1.6,
mb: 2,
color: '#333'
},
// 选项区域
optionsContainer: {
pl: 2,
mb: 2
},
optionItem: (isSelected, isCorrectOption) => ({
p: 1,
mb: 0.5,
borderRadius: 1,
bgcolor: isCorrectOption
? 'rgba(46, 125, 50, 0.1)' // 正确选项显示绿色背景
: isSelected
? 'rgba(211, 47, 47, 0.1)'
: 'transparent', // 错误选中显示红色背景
color: isCorrectOption ? 'success.main' : isSelected ? 'error.main' : 'text.primary',
display: 'flex',
alignItems: 'flex-start',
gap: 1
}),
// 答案区域
answerSection: {
mt: 2,
p: 2,
bgcolor: '#f8f9fa',
borderRadius: 2,
borderLeft: '4px solid #ddd',
position: 'relative'
},
// Markdown 展示区域
markdownContainer: isExpanded => ({
maxHeight: isExpanded ? 'none' : '200px',
overflow: 'hidden',
position: 'relative',
'& .markdown-body': {
fontSize: '0.9rem',
lineHeight: 1.6,
bgcolor: 'transparent',
color: 'inherit',
padding: 0
}
}),
// 展开收起遮罩层(渐变效果)
expandMask: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '60px',
background: 'linear-gradient(transparent, #f8f9fa)',
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
pb: 1,
zIndex: 1
},
expandButton: {
fontSize: '0.75rem',
textTransform: 'none',
color: 'primary.main',
bgcolor: 'rgba(255,255,255,0.8)',
'&:hover': {
bgcolor: 'rgba(255,255,255,1)'
},
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
borderRadius: '16px',
px: 2
},
// 教师点评样式
judgeComment: {
mt: 2,
position: 'relative',
fontFamily: '"KaiTi", "KaiTi_GB2312", serif', // 楷体模拟手写点评
color: '#d32f2f',
padding: '10px 20px',
border: '1px solid #d32f2f',
borderRadius: '20px 20px 20px 4px', // 气泡形状
maxWidth: 'fit-content',
bgcolor: '#fff5f5'
},
judgeLabel: {
fontSize: '0.8rem',
opacity: 0.7,
fontStyle: 'italic',
mb: 0.5
},
// 按题型统计样式
typeStatsItem: {
textAlign: 'center',
p: 2,
bgcolor: '#fff',
borderRadius: 2,
border: '1px solid #eee',
boxShadow: '0 2px 8px rgba(0,0,0,0.03)',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
},
typeStatsLabel: {
fontSize: '0.85rem',
color: 'text.secondary',
mb: 1
},
typeStatsScore: {
fontWeight: 700,
fontSize: '1.25rem',
color: 'text.primary'
},
typeStatsPercent: {
fontSize: '0.75rem',
color: 'text.secondary',
fontWeight: 500
}
};

View File

@@ -0,0 +1,214 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import {
Container,
Box,
Button,
CircularProgress,
Alert,
Typography,
LinearProgress,
Paper,
Grid,
Pagination
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import RefreshIcon from '@mui/icons-material/Refresh';
import { useTranslation } from 'react-i18next';
import useEvalTaskDetail from '../hooks/useEvalTaskDetail';
import { detailStyles } from './detailStyles';
import EvalHeader from './components/EvalHeader';
import EvalStats from './components/EvalStats';
import QuestionCard from './components/QuestionCard';
export default function EvalTaskDetailPage() {
const { projectId, taskId } = useParams();
const router = useRouter();
const { t } = useTranslation();
const {
task,
results,
stats,
total,
page,
setPage,
pageSize,
filterType,
setFilterType,
filterCorrect,
setFilterCorrect,
loading,
error,
setError,
loadData
} = useEvalTaskDetail(projectId, taskId);
const handleFilterSelect = type => {
setFilterType(type);
setPage(1); // 切换筛选时重置到第一页
};
const handleFilterCorrectSelect = isCorrect => {
setFilterCorrect(isCorrect);
setPage(1); // 切换筛选时重置到第一页
};
const handlePageChange = (event, value) => {
setPage(value);
// 滚动到试卷顶部
document.getElementById('paper-top')?.scrollIntoView({ behavior: 'smooth' });
};
if (loading && !task) {
return (
<Container
maxWidth="xl"
sx={{ ...detailStyles.pageContainer, display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
<CircularProgress />
</Container>
);
}
return (
<Box sx={detailStyles.pageContainer}>
<Container maxWidth="xl">
{/* 顶部导航栏 */}
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => router.back()}
sx={{ color: 'text.secondary', fontWeight: 600 }}
>
{t('common.back')}
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={loadData}
disabled={loading}
size="small"
sx={{ bgcolor: 'white' }}
>
{t('common.refresh')}
</Button>
</Box>
{/* 错误提示 */}
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{/* 任务进度(仅进行中时显示) */}
{task?.status === 0 && (
<Paper sx={{ p: 3, mb: 4, borderRadius: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" fontWeight="bold">
{t('evalTasks.statusProcessing')}...
</Typography>
<Typography variant="body2" color="primary">
{task.completedCount}/{task.totalCount}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={task.totalCount > 0 ? (task.completedCount / task.totalCount) * 100 : 0}
sx={{ height: 10, borderRadius: 5 }}
/>
</Paper>
)}
{/* 核心内容区 */}
{task && (
<>
{/* 头部概览 */}
<EvalHeader
task={task}
stats={stats}
filterCorrect={filterCorrect}
onFilterCorrectSelect={handleFilterCorrectSelect}
/>
{/* 统计图表 & 筛选 */}
<EvalStats stats={stats} currentFilter={filterType} onFilterSelect={handleFilterSelect} />
{/* 试卷主体 */}
<Box sx={detailStyles.paperContainer} id="paper-top">
{/* 试卷抬头 */}
<Box sx={detailStyles.paperHeader}>
<Typography sx={detailStyles.paperTitle}>{t('evalTasks.reportTitle', '模型能力评估报告')}</Typography>
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'center',
gap: 3,
color: 'text.secondary',
fontSize: '0.875rem'
}}
>
<Typography variant="caption">
{t('evalTasks.taskIdLabel', '任务 ID')}: {taskId}
</Typography>
<Typography variant="caption">
{t('evalTasks.pageInfo', '第 {{page}} / {{totalPages}} 页', {
page,
totalPages: Math.ceil(total / pageSize)
})}
</Typography>
</Box>
</Box>
{/* 题目列表 (双列布局) */}
<Box sx={{ p: 3, bgcolor: '#fff' }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={3}>
{results?.map((result, index) => (
<Grid item xs={12} md={6} key={result.id}>
<QuestionCard result={result} index={(page - 1) * pageSize + index} task={task} />
</Grid>
))}
</Grid>
)}
{!loading && results?.length === 0 && (
<Box sx={{ p: 8, textAlign: 'center', color: 'text.disabled' }}>
<Typography>{t('evalTasks.noMatchingResults', '暂无符合条件的评估结果')}</Typography>
</Box>
)}
</Box>
{/* 分页控制 */}
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center', borderTop: '1px solid #eee' }}>
<Pagination
count={Math.ceil(total / pageSize)}
page={page}
onChange={handlePageChange}
color="primary"
size="large"
showFirstButton
showLastButton
/>
</Box>
{/* 试卷底部 */}
<Box sx={{ p: 4, textAlign: 'center', color: 'text.disabled', borderTop: '2px solid #000' }}>
<Typography variant="caption">
{t('evalTasks.reportFooter', 'Easy Dataset Evaluation System · Generated by AI')}
</Typography>
</Box>
</Box>
</>
)}
</Container>
</Box>
);
}