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>
);
}

View File

@@ -0,0 +1,328 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
Divider,
CircularProgress,
FormHelperText
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import ModelSelector from './ModelSelector';
import QuestionFilter from './QuestionFilter';
import ScoreAnchorsForm from './ScoreAnchorsForm';
import { useEvalTaskForm } from '../hooks/useEvalTaskForm';
import { useEffect } from 'react';
export default function CreateEvalTaskDialog({ open, onClose, projectId, onSuccess }) {
const { t, i18n } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const {
models,
selectedModels,
setSelectedModels,
judgeModel,
setJudgeModel,
evalDatasets,
availableTags,
questionTypes,
setQuestionTypes,
selectedTags,
setSelectedTags,
searchKeyword,
setSearchKeyword,
questionCount,
setQuestionCount,
filteredTotal,
sampledIds,
hasSubjectiveQuestions,
hasShortAnswer,
hasOpenEnded,
shortAnswerScoreAnchors,
setShortAnswerScoreAnchors,
openEndedScoreAnchors,
setOpenEndedScoreAnchors,
initScoreAnchors,
loading,
error,
setError,
setSampledIds,
resetFilters,
resetForm
} = useEvalTaskForm(projectId, open);
// 当有主观题时,初始化评分规则
useEffect(() => {
if (hasSubjectiveQuestions && open) {
initScoreAnchors(i18n.language === 'zh-CN' ? 'zh-CN' : 'en');
}
}, [hasSubjectiveQuestions, open, i18n.language]);
// 统计各题型数量
const typeStats = {};
evalDatasets.forEach(d => {
typeStats[d.questionType] = (typeStats[d.questionType] || 0) + 1;
});
const getModelKey = model => `${model.providerId}::${model.modelId}`;
const handleModelSelectionChange = newSelection => {
setSelectedModels(newSelection);
setError('');
};
const handleSubmit = async () => {
// 先清除之前的错误
setError('');
// 验证
if (selectedModels.length === 0) {
setError(t('evalTasks.errorNoModels'));
return;
}
if (filteredTotal === 0) {
setError(t('evalTasks.errorNoQuestions'));
return;
}
if (hasSubjectiveQuestions && !judgeModel) {
setError(t('evalTasks.errorNoJudgeModel'));
return;
}
// 验证教师模型不在测试模型中
if (judgeModel && selectedModels.includes(judgeModel)) {
setError(t('evalTasks.errorJudgeSameAsTest'));
return;
}
try {
setSubmitting(true);
setError('');
// 解析选中的模型
const models = selectedModels.map(m => {
const [providerId, modelId] = m.split('::');
return { modelId, providerId }; // 注意顺序modelId 在前
});
// 解析教师模型
let judgeModelId = null;
let judgeProviderId = null;
if (judgeModel) {
const [pId, mId] = judgeModel.split('::');
judgeProviderId = pId;
judgeModelId = mId;
}
// 调用后端采样接口获取题目 ID
const sampleBody = {
questionTypes: questionTypes,
tags: selectedTags,
keyword: searchKeyword.trim() || '',
limit: questionCount > 0 ? questionCount : undefined
};
const sampleResponse = await fetch(`/api/projects/${projectId}/eval-datasets/sample`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sampleBody)
});
const sampleResult = await sampleResponse.json();
if (!sampleResponse.ok || sampleResult.code !== 0) {
setError(sampleResult.error || t('evalTasks.errorCreateFailed'));
return;
}
const ids = sampleResult?.data?.ids || [];
if (ids.length === 0) {
setError(t('evalTasks.errorNoQuestions'));
return;
}
setSampledIds(ids);
// 构建自定义评分规则对象
const customScoreAnchors = {};
if (hasShortAnswer && shortAnswerScoreAnchors.length > 0) {
customScoreAnchors.short_answer = shortAnswerScoreAnchors;
}
if (hasOpenEnded && openEndedScoreAnchors.length > 0) {
customScoreAnchors.open_ended = openEndedScoreAnchors;
}
// 创建任务
const response = await fetch(`/api/projects/${projectId}/eval-tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
models, // 后端期望的字段名
judgeModelId, // 分开传递
judgeProviderId, // 分开传递
evalDatasetIds: ids,
language: i18n.language === 'zh-CN' ? 'zh-CN' : 'en',
customScoreAnchors: Object.keys(customScoreAnchors).length > 0 ? customScoreAnchors : undefined
})
});
const result = await response.json();
if (result.code === 0) {
onSuccess && onSuccess(result.data);
handleClose();
} else {
setError(result.error || t('evalTasks.errorCreateFailed'));
}
} catch (err) {
console.error('创建评估任务失败:', err);
setError(t('evalTasks.errorCreateFailed'));
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
resetForm();
onClose();
};
const handleJudgeModelChange = event => {
setJudgeModel(event.target.value);
setError('');
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>{t('evalTasks.createTitle')}</DialogTitle>
<DialogContent>
<Box sx={{ mt: 1 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{/* 选择测试模型 */}
<ModelSelector
models={models}
selectedModels={selectedModels}
onSelectionChange={handleModelSelectionChange}
error={selectedModels.length === 0 && error}
/>
<Divider sx={{ my: 2 }} />
{/* 题目筛选 */}
<QuestionFilter
questionTypes={questionTypes}
selectedTags={selectedTags}
searchKeyword={searchKeyword}
questionCount={questionCount}
availableTags={availableTags}
typeStats={typeStats}
filteredCount={filteredTotal}
onQuestionTypesChange={setQuestionTypes}
onTagsChange={setSelectedTags}
onSearchChange={setSearchKeyword}
onQuestionCountChange={setQuestionCount}
onReset={resetFilters}
/>
{/* 最终题目统计 */}
<Box sx={{ mb: 3, p: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="body2" color="text.secondary">
{t('evalTasks.finalSelection')}
<strong>{sampledIds.length || (questionCount > 0 ? questionCount : filteredTotal)}</strong>{' '}
{t('evalTasks.questionsSuffix')}
</Typography>
{hasSubjectiveQuestions && (
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
{t('evalTasks.hasSubjectiveHint')}
</Typography>
)}
</Box>
{/* 选择教师模型(仅当有主观题时显示) */}
{hasSubjectiveQuestions && (
<>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>{t('evalTasks.selectJudgeModel')} *</InputLabel>
<Select
value={judgeModel}
onChange={handleJudgeModelChange}
label={`${t('evalTasks.selectJudgeModel')} *`}
>
<MenuItem value="">
<em>{t('evalTasks.selectJudgeModelPlaceholder')}</em>
</MenuItem>
{models
.filter(m => {
const key = `${m.providerId}::${m.modelId}`;
return !selectedModels.includes(key);
})
.map(model => {
const key = `${model.providerId}::${model.modelId}`;
return (
<MenuItem key={key} value={key}>
{model.providerName} / {model.modelName}
</MenuItem>
);
})}
</Select>
<FormHelperText>{t('evalTasks.selectJudgeModelHint')}</FormHelperText>
</FormControl>
{/* 简答题评分规则 */}
{hasShortAnswer && (
<ScoreAnchorsForm
questionType="short_answer"
scoreAnchors={shortAnswerScoreAnchors}
onChange={setShortAnswerScoreAnchors}
language={i18n.language}
/>
)}
{/* 开放题评分规则 */}
{hasOpenEnded && (
<ScoreAnchorsForm
questionType="open_ended"
scoreAnchors={openEndedScoreAnchors}
onChange={setOpenEndedScoreAnchors}
language={i18n.language}
/>
)}
</>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={submitting || loading}
startIcon={submitting && <CircularProgress size={16} />}
>
{submitting ? t('common.creating') : t('evalTasks.startEval')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import { useState } from 'react';
import {
Card,
CardContent,
Box,
Typography,
Chip,
IconButton,
LinearProgress,
Menu,
MenuItem,
ListItemIcon,
Avatar,
useTheme
} from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete';
import StopIcon from '@mui/icons-material/Stop';
import VisibilityIcon from '@mui/icons-material/Visibility';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import PauseCircleIcon from '@mui/icons-material/PauseCircle';
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import QuizIcon from '@mui/icons-material/Quiz';
import { useTranslation } from 'react-i18next';
import { getModelIcon } from '@/lib/util/modelIcon';
import styles from '../styles';
const STATUS_CONFIG = {
0: { label: 'evalTasks.statusProcessing', color: 'info', icon: HourglassEmptyIcon },
1: { label: 'evalTasks.statusCompleted', color: 'success', icon: CheckCircleIcon },
2: { label: 'evalTasks.statusFailed', color: 'error', icon: ErrorIcon },
3: { label: 'evalTasks.statusInterrupted', color: 'warning', icon: PauseCircleIcon }
};
export default function EvalTaskCard({ task, onView, onDelete, onInterrupt }) {
const { t } = useTranslation();
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const { modelInfo, detail, status, completedCount, totalCount, createAt } = task;
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG[0];
const StatusIcon = statusConfig.icon;
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
const finalScore = detail?.finalScore;
const handleMenuClick = e => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
};
const handleMenuClose = () => setAnchorEl(null);
const handleAction = action => () => {
handleMenuClose();
action?.(task);
};
const getScoreColor = score => {
if (score >= 80) return 'success';
if (score >= 60) return 'info';
if (score >= 40) return 'warning';
return 'error';
};
return (
<Card sx={styles.taskCard(theme)} onClick={handleAction(onView)}>
<CardContent sx={styles.taskCardContent}>
{/* 头部 */}
<Box sx={styles.taskCardHeader}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1, overflow: 'hidden' }}>
<Avatar sx={{ bgcolor: 'transparent', width: 40, height: 40, border: '1px solid', borderColor: 'divider' }}>
<img
src={getModelIcon(modelInfo?.modelName || modelInfo?.modelId)}
alt={modelInfo?.modelId || 'model'}
style={{ width: 28, height: 28, objectFit: 'contain' }}
/>
</Avatar>
<Box sx={styles.taskCardModel}>
<Typography sx={styles.taskCardModelName} noWrap>
{modelInfo?.modelName || modelInfo?.modelId}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{modelInfo?.providerName || modelInfo?.providerId}
</Typography>
</Box>
</Box>
<IconButton size="small" onClick={handleMenuClick}>
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
{/* 状态和得分 */}
<Box sx={styles.taskCardStatus}>
<Chip
icon={<StatusIcon sx={{ fontSize: 14 }} />}
label={t(statusConfig.label)}
color={statusConfig.color}
size="small"
variant="outlined"
sx={{ height: 24, '& .MuiChip-label': { px: 1, fontSize: '0.7rem' } }}
/>
{finalScore !== undefined && status === 1 && (
<Chip
label={`${finalScore.toFixed(1)}%`}
color={getScoreColor(finalScore)}
size="small"
sx={{ height: 24, fontWeight: 600, '& .MuiChip-label': { px: 1 } }}
/>
)}
</Box>
{/* 进度条 */}
{status === 0 && (
<Box sx={styles.taskCardProgress}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{t('evalTasks.progress')}
</Typography>
<Typography variant="caption" color="primary" fontWeight={600}>
{completedCount}/{totalCount}
</Typography>
</Box>
<LinearProgress variant="determinate" value={progress} sx={styles.progressBar} />
</Box>
)}
{/* 统计信息 */}
<Box sx={styles.taskCardStats}>
<Chip
icon={<QuizIcon sx={{ fontSize: 14 }} />}
label={`${totalCount} ${t('evalTasks.questions')}`}
size="small"
variant="outlined"
sx={{ height: 22, '& .MuiChip-label': { px: 0.75, fontSize: '0.7rem' } }}
/>
{detail?.hasSubjectiveQuestions && (
<Chip
label={t('evalTasks.hasSubjective')}
size="small"
color="info"
variant="outlined"
sx={{ height: 22, '& .MuiChip-label': { px: 0.75, fontSize: '0.7rem' } }}
/>
)}
</Box>
{/* 时间 */}
<Typography sx={{ ...styles.taskCardTime, mt: 1.5 }} color="text.disabled">
{new Date(createAt).toLocaleString()}
</Typography>
</CardContent>
{/* 菜单 */}
<Menu anchorEl={anchorEl} open={open} onClose={handleMenuClose} onClick={e => e.stopPropagation()}>
<MenuItem onClick={handleAction(onView)}>
<ListItemIcon>
<VisibilityIcon fontSize="small" />
</ListItemIcon>
{t('datasets.viewDetails')}
</MenuItem>
{status === 0 && (
<MenuItem onClick={handleAction(onInterrupt)}>
<ListItemIcon>
<StopIcon fontSize="small" color="warning" />
</ListItemIcon>
{t('evalTasks.interrupt')}
</MenuItem>
)}
<MenuItem onClick={handleAction(onDelete)} sx={{ color: 'error.main' }}>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
{t('common.delete')}
</MenuItem>
</Menu>
</Card>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import {
Box,
Typography,
Checkbox,
FormHelperText,
FormControl,
InputLabel,
Select,
MenuItem,
ListItemText,
OutlinedInput,
Chip
} from '@mui/material';
import { useTranslation } from 'react-i18next';
export default function ModelSelector({ models, selectedModels, onSelectionChange, error }) {
const { t } = useTranslation();
const getModelKey = model => `${model.providerId}::${model.modelId}`;
const handleChange = event => {
const {
target: { value }
} = event;
// On autofill we get a stringified value.
onSelectionChange(typeof value === 'string' ? value.split(',') : value);
};
const getModelLabel = modelKey => {
const model = models.find(m => getModelKey(m) === modelKey);
if (!model) return modelKey;
return `${model.providerName || model.providerId} / ${model.modelName || model.modelId}`;
};
return (
<Box sx={{ mb: 3 }}>
<FormControl fullWidth error={!!error} size="small">
<InputLabel id="model-selector-label">{t('evalTasks.selectModels')} *</InputLabel>
<Select
labelId="model-selector-label"
multiple
value={selectedModels}
onChange={handleChange}
input={<OutlinedInput label={`${t('evalTasks.selectModels')} *`} />}
renderValue={selected => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map(value => (
<Chip key={value} label={getModelLabel(value)} size="small" />
))}
</Box>
)}
MenuProps={{
PaperProps: {
style: {
maxHeight: 300,
width: 250
}
}
}}
>
{models.length === 0 ? (
<MenuItem disabled value="">
<Typography variant="body2" color="text.secondary">
{t('evalTasks.noModelsAvailable')}
</Typography>
</MenuItem>
) : (
models.map(model => {
const modelKey = getModelKey(model);
return (
<MenuItem key={modelKey} value={modelKey}>
<Checkbox checked={selectedModels.includes(modelKey)} />
<ListItemText
primary={`${model.providerName || model.providerId} / ${model.modelName || model.modelId}`}
/>
</MenuItem>
);
})
)}
</Select>
<FormHelperText>{error || t('evalTasks.selectModelsHint')}</FormHelperText>
</FormControl>
</Box>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import {
Box,
Typography,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
OutlinedInput,
Checkbox,
ListItemText,
Slider,
Button
} from '@mui/material';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import ClearIcon from '@mui/icons-material/Clear';
import { useTranslation } from 'react-i18next';
const QUESTION_TYPES = [
{ value: 'true_false', labelKey: 'eval.questionTypes.true_false' },
{ value: 'single_choice', labelKey: 'eval.questionTypes.single_choice' },
{ value: 'multiple_choice', labelKey: 'eval.questionTypes.multiple_choice' },
{ value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' },
{ value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' }
];
export default function QuestionFilter({
questionTypes,
selectedTags,
searchKeyword,
questionCount,
availableTags,
filteredCount,
onQuestionTypesChange,
onTagsChange,
onSearchChange,
onQuestionCountChange,
onReset
}) {
const { t } = useTranslation();
const hasFilters = questionTypes.length > 0 || selectedTags.length > 0 || searchKeyword || questionCount > 0;
return (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<FilterAltIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
{t('evalTasks.filterTitle')}
</Typography>
{hasFilters && (
<Button size="small" startIcon={<ClearIcon />} onClick={onReset}>
{t('evalTasks.clearFilter')}
</Button>
)}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* 关键字搜索 */}
<TextField
fullWidth
size="small"
label={t('evalTasks.searchKeyword')}
placeholder={t('evalTasks.searchPlaceholder')}
value={searchKeyword}
onChange={e => onSearchChange(e.target.value)}
/>
{/* 题型和标签筛选 - 并排显示 */}
<Box sx={{ display: 'flex', gap: 2 }}>
{/* 题型筛选 */}
<FormControl fullWidth size="small">
<InputLabel>{t('evalTasks.filterByTypeLabel')}</InputLabel>
<Select
multiple
value={questionTypes}
onChange={e => onQuestionTypesChange(e.target.value)}
input={<OutlinedInput label={t('evalTasks.filterByTypeLabel')} />}
renderValue={selected => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map(value => (
<Chip key={value} label={t(`eval.questionTypes.${value}`)} size="small" />
))}
</Box>
)}
>
{QUESTION_TYPES.map(type => (
<MenuItem key={type.value} value={type.value}>
<Checkbox checked={questionTypes.includes(type.value)} />
<ListItemText primary={`${t(type.labelKey)} `} />
</MenuItem>
))}
</Select>
</FormControl>
{/* 标签筛选 */}
{availableTags.length > 0 && (
<FormControl fullWidth size="small">
<InputLabel>{t('evalTasks.filterByTagLabel')}</InputLabel>
<Select
multiple
value={selectedTags}
onChange={e => onTagsChange(e.target.value)}
input={<OutlinedInput label={t('evalTasks.filterByTagLabel')} />}
renderValue={selected => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map(value => (
<Chip key={value} label={value} size="small" />
))}
</Box>
)}
>
{availableTags.map(tag => (
<MenuItem key={tag} value={tag}>
<Checkbox checked={selectedTags.includes(tag)} />
<ListItemText primary={tag} />
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
{/* 题目数量选择 - 紧凑布局 */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" sx={{ flex: 1 }}>
{t('evalTasks.questionCountLabel')}
{questionCount === 0 ? t('common.all') : questionCount} / {filteredCount}
</Typography>
<TextField
size="small"
type="number"
value={questionCount}
onChange={e => onQuestionCountChange(parseInt(e.target.value) || 0)}
inputProps={{ min: 0, max: filteredCount }}
sx={{ width: 100 }}
/>
</Box>
<Slider
value={questionCount}
onChange={(e, value) => onQuestionCountChange(value)}
min={0}
max={filteredCount}
step={1}
valueLabelDisplay="auto"
/>
<Typography variant="caption" color="text.secondary">
{questionCount === 0
? t('evalTasks.useAllQuestions')
: t('evalTasks.randomSampleHint', { filteredCount, questionCount })}
</Typography>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
IconButton,
Tooltip
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import RestoreIcon from '@mui/icons-material/Restore';
import { useTranslation } from 'react-i18next';
import { getDefaultScoreAnchors } from '@/lib/llm/prompts/llmJudge';
/**
* 评分规则表单组件
* 用于自定义简答题和开放题的评分规则
*/
export default function ScoreAnchorsForm({
questionType, // 'short_answer' 或 'open_ended'
scoreAnchors,
onChange,
language = 'zh-CN'
}) {
const { t, i18n } = useTranslation();
const [expanded, setExpanded] = useState(false);
// 获取当前语言
const currentLanguage = i18n.language === 'zh-CN' ? 'zh-CN' : 'en';
// 初始化评分规则(如果为空)
useEffect(() => {
if (!scoreAnchors || scoreAnchors.length === 0) {
onChange(getDefaultScoreAnchors(questionType, currentLanguage));
}
}, [questionType, currentLanguage]);
// 处理单个规则的描述更改
const handleDescriptionChange = (index, newDescription) => {
const newAnchors = [...scoreAnchors];
newAnchors[index] = { ...newAnchors[index], description: newDescription };
onChange(newAnchors);
};
// 恢复默认值
const handleRestore = () => {
onChange(getDefaultScoreAnchors(questionType, currentLanguage));
};
// 获取题型显示名称
const getQuestionTypeName = () => {
if (questionType === 'short_answer') {
return t('evalTasks.shortAnswer', '简答题');
}
return t('evalTasks.openEnded', '开放题');
};
// 获取分数区间的颜色
const getScoreColor = range => {
if (range === '1.0') return 'success';
if (range.includes('0.8') || range.includes('0.9')) return 'info';
if (range.includes('0.6') || range.includes('0.7')) return 'warning';
return 'error';
};
if (!scoreAnchors || scoreAnchors.length === 0) {
return null;
}
return (
<Accordion
expanded={expanded}
onChange={(e, isExpanded) => setExpanded(isExpanded)}
sx={{
mb: 2,
'&:before': { display: 'none' },
boxShadow: 1
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
bgcolor: 'action.hover',
'&:hover': { bgcolor: 'action.selected' }
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Typography variant="subtitle2" fontWeight={600}>
{t('evalTasks.scoreAnchorsTitle', '{{type}}评分规则', { type: getQuestionTypeName() })}
</Typography>
<Chip label={t('evalTasks.customizable', '可自定义')} size="small" color="primary" variant="outlined" />
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('evalTasks.scoreAnchorsHint', '自定义评分标准用于指导LLM评估模型的回答质量')}
</Typography>
<Tooltip title={t('evalTasks.restoreDefault', '恢复默认')}>
<IconButton size="small" onClick={handleRestore} color="primary">
<RestoreIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{scoreAnchors.map((anchor, index) => (
<Box key={index} sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip
label={anchor.range}
size="small"
color={getScoreColor(anchor.range)}
sx={{ minWidth: 70, fontWeight: 600 }}
/>
<Typography variant="caption" color="text.secondary">
{t('evalTasks.scoreRange', '分数区间')}
</Typography>
</Box>
<TextField
fullWidth
size="small"
multiline
rows={2}
value={anchor.description}
onChange={e => handleDescriptionChange(index, e.target.value)}
placeholder={t('evalTasks.scoreDescriptionPlaceholder', '请输入该分数区间的评分标准描述...')}
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '0.875rem'
}
}}
/>
</Box>
))}
</AccordionDetails>
</Accordion>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
/**
* 评估任务详情 Hook
*/
export default function useEvalTaskDetail(projectId, taskId) {
const [task, setTask] = useState(null);
const [results, setResults] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// 分页和筛选状态
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [filterType, setFilterType] = useState(null);
const [filterCorrect, setFilterCorrect] = useState(null); // null: all, true: correct, false: incorrect
const [total, setTotal] = useState(0);
// 加载任务详情
const loadData = useCallback(async () => {
if (!projectId || !taskId) return;
try {
setLoading(true);
setError('');
// 构建查询参数
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString()
});
if (filterType) {
params.append('type', filterType);
}
if (filterCorrect !== null) {
params.append('isCorrect', filterCorrect.toString());
}
const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}?${params.toString()}`);
const result = await response.json();
if (result.code === 0) {
setTask(result.data.task);
setResults(result.data.results || []);
setTotal(result.data.total || 0);
setStats(result.data.stats);
} else {
setError(result.error || '加载失败');
}
} catch (err) {
console.error('加载任务详情失败:', err);
setError('加载失败');
} finally {
setLoading(false);
}
}, [projectId, taskId, page, pageSize, filterType, filterCorrect]);
// 初始加载
useEffect(() => {
loadData();
}, [loadData]);
// 自动刷新进行中的任务 (仅在第一页且无筛选时刷新,避免干扰用户查看历史记录)
useEffect(() => {
if (task?.status !== 0 || page !== 1 || filterType || filterCorrect !== null) return;
const interval = setInterval(loadData, 3000);
return () => clearInterval(interval);
}, [task?.status, page, filterType, filterCorrect, loadData]);
return {
task,
results,
stats,
total,
page,
setPage,
pageSize,
setPageSize,
filterType,
setFilterType,
filterCorrect,
setFilterCorrect,
loading,
error,
setError,
loadData
};
}

