first-update
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 对应 ✅/True,B 对应 ❌/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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
188
easy-dataset-main/app/projects/[projectId]/eval-tasks/page.js
Normal file
188
easy-dataset-main/app/projects/[projectId]/eval-tasks/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
280
easy-dataset-main/app/projects/[projectId]/eval-tasks/styles.js
Normal file
280
easy-dataset-main/app/projects/[projectId]/eval-tasks/styles.js
Normal 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;
|
||||
Reference in New Issue
Block a user