View File

@@ -0,0 +1,187 @@
'use client';
import { useState, useEffect } from 'react';
import { getDefaultScoreAnchors } from '@/lib/llm/prompts/llmJudge';
export function useEvalTaskForm(projectId, open) {
const [models, setModels] = useState([]);
const [selectedModels, setSelectedModels] = useState([]);
const [judgeModel, setJudgeModel] = useState('');
const [evalDatasets, setEvalDatasets] = useState([]);
const [availableTags, setAvailableTags] = useState([]);
// 筛选条件
const [questionTypes, setQuestionTypes] = useState([]);
const [selectedTags, setSelectedTags] = useState([]);
const [searchKeyword, setSearchKeyword] = useState('');
const [questionCount, setQuestionCount] = useState(0);
// 后端统计 & 采样结果
const [filteredTotal, setFilteredTotal] = useState(0);
const [sampledIds, setSampledIds] = useState([]);
const [hasSubjectiveQuestions, setHasSubjectiveQuestions] = useState(false);
// 主观题类型统计(用于确定显示哪个评分规则表单)
const [hasShortAnswer, setHasShortAnswer] = useState(false);
const [hasOpenEnded, setHasOpenEnded] = useState(false);
// 自定义评分规则
const [shortAnswerScoreAnchors, setShortAnswerScoreAnchors] = useState([]);
const [openEndedScoreAnchors, setOpenEndedScoreAnchors] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 加载数据
useEffect(() => {
if (open && projectId) {
loadModels();
loadEvalDatasets();
}
}, [open, projectId]);
// 当筛选条件变化时,调用后端统计数量
useEffect(() => {
if (!open || !projectId) return;
const controller = new AbortController();
const fetchCount = async () => {
try {
const params = new URLSearchParams();
if (questionTypes.length > 0) {
questionTypes.forEach(t => params.append('questionTypes', t));
}
if (searchKeyword.trim()) {
params.append('keyword', searchKeyword.trim());
}
if (selectedTags.length > 0) {
selectedTags.forEach(tag => params.append('tags', tag));
}
const response = await fetch(`/api/projects/${projectId}/eval-datasets/count?${params.toString()}`, {
signal: controller.signal
});
if (response.ok) {
const result = await response.json();
const total = result?.data?.total ?? 0;
const hasSubjective = result?.data?.hasSubjective ?? false;
const hasShort = result?.data?.hasShortAnswer ?? false;
const hasOpen = result?.data?.hasOpenEnded ?? false;
setFilteredTotal(total);
setHasSubjectiveQuestions(hasSubjective);
setHasShortAnswer(hasShort);
setHasOpenEnded(hasOpen);
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error('加载评估题目数量失败:', err);
}
}
};
fetchCount();
return () => {
controller.abort();
};
}, [open, projectId, questionTypes, selectedTags, searchKeyword]);
const loadModels = async () => {
try {
const response = await fetch(`/api/projects/${projectId}/model-config`);
if (response.ok) {
const result = await response.json();
const modelList = result?.data || [];
const availableModels = modelList.filter(m => m.apiKey && m.apiKey.trim() !== '' && m.status === 1);
setModels(availableModels);
}
} catch (err) {
console.error('加载模型列表失败:', err);
setModels([]);
}
};
const loadEvalDatasets = async () => {
try {
setLoading(true);
// 这里只需要拿到全部可用标签和题型分布,可以复用已有列表接口或标签接口
const response = await fetch(`/api/projects/${projectId}/eval-datasets?includeStats=true&page=1&pageSize=20`);
if (response.ok) {
const data = await response.json();
const stats = data.stats || {};
const byTag = stats.byTag || {};
const tags = Object.keys(byTag);
setAvailableTags(tags.sort());
// 用部分数据来判断是否存在主观题(类型统计更准确)
const byType = stats.byType || {};
const mockDatasets = Object.entries(byType).map(([type]) => ({ questionType: type }));
setEvalDatasets(mockDatasets);
}
} catch (err) {
console.error('加载评估题目失败:', err);
} finally {
setLoading(false);
}
};
const resetFilters = () => {
setQuestionTypes([]);
setSelectedTags([]);
setSearchKeyword('');
setQuestionCount(0);
setFilteredTotal(0);
setSampledIds([]);
setHasShortAnswer(false);
setHasOpenEnded(false);
};
// 初始化评分规则(根据语言环境)
const initScoreAnchors = (language = 'zh-CN') => {
setShortAnswerScoreAnchors(getDefaultScoreAnchors('short_answer', language));
setOpenEndedScoreAnchors(getDefaultScoreAnchors('open_ended', language));
};
const resetForm = () => {
setSelectedModels([]);
setJudgeModel('');
resetFilters();
setError('');
setShortAnswerScoreAnchors([]);
setOpenEndedScoreAnchors([]);
};
return {
models,
selectedModels,
setSelectedModels,
judgeModel,
setJudgeModel,
evalDatasets,
availableTags,
questionTypes,
setQuestionTypes,
selectedTags,
setSelectedTags,
searchKeyword,
setSearchKeyword,
questionCount,
setQuestionCount,
filteredTotal,
sampledIds,
hasSubjectiveQuestions,
hasShortAnswer,
hasOpenEnded,
shortAnswerScoreAnchors,
setShortAnswerScoreAnchors,
openEndedScoreAnchors,
setOpenEndedScoreAnchors,
initScoreAnchors,
loading,
error,
setError,
setSampledIds,
resetFilters,
resetForm
};
}

View File

@@ -0,0 +1,149 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
/**
* 评估任务列表 Hook
*/
export default function useEvalTasks(projectId) {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [total, setTotal] = useState(0);
// 加载任务列表
const loadTasks = useCallback(
async (isRefresh = false) => {
if (!projectId) return;
try {
if (!isRefresh) setLoading(true);
setError('');
const response = await fetch(`/api/projects/${projectId}/eval-tasks?page=${page}&pageSize=${pageSize}`);
const result = await response.json();
if (result.code === 0) {
setTasks(result.data.items || []);
setTotal(result.data.total || 0);
} else {
setError(result.error || '加载失败');
}
} catch (err) {
console.error('加载评估任务失败:', err);
setError('加载失败');
} finally {
if (!isRefresh) setLoading(false);
}
},
[projectId, page, pageSize]
);
// 初始加载和分页变化加载
useEffect(() => {
loadTasks();
}, [loadTasks]);
// 自动刷新进行中的任务
useEffect(() => {
const hasProcessingTasks = tasks.some(t => t.status === 0);
if (!hasProcessingTasks) return;
const interval = setInterval(() => loadTasks(true), 5000);
return () => clearInterval(interval);
}, [tasks, loadTasks]);
// 删除任务
const deleteTask = useCallback(
async taskId => {
try {
const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.code === 0) {
loadTasks();
return true;
} else {
setError(result.error || '删除失败');
return false;
}
} catch (err) {
console.error('删除任务失败:', err);
setError('删除失败');
return false;
}
},
[projectId]
);
// 中断任务
const interruptTask = useCallback(
async taskId => {
try {
const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'interrupt' })
});
const result = await response.json();
if (result.code === 0) {
loadTasks();
return true;
} else {
setError(result.error || '中断失败');
return false;
}
} catch (err) {
console.error('中断任务失败:', err);
setError('中断失败');
return false;
}
},
[projectId, loadTasks]
);
// 创建任务
const createTasks = useCallback(
async data => {
try {
const response = await fetch(`/api/projects/${projectId}/eval-tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.code === 0) {
loadTasks();
return { success: true, data: result.data };
} else {
return { success: false, error: result.error };
}
} catch (err) {
console.error('创建任务失败:', err);
return { success: false, error: '创建失败' };
}
},
[projectId, loadTasks]
);
return {
tasks,
loading,
error,
setError,
loadTasks,
deleteTask,
interruptTask,
createTasks,
page,
setPage,
pageSize,
setPageSize,
total
};
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
Container,
Typography,
Box,
Paper,
Button,
Grid,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TablePagination
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import AssessmentIcon from '@mui/icons-material/Assessment';
import { useTranslation } from 'react-i18next';
import useEvalTasks from './hooks/useEvalTasks';
import CreateEvalTaskDialog from './components/CreateEvalTaskDialog';
import EvalTaskCard from './components/EvalTaskCard';
import styles from './styles';
export default function EvalTasksPage() {
const { projectId } = useParams();
const router = useRouter();
const { t } = useTranslation();
const {
tasks,
loading,
error,
setError,
loadTasks,
deleteTask,
interruptTask,
page,
setPage,
pageSize,
setPageSize,
total
} = useEvalTasks(projectId);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialog, setDeleteDialog] = useState({ open: false, task: null });
const [interruptDialog, setInterruptDialog] = useState({ open: false, task: null });
const handleView = task => router.push(`/projects/${projectId}/eval-tasks/${task.id}`);
const handleDelete = task => setDeleteDialog({ open: true, task });
const handleInterrupt = task => setInterruptDialog({ open: true, task });
const handlePageChange = (event, newPage) => {
setPage(newPage + 1);
};
const handlePageSizeChange = event => {
setPageSize(parseInt(event.target.value, 10));
setPage(1);
};
const confirmDelete = async () => {
if (deleteDialog.task) {
await deleteTask(deleteDialog.task.id);
}
setDeleteDialog({ open: false, task: null });
};
const confirmInterrupt = async () => {
if (interruptDialog.task) {
await interruptTask(interruptDialog.task.id);
}
setInterruptDialog({ open: false, task: null });
};
return (
<Container maxWidth="xl" sx={styles.pageContainer}>
{/* 标题栏 */}
<Box sx={styles.header}>
<Typography variant="h5" sx={styles.headerTitle}>
{t('evalTasks.title')}
</Typography>
<Box sx={styles.headerActions}>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateDialogOpen(true)}>
{t('evalTasks.createTask')}
</Button>
</Box>
</Box>
{/* 错误提示 */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{/* 加载状态 */}
{loading && tasks.length === 0 && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{/* 空状态 */}
{!loading && tasks.length === 0 && (
<Paper variant="outlined" sx={styles.emptyState}>
<AssessmentIcon sx={styles.emptyIcon} />
<Typography variant="h6" color="text.secondary" sx={styles.emptyTitle}>
{t('evalTasks.noTasks')}
</Typography>
<Typography variant="body2" color="text.disabled" sx={styles.emptyHint}>
{t('evalTasks.noTasksHint')}
</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateDialogOpen(true)} size="large">
{t('evalTasks.createTask')}
</Button>
</Paper>
)}
{/* 任务列表 */}
{tasks.length > 0 && (
<>
<Grid container spacing={2.5}>
{tasks.map(task => (
<Grid item xs={12} sm={6} md={4} lg={3} key={task.id}>
<EvalTaskCard task={task} onView={handleView} onDelete={handleDelete} onInterrupt={handleInterrupt} />
</Grid>
))}
</Grid>
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<TablePagination
component="div"
count={total}
page={page - 1}
onPageChange={handlePageChange}
rowsPerPage={pageSize}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[12, 24, 48]}
labelRowsPerPage={t('datasets.rowsPerPage', '每页行数')}
/>
</Box>
</>
)}
{/* 创建任务对话框 */}
<CreateEvalTaskDialog
open={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
projectId={projectId}
onSuccess={loadTasks}
/>
{/* 删除确认对话框 */}
<Dialog open={deleteDialog.open} onClose={() => setDeleteDialog({ open: false, task: null })}>
<DialogTitle>{t('evalTasks.deleteConfirmTitle')}</DialogTitle>
<DialogContent>
<DialogContentText>{t('evalTasks.deleteConfirmMessage')}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialog({ open: false, task: null })}>{t('common.cancel')}</Button>
<Button onClick={confirmDelete} color="error" variant="contained">
{t('common.delete')}
</Button>
</DialogActions>
</Dialog>
{/* 中断确认对话框 */}
<Dialog open={interruptDialog.open} onClose={() => setInterruptDialog({ open: false, task: null })}>
<DialogTitle>{t('evalTasks.interruptConfirmTitle')}</DialogTitle>
<DialogContent>
<DialogContentText>{t('evalTasks.interruptConfirmMessage')}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setInterruptDialog({ open: false, task: null })}>{t('common.cancel')}</Button>
<Button onClick={confirmInterrupt} color="warning" variant="contained">
{t('evalTasks.interrupt')}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -0,0 +1,280 @@
/**
* 评估任务页面样式
*/
export const evalTasksStyles = {
// 页面容器
pageContainer: {
py: 3,
minHeight: '100vh'
},
// 页头
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 3
},
headerTitle: {
fontWeight: 600
},
headerActions: {
display: 'flex',
gap: 1
},
// 空状态
emptyState: {
p: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 400,
borderRadius: 3,
bgcolor: 'background.paper'
},
emptyIcon: {
fontSize: 80,
color: 'text.disabled',
mb: 2
},
emptyTitle: {
mb: 1,
fontWeight: 500
},
emptyHint: {
mb: 4,
textAlign: 'center',
maxWidth: 400
},
// 任务卡片
taskCard: theme => ({
height: '100%',
cursor: 'pointer',
transition: 'all 0.2s ease',
borderRadius: 2,
overflow: 'hidden',
border: `1px solid ${theme.palette.divider}`,
'&:hover': {
boxShadow: theme.shadows[6],
transform: 'translateY(-4px)',
borderColor: theme.palette.primary.main
}
}),
taskCardContent: {
p: 2.5
},
taskCardHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 2
},
taskCardModel: {
flex: 1,
overflow: 'hidden'
},
taskCardModelName: {
fontWeight: 600,
fontSize: '0.95rem',
lineHeight: 1.3
},
taskCardTime: {
mt: 0.5,
fontSize: '0.75rem'
},
taskCardStatus: {
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2
},
taskCardProgress: {
mb: 2
},
progressBar: {
height: 6,
borderRadius: 3
},
taskCardStats: {
display: 'flex',
gap: 1,
flexWrap: 'wrap'
},
// 统计卡片
statsCard: theme => ({
height: '100%',
borderRadius: 2,
border: `1px solid ${theme.palette.divider}`,
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: theme.shadows[2]
}
}),
statsCardContent: {
p: 2.5
},
statsLabel: {
fontSize: '0.75rem',
color: 'text.secondary',
mb: 1,
textTransform: 'uppercase',
letterSpacing: 0.5
},
statsValue: {
fontWeight: 700,
fontSize: '1.75rem',
lineHeight: 1.2
},
// 按题型统计
typeStatsContainer: {
p: 2.5,
mb: 3,
borderRadius: 2
},
typeStatsTitle: {
fontWeight: 600,
mb: 2
},
typeStatsItem: theme => ({
textAlign: 'center',
p: 1.5,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)',
borderRadius: 1.5,
border: `1px solid ${theme.palette.divider}`
}),
typeStatsLabel: {
fontSize: '0.7rem',
color: 'text.secondary',
mb: 0.5
},
typeStatsScore: {
fontWeight: 700,
fontSize: '1.1rem'
},
typeStatsPercent: {
fontSize: '0.7rem',
color: 'text.secondary'
},
// 结果表格
resultsTable: {
overflow: 'hidden',
borderRadius: 2
},
resultsTableHeader: {
fontWeight: 600,
p: 2,
borderBottom: 1,
borderColor: 'divider'
},
resultsTableContainer: {
maxHeight: 600
},
resultRow: {
cursor: 'pointer',
'&:hover': {
bgcolor: 'action.hover'
}
},
resultQuestion: {
maxWidth: 400
},
resultScore: correct => ({
fontWeight: 'bold',
color: correct ? 'success.main' : 'error.main'
}),
resultExpandedContent: {
py: 2.5,
px: 1.5
},
resultAnswerBox: isCorrect => theme => ({
p: 2,
mt: 1,
borderRadius: 1.5,
bgcolor: isCorrect
? theme.palette.mode === 'dark'
? 'rgba(46, 125, 50, 0.15)'
: 'rgba(46, 125, 50, 0.08)'
: theme.palette.mode === 'dark'
? 'rgba(211, 47, 47, 0.15)'
: 'rgba(211, 47, 47, 0.08)',
border: `1px solid ${isCorrect ? theme.palette.success.main : theme.palette.error.main}`
}),
resultReferenceBox: {
p: 2,
mt: 1,
borderRadius: 1.5,
bgcolor: 'action.hover'
},
resultJudgeBox: {
p: 2,
mt: 1,
borderRadius: 1.5,
bgcolor: 'action.hover'
},
// 对话框
dialogContent: {
mt: 1
},
dialogSection: {
mb: 3
},
dialogDivider: {
my: 2
},
dialogInfoBox: theme => ({
p: 2,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)',
borderRadius: 1.5,
border: `1px solid ${theme.palette.divider}`
}),
dialogWarning: {
mt: 1,
color: 'warning.main',
fontWeight: 500
}
};
export default evalTasksStyles;