first-update
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Paper,
|
||||
Chip,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Stack,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
FormGroup,
|
||||
Checkbox as MuiCheckbox
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import ShortTextIcon from '@mui/icons-material/ShortText';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import TagIcon from '@mui/icons-material/Tag';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
|
||||
import useEvalDatasetDetails from './useEvalDatasetDetails';
|
||||
import EvalDatasetHeader from '../components/EvalDatasetHeader';
|
||||
import EvalEditableField from '../components/EvalEditableField';
|
||||
import TagSelector from '@/components/datasets/TagSelector';
|
||||
|
||||
// 题型图标和颜色映射
|
||||
const QUESTION_TYPE_CONFIG = {
|
||||
true_false: {
|
||||
icon: CheckCircleIcon,
|
||||
color: 'success',
|
||||
bgColor: 'success.light'
|
||||
},
|
||||
single_choice: {
|
||||
icon: RadioButtonCheckedIcon,
|
||||
color: 'primary',
|
||||
bgColor: 'primary.light'
|
||||
},
|
||||
multiple_choice: {
|
||||
icon: CheckBoxIcon,
|
||||
color: 'secondary',
|
||||
bgColor: 'secondary.light'
|
||||
},
|
||||
short_answer: {
|
||||
icon: ShortTextIcon,
|
||||
color: 'warning',
|
||||
bgColor: 'warning.light'
|
||||
},
|
||||
open_ended: {
|
||||
icon: NotesIcon,
|
||||
color: 'info',
|
||||
bgColor: 'info.light'
|
||||
}
|
||||
};
|
||||
|
||||
export default function EvalDatasetDetailPage() {
|
||||
const { projectId, evalId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
|
||||
const { data, loading, error, handleNavigate, handleSave, handleDelete } = useEvalDatasetDetails(projectId, evalId);
|
||||
|
||||
// 获取项目中已使用的标签
|
||||
useEffect(() => {
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/tags`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setAvailableTags(result.tags || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取可用标签失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId && !loading) {
|
||||
fetchAvailableTags();
|
||||
}
|
||||
}, [projectId, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '70vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{error || t('eval.notFound')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const typeConfig = QUESTION_TYPE_CONFIG[data.questionType] || QUESTION_TYPE_CONFIG.short_answer;
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
// 解析选项
|
||||
let options = [];
|
||||
try {
|
||||
options = data.options ? (typeof data.options === 'string' ? JSON.parse(data.options) : data.options) : [];
|
||||
} catch (e) {
|
||||
options = [];
|
||||
}
|
||||
|
||||
// 渲染选项预览
|
||||
const renderOptionsPreview = value => {
|
||||
let opts = [];
|
||||
try {
|
||||
opts = value ? (typeof value === 'string' ? JSON.parse(value) : value) : [];
|
||||
} catch (e) {
|
||||
return <Typography color="error">Invalid JSON format</Typography>;
|
||||
}
|
||||
|
||||
if (!Array.isArray(opts) || opts.length === 0) {
|
||||
return <Typography color="text.secondary">{t('common.noData')}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{opts.map((option, index) => {
|
||||
const optionLabel = String.fromCharCode(65 + index);
|
||||
const isCorrect =
|
||||
data.questionType === 'multiple_choice'
|
||||
? (Array.isArray(data.correctAnswer)
|
||||
? data.correctAnswer
|
||||
: JSON.parse(data.correctAnswer || '[]')
|
||||
).includes(optionLabel)
|
||||
: data.correctAnswer === optionLabel;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: isCorrect ? alpha(theme.palette.success.main, 0.08) : 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: isCorrect ? alpha(theme.palette.success.main, 0.3) : 'divider'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: isCorrect ? 'success.main' : 'text.secondary',
|
||||
minWidth: 24
|
||||
}}
|
||||
>
|
||||
{optionLabel}.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: isCorrect ? 'success.dark' : 'text.primary'
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</Typography>
|
||||
{isCorrect && (
|
||||
<Chip
|
||||
label={t('eval.correct')}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{ ml: 'auto', height: 20, fontSize: '0.75rem' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染答案编辑组件
|
||||
const renderAnswerEditor = (currentValue, onChange) => {
|
||||
if (data.questionType === 'true_false') {
|
||||
return (
|
||||
<RadioGroup value={currentValue} onChange={e => onChange(e.target.value)} row>
|
||||
<FormControlLabel value="✅" control={<Radio />} label={t('eval.correct')} />
|
||||
<FormControlLabel value="❌" control={<Radio />} label={t('eval.wrong')} />
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.questionType === 'single_choice') {
|
||||
return (
|
||||
<RadioGroup value={currentValue} onChange={e => onChange(e.target.value)}>
|
||||
{options.map((_, index) => {
|
||||
const label = String.fromCharCode(65 + index);
|
||||
return (
|
||||
<FormControlLabel key={label} value={label} control={<Radio />} label={`${label}. ${options[index]}`} />
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.questionType === 'multiple_choice') {
|
||||
const selected = Array.isArray(currentValue) ? currentValue : JSON.parse(currentValue || '[]');
|
||||
const handleChange = label => {
|
||||
const newSelected = selected.includes(label) ? selected.filter(i => i !== label) : [...selected, label].sort();
|
||||
onChange(JSON.stringify(newSelected));
|
||||
};
|
||||
|
||||
return (
|
||||
<FormGroup>
|
||||
{options.map((_, index) => {
|
||||
const label = String.fromCharCode(65 + index);
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={label}
|
||||
control={<MuiCheckbox checked={selected.includes(label)} onChange={() => handleChange(label)} />}
|
||||
label={`${label}. ${options[index]}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return null; // 简答题和开放题保持默认文本框
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 6 }}>
|
||||
<EvalDatasetHeader projectId={projectId} onNavigate={handleNavigate} onDelete={handleDelete} />
|
||||
|
||||
<Grid container spacing={3} alignItems="flex-start">
|
||||
{/* 左侧主要内容 */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ p: 4, borderRadius: 3 }}>
|
||||
{/* 题型标识 */}
|
||||
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Chip
|
||||
icon={<TypeIcon sx={{ fontSize: '18px !important' }} />}
|
||||
label={t(`eval.questionTypes.${data.questionType}`)}
|
||||
color={typeConfig.color}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
py: 0.5,
|
||||
height: 32
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
<AccessTimeIcon sx={{ fontSize: 14 }} />
|
||||
{new Date(data.createAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 问题 */}
|
||||
<EvalEditableField
|
||||
label={t('eval.question')}
|
||||
value={data.question}
|
||||
onSave={val => handleSave('question', val)}
|
||||
placeholder={t('eval.questionPlaceholder')}
|
||||
/>
|
||||
|
||||
{/* 选项 (仅选择题) */}
|
||||
{(data.questionType === 'single_choice' || data.questionType === 'multiple_choice') && (
|
||||
<EvalEditableField
|
||||
label={t('eval.options')}
|
||||
value={typeof data.options === 'string' ? data.options : JSON.stringify(data.options, null, 2)}
|
||||
onSave={val => handleSave('options', val)}
|
||||
placeholder={'["Option A", "Option B", ...]'}
|
||||
renderPreview={() => renderOptionsPreview(data.options)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 答案 */}
|
||||
<EvalEditableField
|
||||
label={t('eval.answer')}
|
||||
value={data.correctAnswer}
|
||||
onSave={val => handleSave('correctAnswer', val)}
|
||||
placeholder={t('eval.answerPlaceholder')}
|
||||
renderEditor={(val, setVal) => renderAnswerEditor(val, setVal)}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* 右侧侧边栏 */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack spacing={3} sx={{ position: 'sticky', top: 24 }}>
|
||||
{/* 来源信息 */}
|
||||
<Card variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<DescriptionIcon fontSize="small" />
|
||||
{t('eval.sourceChunk')}
|
||||
</Typography>
|
||||
{data.chunks ? (
|
||||
<>
|
||||
<Chip
|
||||
label={data.chunks.name || data.chunks.fileName}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ mb: 2, maxWidth: '100%' }}
|
||||
/>
|
||||
{data.chunks.content && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: 'text.secondary',
|
||||
borderRadius: 2,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{data.chunks.content}
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
{t('common.noData')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 标签和备注 */}
|
||||
<Card variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<TagIcon fontSize="small" />
|
||||
{t('eval.tags')}
|
||||
</Typography>
|
||||
<TagSelector
|
||||
value={
|
||||
data.tags
|
||||
? typeof data.tags === 'string'
|
||||
? data.tags
|
||||
.split(/[,,]/)
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
: []
|
||||
}
|
||||
onChange={newTags => handleSave('tags', newTags.join(', '))}
|
||||
availableTags={availableTags}
|
||||
placeholder={t('eval.tagsPlaceholder')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box>
|
||||
<EvalEditableField
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<NotesIcon fontSize="small" />
|
||||
{t('eval.note')}
|
||||
</Box>
|
||||
}
|
||||
value={data.note}
|
||||
onSave={val => handleSave('note', val)}
|
||||
placeholder={t('eval.notePlaceholder')}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function useEvalDatasetDetails(projectId, evalId) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 编辑状态
|
||||
const [editingField, setEditingField] = useState(null); // 'question', 'options', 'correctAnswer', 'note', 'tags'
|
||||
const [fieldValue, setFieldValue] = useState('');
|
||||
// 获取详情
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('未找到该题目');
|
||||
}
|
||||
throw new Error('获取数据失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, evalId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 导航
|
||||
const handleNavigate = async direction => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=${direction}`);
|
||||
if (response.ok) {
|
||||
const neighbor = await response.json();
|
||||
if (neighbor && neighbor.id) {
|
||||
router.push(`/projects/${projectId}/eval-datasets/${neighbor.id}`);
|
||||
} else {
|
||||
toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条数据了`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Navigation error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始编辑
|
||||
const handleStartEdit = (field, value) => {
|
||||
setEditingField(field);
|
||||
// 对于 options,如果是数组则转为 JSON 字符串编辑,或者在组件层面处理
|
||||
// 这里假设 value 已经是适合编辑的格式
|
||||
setFieldValue(value);
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancelEdit = () => {
|
||||
setEditingField(null);
|
||||
setFieldValue('');
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSave = async (field, value) => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('保存失败');
|
||||
|
||||
const updated = await response.json();
|
||||
setData(prev => ({ ...prev, ...updated })); // 更新本地数据
|
||||
setEditingField(null);
|
||||
toast.success('保存成功');
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('确定要删除这条数据吗?此操作不可撤销。')) return;
|
||||
|
||||
try {
|
||||
// 先尝试获取下一条,以便删除后跳转
|
||||
const nextResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=next`);
|
||||
let nextId = null;
|
||||
if (nextResponse.ok) {
|
||||
const next = await nextResponse.json();
|
||||
if (next && next.id) nextId = next.id;
|
||||
}
|
||||
|
||||
// 如果没有下一条,尝试获取上一条
|
||||
if (!nextId) {
|
||||
const prevResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=prev`);
|
||||
if (prevResponse.ok) {
|
||||
const prev = await prevResponse.json();
|
||||
if (prev && prev.id) nextId = prev.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const deleteResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!deleteResponse.ok) throw new Error('删除失败');
|
||||
|
||||
toast.success('删除成功');
|
||||
|
||||
if (nextId) {
|
||||
router.replace(`/projects/${projectId}/eval-datasets/${nextId}`);
|
||||
} else {
|
||||
router.push(`/projects/${projectId}/eval-datasets`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
editingField,
|
||||
fieldValue,
|
||||
setFieldValue,
|
||||
handleNavigate,
|
||||
handleStartEdit,
|
||||
handleCancelEdit,
|
||||
handleSave,
|
||||
handleDelete
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Card,
|
||||
CardActionArea,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
InputAdornment,
|
||||
CircularProgress,
|
||||
DialogTitle,
|
||||
DialogContentText
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
import { StyledDialogTitle } from './ImportDialog.styles';
|
||||
import { DATA_SETS } from '../constants';
|
||||
|
||||
export default function BuiltinDatasetDialog({ open, onClose, projectId, onSuccess }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedDataset, setSelectedDataset] = useState(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const isZh = i18n.language.startsWith('zh');
|
||||
|
||||
// 过滤数据集
|
||||
const filteredDatasets = useMemo(() => {
|
||||
if (!keyword) return DATA_SETS;
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
return DATA_SETS.filter(
|
||||
ds =>
|
||||
ds.zh.toLowerCase().includes(lowerKeyword) ||
|
||||
ds.en.toLowerCase().includes(lowerKeyword) ||
|
||||
ds.type.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}, [keyword]);
|
||||
|
||||
const handleCardClick = dataset => {
|
||||
setSelectedDataset(dataset);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmClose = () => {
|
||||
setConfirmOpen(false);
|
||||
setSelectedDataset(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedDataset) return;
|
||||
|
||||
setDownloading(true);
|
||||
setConfirmOpen(false);
|
||||
|
||||
try {
|
||||
const cdnUrl = `https://raw.githubusercontent.com/ConardLi/easy-dataset-eval/main/${selectedDataset.file}`;
|
||||
const response = await fetch(cdnUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch dataset: ${response.statusText}`);
|
||||
}
|
||||
const jsonData = await response.blob();
|
||||
|
||||
const formData = new FormData();
|
||||
const file = new File([jsonData], `${selectedDataset.en}.json`, { type: 'application/json' });
|
||||
formData.append('file', file);
|
||||
formData.append('questionType', selectedDataset.type);
|
||||
const tags = `[${selectedDataset.level}] ${selectedDataset.en}`;
|
||||
formData.append('tags', tags);
|
||||
|
||||
const importResponse = await fetch(`/api/projects/${projectId}/eval-datasets/import`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await importResponse.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
onSuccess?.(result.data);
|
||||
handleClose();
|
||||
} else {
|
||||
console.error(result.error);
|
||||
alert(result.error || t('evalDatasets.import.failed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
alert(error.message || t('evalDatasets.import.failed'));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
setSelectedDataset(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (downloading) return;
|
||||
setKeyword('');
|
||||
setSelectedDataset(null);
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<StyledDialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<StorageIcon color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{t('evalDatasets.import.builtinTitle', '选择内置数据集')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={handleClose} disabled={downloading} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</StyledDialogTitle>
|
||||
|
||||
<DialogContent
|
||||
dividers
|
||||
sx={{
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '70vh',
|
||||
bgcolor: alpha(theme.palette.grey[50], 0.5)
|
||||
}}
|
||||
>
|
||||
{/* 搜索栏 */}
|
||||
<Box sx={{ p: 2, bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={t('evalDatasets.import.searchPlaceholder', '搜索数据集...')}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: 'text.disabled', fontSize: 20 }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { borderRadius: 2 }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 数据集列表 */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
{downloading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} thickness={4} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
{t('evalDatasets.import.downloading', '下载并导入中...')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 1.5,
|
||||
alignContent: 'start'
|
||||
}}
|
||||
>
|
||||
{filteredDatasets.map((ds, index) => {
|
||||
const difficultyColor = ds.level === 'easy' ? 'success.main' : 'warning.main';
|
||||
const typeLabel = t(`eval.questionTypes.${ds.type}`, ds.type);
|
||||
const tooltipTitle = (
|
||||
<Box sx={{ display: 'flex', gap: 0.8, p: 0.5 }}>
|
||||
<Chip
|
||||
label={typeLabel}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: alpha('#fff', 0.15),
|
||||
color: '#fff',
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#fff', 0.1),
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={ds.level.toUpperCase()}
|
||||
size="small"
|
||||
color={ds.level === 'easy' ? 'success' : 'warning'}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 800,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={tooltipTitle}
|
||||
arrow
|
||||
placement="top"
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: 'rgba(33, 33, 33, 0.95)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
borderRadius: 1.5,
|
||||
padding: '4px 8px'
|
||||
}
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: 'rgba(33, 33, 33, 0.95)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderLeft: '4px solid',
|
||||
borderLeftColor: difficultyColor,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
bgcolor: 'background.paper',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 6px 16px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
borderColor: theme.palette.primary.main,
|
||||
'& .dataset-title': { color: 'primary.main' }
|
||||
}
|
||||
}}
|
||||
onClick={() => handleCardClick(ds)}
|
||||
>
|
||||
<CardActionArea
|
||||
sx={{
|
||||
p: 1.5,
|
||||
height: '100%',
|
||||
minHeight: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
className="dataset-title"
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
color: 'text.primary',
|
||||
transition: 'color 0.2s',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{isZh ? ds.zh : ds.en}
|
||||
</Typography>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={confirmOpen}
|
||||
onClose={handleConfirmClose}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { borderRadius: 3 } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 700, pb: 1 }}>
|
||||
{t('evalDatasets.import.confirmImportTitle', '确认导入')}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pb: 1 }}>
|
||||
<DialogContentText sx={{ color: 'text.primary' }}>
|
||||
{selectedDataset &&
|
||||
t('evalDatasets.import.confirmImportMessage', {
|
||||
name: isZh ? selectedDataset.zh : selectedDataset.en
|
||||
})}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2.5, pt: 1.5 }}>
|
||||
<Button onClick={handleConfirmClose} color="inherit" sx={{ fontWeight: 600 }}>
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button onClick={handleImport} variant="contained" autoFocus sx={{ fontWeight: 600, px: 3 }}>
|
||||
{t('evalDatasets.import.import', '导入')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, Box, Typography, Chip, Checkbox, IconButton, Tooltip, Divider } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import ShortTextIcon from '@mui/icons-material/ShortText';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// 题型图标和颜色映射
|
||||
const QUESTION_TYPE_CONFIG = {
|
||||
true_false: {
|
||||
icon: CheckCircleIcon,
|
||||
color: 'success',
|
||||
bgColor: 'success.light'
|
||||
},
|
||||
single_choice: {
|
||||
icon: RadioButtonCheckedIcon,
|
||||
color: 'primary',
|
||||
bgColor: 'primary.light'
|
||||
},
|
||||
multiple_choice: {
|
||||
icon: CheckBoxIcon,
|
||||
color: 'secondary',
|
||||
bgColor: 'secondary.light'
|
||||
},
|
||||
short_answer: {
|
||||
icon: ShortTextIcon,
|
||||
color: 'warning',
|
||||
bgColor: 'warning.light'
|
||||
},
|
||||
open_ended: {
|
||||
icon: NotesIcon,
|
||||
color: 'info',
|
||||
bgColor: 'info.light'
|
||||
}
|
||||
};
|
||||
|
||||
export default function EvalDatasetCard({ item, selected, onSelect, onEdit, onDelete, projectId }) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const typeConfig = QUESTION_TYPE_CONFIG[item.questionType] || QUESTION_TYPE_CONFIG.short_answer;
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
// 解析选项
|
||||
const options = item.options
|
||||
? typeof item.options === 'string'
|
||||
? JSON.parse(item.options || '[]')
|
||||
: item.options
|
||||
: [];
|
||||
|
||||
// 解析答案
|
||||
const correctAnswer = item.correctAnswer;
|
||||
|
||||
const handleCardClick = e => {
|
||||
// 如果点击的是复选框或按钮,不跳转
|
||||
if (e.target.closest('.MuiCheckbox-root') || e.target.closest('.MuiIconButton-root')) {
|
||||
return;
|
||||
}
|
||||
router.push(`/projects/${projectId}/eval-datasets/${item.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
onClick={handleCardClick}
|
||||
sx={{
|
||||
height: 'fit-content',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
borderColor: selected ? theme.palette.primary.main : theme.palette.divider,
|
||||
bgcolor: selected ? alpha(theme.palette.primary.main, 0.04) : 'background.paper',
|
||||
borderRadius: 2,
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: `0 8px 24px ${alpha(theme.palette.common.black, 0.08)}`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flex: 1, display: 'flex', flexDirection: 'column', p: 2.5 }}>
|
||||
{/* 头部:题型标签和操作 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={selected}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id);
|
||||
}}
|
||||
sx={{ p: 0.5, ml: -0.5 }}
|
||||
/>
|
||||
<Chip
|
||||
icon={<TypeIcon sx={{ fontSize: '16px !important' }} />}
|
||||
label={t(`eval.questionTypes.${item.questionType}`)}
|
||||
size="small"
|
||||
color={typeConfig.color}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
borderWidth: '1.5px',
|
||||
bgcolor: alpha(theme.palette[typeConfig.color].main, 0.05)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={t('common.edit')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onEdit(item);
|
||||
}}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'primary.main', bgcolor: alpha(theme.palette.primary.main, 0.1) }
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'error.main', bgcolor: alpha(theme.palette.error.main, 0.1) }
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 问题内容 */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.6,
|
||||
color:
|
||||
item.questionType === 'true_false'
|
||||
? correctAnswer === '✅'
|
||||
? 'success.main'
|
||||
: 'error.main'
|
||||
: 'text.primary',
|
||||
display: 'inline'
|
||||
}}
|
||||
>
|
||||
{item.questionType === 'true_false' && correctAnswer} {item.question}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 选项列表(仅单选/多选显示) */}
|
||||
{(item.questionType === 'single_choice' || item.questionType === 'multiple_choice') && options.length > 0 && (
|
||||
<Box sx={{ mb: 2, flex: 1 }}>
|
||||
{(item.questionType === 'multiple_choice' ? options : options.slice(0, 4)).map((option, index) => {
|
||||
const optionLabel = String.fromCharCode(65 + index); // A, B, C, D
|
||||
// 解析多选题答案,支持多种格式:数组、JSON字符串、逗号分隔字符串
|
||||
const parseMultipleAnswers = answer => {
|
||||
if (Array.isArray(answer)) return answer;
|
||||
if (!answer) return [];
|
||||
// 尝试解析 JSON 数组
|
||||
if (answer.startsWith('[')) {
|
||||
try {
|
||||
return JSON.parse(answer);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// 逗号分隔字符串格式,如 "A,B,D"
|
||||
return answer.split(',').map(s => s.trim());
|
||||
};
|
||||
const isCorrect =
|
||||
item.questionType === 'multiple_choice'
|
||||
? parseMultipleAnswers(correctAnswer).includes(optionLabel)
|
||||
: correctAnswer === optionLabel;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
p: '4px 8px',
|
||||
borderRadius: 1,
|
||||
bgcolor: isCorrect ? alpha(theme.palette.success.main, 0.08) : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: isCorrect ? alpha(theme.palette.success.main, 0.3) : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: isCorrect ? 'success.main' : 'text.secondary',
|
||||
minWidth: 16
|
||||
}}
|
||||
>
|
||||
{optionLabel}.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: isCorrect ? 'success.dark' : 'text.secondary',
|
||||
fontSize: '0.875rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{item.questionType === 'single_choice' && options.length > 4 && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ pl: 1, mt: 0.5, display: 'block' }}>
|
||||
... +{options.length - 4} {t('eval.moreOptions')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 非选择题且非判断题答案 */}
|
||||
{item.questionType !== 'single_choice' &&
|
||||
item.questionType !== 'multiple_choice' &&
|
||||
item.questionType !== 'true_false' &&
|
||||
correctAnswer && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
mb: 2,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
{t('eval.answer')}:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 4,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{correctAnswer}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5, opacity: 0.6 }} />
|
||||
|
||||
{/* 底部元信息 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
{item.chunks ? (
|
||||
<Tooltip title={item.chunks.name || item.chunks.fileName}>
|
||||
<Chip
|
||||
label={item.chunks.name || item.chunks.fileName}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
height: 22,
|
||||
maxWidth: 140,
|
||||
borderColor: 'divider',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Box />
|
||||
)}
|
||||
|
||||
{item.tags && (
|
||||
<Tooltip title={item.tags}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, overflow: 'hidden', maxWidth: 120 }}>
|
||||
{item.tags
|
||||
.split(/[,,]/)
|
||||
.slice(0, 2)
|
||||
.map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
height: 22,
|
||||
bgcolor: alpha(theme.palette.info.main, 0.08),
|
||||
color: 'info.dark',
|
||||
maxWidth: 80
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{item.tags.split(/[,,]/).length > 2 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11, alignSelf: 'center' }}>
|
||||
+{item.tags.split(/[,,]/).length - 2}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Divider, Typography, IconButton, Paper, Tooltip } from '@mui/material';
|
||||
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function EvalDatasetHeader({ projectId, onNavigate, onDelete }) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<NavigateBeforeIcon />}
|
||||
onClick={() => router.push(`/projects/${projectId}/eval-datasets`)}
|
||||
>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="h6">{t('eval.detail')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<IconButton onClick={() => onNavigate('prev')} title={t('common.prev')}>
|
||||
<NavigateBeforeIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onNavigate('next')} title={t('common.next')}>
|
||||
<NavigateNextIcon />
|
||||
</IconButton>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Chip,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function EvalDatasetList({ items, selectedIds, onSelect, onSelectAll, onEdit, onDelete, onView }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isAllSelected = items.length > 0 && selectedIds.length === items.length;
|
||||
const isIndeterminate = selectedIds.length > 0 && selectedIds.length < items.length;
|
||||
|
||||
// 题型颜色映射
|
||||
const getTypeColor = type => {
|
||||
const colors = {
|
||||
true_false: 'success',
|
||||
single_choice: 'primary',
|
||||
multiple_choice: 'secondary',
|
||||
short_answer: 'warning',
|
||||
open_ended: 'info'
|
||||
};
|
||||
return colors[type] || 'default';
|
||||
};
|
||||
|
||||
// 格式化答案显示
|
||||
const formatAnswer = item => {
|
||||
const { questionType, correctAnswer, options } = item;
|
||||
|
||||
if (questionType === 'true_false') {
|
||||
return correctAnswer;
|
||||
}
|
||||
|
||||
if (questionType === 'single_choice' || questionType === 'multiple_choice') {
|
||||
return correctAnswer;
|
||||
}
|
||||
|
||||
// 非选择题,截断显示
|
||||
if (correctAnswer && correctAnswer.length > 50) {
|
||||
return correctAnswer.substring(0, 50) + '...';
|
||||
}
|
||||
return correctAnswer || '-';
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox indeterminate={isIndeterminate} checked={isAllSelected} onChange={onSelectAll} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 100 }}>{t('eval.questionType')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 300 }}>{t('eval.question')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 150 }}>{t('eval.answer')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 120 }}>{t('eval.sourceChunk')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, width: 120 }} align="center">
|
||||
{t('common.actions')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map(item => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
hover
|
||||
selected={selectedIds.includes(item.id)}
|
||||
sx={{ '&:last-child td': { border: 0 } }}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox checked={selectedIds.includes(item.id)} onChange={() => onSelect(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={t(`eval.questionTypes.${item.questionType}`)}
|
||||
size="small"
|
||||
color={getTypeColor(item.questionType)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{item.question}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>
|
||||
{formatAnswer(item)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.chunks ? (
|
||||
<Chip
|
||||
label={item.chunks.name || item.chunks.fileName}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ maxWidth: 150 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
-
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 0.5 }}>
|
||||
<Tooltip title={t('datasets.viewDetails')}>
|
||||
<IconButton size="small" onClick={() => onView(item)}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(item.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">{t('common.noData')}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Typography, Button, TextField, IconButton, Paper } from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function EvalEditableField({
|
||||
label,
|
||||
value,
|
||||
multiline = true,
|
||||
onSave,
|
||||
placeholder,
|
||||
renderPreview, // Optional custom preview renderer
|
||||
renderEditor // Optional custom editor renderer (currentValue, onChange) => ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditValue(value || '');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
await onSave(editValue);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ fontWeight: 600, mr: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{!editing && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleStartEdit}
|
||||
sx={{
|
||||
color: 'text.disabled',
|
||||
'&:hover': { color: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{editing ? (
|
||||
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'background.default', borderRadius: 2 }}>
|
||||
{renderEditor && renderEditor(editValue, setEditValue) ? (
|
||||
<Box sx={{ mb: 2 }}>{renderEditor(editValue, setEditValue)}</Box>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline={multiline}
|
||||
minRows={multiline ? 3 : 1}
|
||||
maxRows={15}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button size="small" startIcon={<CancelIcon />} onClick={handleCancel} color="inherit">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="small" variant="contained" startIcon={<SaveIcon />} onClick={handleSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
minHeight: 40,
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.02),
|
||||
boxShadow: `0 0 0 1px ${theme.palette.primary.main}`
|
||||
}
|
||||
}}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{renderPreview ? (
|
||||
renderPreview(value)
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: value ? 'text.primary' : 'text.disabled',
|
||||
fontStyle: value ? 'normal' : 'italic',
|
||||
lineHeight: 1.6
|
||||
}}
|
||||
>
|
||||
{value || t('common.noData')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
ToggleButton,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Autocomplete,
|
||||
TextField,
|
||||
Menu,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||
import ViewListIcon from '@mui/icons-material/ViewList';
|
||||
import DeleteIcon from '@mui/icons-material/DeleteOutline'; // 使用 Outline 版本更精致
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircleOutline'; // 统一使用 Outline 风格图标
|
||||
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBoxOutlineBlank'; // 或者 CheckBox
|
||||
import ShortTextIcon from '@mui/icons-material/ShortText';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
ToolbarContainer,
|
||||
FilterGroup,
|
||||
FilterButton,
|
||||
SearchWrapper,
|
||||
StyledInputBase,
|
||||
ActionGroup,
|
||||
ActionButton,
|
||||
DeleteActionButton,
|
||||
StyledToggleButtonGroup
|
||||
} from './EvalToolbar.styles';
|
||||
|
||||
const STATS_CONFIG = [
|
||||
{ key: 'true_false', icon: CheckCircleIcon, color: 'success' },
|
||||
{ key: 'single_choice', icon: RadioButtonCheckedIcon, color: 'primary' },
|
||||
{ key: 'multiple_choice', icon: CheckBoxIcon, color: 'secondary' },
|
||||
{ key: 'short_answer', icon: ShortTextIcon, color: 'warning' },
|
||||
{ key: 'open_ended', icon: NotesIcon, color: 'info' }
|
||||
];
|
||||
|
||||
export default function EvalToolbar({
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
selectedCount,
|
||||
onDeleteSelected,
|
||||
stats,
|
||||
questionType,
|
||||
onTypeChange,
|
||||
tags,
|
||||
onTagsChange,
|
||||
onImport,
|
||||
onBuiltinImport,
|
||||
onExport
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const [importAnchorEl, setImportAnchorEl] = useState(null);
|
||||
|
||||
const handleImportClick = event => {
|
||||
setImportAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleImportClose = () => {
|
||||
setImportAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleCustomImport = () => {
|
||||
handleImportClose();
|
||||
onImport?.();
|
||||
};
|
||||
|
||||
const handleBuiltinImport = () => {
|
||||
handleImportClose();
|
||||
onBuiltinImport?.();
|
||||
};
|
||||
|
||||
const tagOptions = stats?.byTag
|
||||
? Object.keys(stats.byTag).map(tag => ({
|
||||
label: tag,
|
||||
count: stats.byTag[tag]
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ToolbarContainer elevation={0} variant="outlined">
|
||||
{/* 顶部:题型统计筛选 */}
|
||||
<FilterGroup>
|
||||
{stats &&
|
||||
STATS_CONFIG.map(({ key, icon: Icon, color }) => {
|
||||
const count = stats.byType?.[key] || 0;
|
||||
const isActive = questionType === key;
|
||||
|
||||
return (
|
||||
<FilterButton
|
||||
key={key}
|
||||
startIcon={<Icon sx={{ fontSize: 18 }} />}
|
||||
active={isActive}
|
||||
colorType={color}
|
||||
onClick={() => onTypeChange(isActive ? '' : key)}
|
||||
>
|
||||
{t(`eval.questionTypes.${key}`)}
|
||||
<Box component="span" sx={{ ml: 0.8, opacity: 0.9, fontSize: '0.8em' }}>
|
||||
({count})
|
||||
</Box>
|
||||
</FilterButton>
|
||||
);
|
||||
})}
|
||||
</FilterGroup>
|
||||
|
||||
<Divider sx={{ opacity: 0.6 }} />
|
||||
|
||||
{/* 底部:筛选和操作 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
{/* 左侧:筛选器组 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1, minWidth: 300 }}>
|
||||
{/* 搜索框 */}
|
||||
<SearchWrapper>
|
||||
<IconButton sx={{ p: '8px' }} aria-label="search" disabled>
|
||||
<SearchIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
</IconButton>
|
||||
<StyledInputBase
|
||||
placeholder={t('eval.searchPlaceholder', '搜索题目内容...')}
|
||||
value={keyword}
|
||||
onChange={e => onKeywordChange(e.target.value)}
|
||||
/>
|
||||
</SearchWrapper>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={tagOptions}
|
||||
getOptionLabel={option => `${option.label} (${option.count})`}
|
||||
value={tagOptions.filter(o => tags.includes(o.label))}
|
||||
onChange={(e, newValue) => onTagsChange(newValue.map(v => v.label))}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={tags.length === 0 ? t('eval.tags', '标签') : ''}
|
||||
size="small"
|
||||
sx={{
|
||||
width: 280,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: 'background.paper',
|
||||
minHeight: 42,
|
||||
fieldset: {
|
||||
borderColor: theme.palette.divider
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: theme.palette.text.secondary
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
borderWidth: 1,
|
||||
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
'& .MuiAutocomplete-tag': {
|
||||
height: 24,
|
||||
borderRadius: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:操作按钮组 */}
|
||||
<ActionGroup>
|
||||
{/* 导入按钮下拉菜单 */}
|
||||
<ActionButton
|
||||
variant="outlined"
|
||||
startIcon={<UploadFileIcon />}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
onClick={handleImportClick}
|
||||
>
|
||||
{t('common.import', '导入')}
|
||||
</ActionButton>
|
||||
<Menu
|
||||
anchorEl={importAnchorEl}
|
||||
open={Boolean(importAnchorEl)}
|
||||
onClose={handleImportClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleCustomImport} disableRipple>
|
||||
<UploadFileIcon fontSize="small" sx={{ mr: 1.5, color: 'text.secondary' }} />
|
||||
{t('evalDatasets.import.custom', '导入自定义数据集')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleBuiltinImport} disableRipple>
|
||||
<StorageIcon fontSize="small" sx={{ mr: 1.5, color: 'text.secondary' }} />
|
||||
{t('evalDatasets.import.builtin', '导入内置数据集')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<ActionButton variant="outlined" startIcon={<FileDownloadIcon />} onClick={onExport}>
|
||||
{t('common.export', '导出')}
|
||||
</ActionButton>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<DeleteActionButton variant="soft" startIcon={<DeleteIcon />} onClick={onDeleteSelected}>
|
||||
{t('eval.deleteSelectedCount', `删除选中 (${selectedCount})`, { count: selectedCount })}
|
||||
</DeleteActionButton>
|
||||
)}
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ height: 24, alignSelf: 'center', mx: 0.5 }} />
|
||||
|
||||
<StyledToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(e, value) => value && onViewModeChange(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="card" aria-label="card view">
|
||||
<Tooltip title={t('eval.cardView', '卡片视图')}>
|
||||
<ViewModuleIcon fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="list" aria-label="list view">
|
||||
<Tooltip title={t('eval.listView', '列表视图')}>
|
||||
<ViewListIcon fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
</StyledToggleButtonGroup>
|
||||
</ActionGroup>
|
||||
</Box>
|
||||
</ToolbarContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import { Box, Paper, Button, ToggleButton, ToggleButtonGroup, InputBase } from '@mui/material';
|
||||
|
||||
export const ToolbarContainer = styled(Paper)(({ theme }) => ({
|
||||
padding: theme.spacing(2, 2.5),
|
||||
marginBottom: theme.spacing(3),
|
||||
borderRadius: theme.shape.borderRadius * 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.03)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2)
|
||||
}));
|
||||
|
||||
export const FilterGroup = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1.5),
|
||||
flexWrap: 'wrap'
|
||||
}));
|
||||
|
||||
export const FilterButton = styled(Button, {
|
||||
shouldForwardProp: prop => prop !== 'active' && prop !== 'colorType'
|
||||
})(({ theme, active, colorType }) => {
|
||||
const colorMap = {
|
||||
success: theme.palette.success,
|
||||
primary: theme.palette.primary,
|
||||
secondary: theme.palette.secondary,
|
||||
warning: theme.palette.warning,
|
||||
info: theme.palette.info
|
||||
};
|
||||
const mainColor = colorMap[colorType] || theme.palette.primary;
|
||||
|
||||
return {
|
||||
padding: theme.spacing(0.75, 2),
|
||||
borderRadius: theme.shape.borderRadius * 5, // Pill shape
|
||||
border: '1px solid',
|
||||
borderColor: active ? mainColor.main : theme.palette.divider,
|
||||
backgroundColor: active ? alpha(mainColor.main, 0.1) : 'transparent',
|
||||
color: active ? mainColor.main : theme.palette.text.secondary,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: active ? 600 : 400,
|
||||
minWidth: 'auto',
|
||||
textTransform: 'none',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
backgroundColor: active ? alpha(mainColor.main, 0.15) : alpha(theme.palette.text.primary, 0.04),
|
||||
borderColor: active ? mainColor.main : theme.palette.text.secondary,
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
'& .MuiButton-startIcon': {
|
||||
marginRight: theme.spacing(0.8),
|
||||
color: active ? mainColor.main : theme.palette.text.disabled,
|
||||
width: 18,
|
||||
height: 18
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const SearchWrapper = styled(Paper)(({ theme }) => ({
|
||||
padding: '2px 4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 280,
|
||||
height: 42,
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
boxShadow: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.text.secondary,
|
||||
backgroundColor: alpha(theme.palette.action.hover, 0.05)
|
||||
},
|
||||
'&:focus-within': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
backgroundColor: theme.palette.background.paper
|
||||
}
|
||||
}));
|
||||
|
||||
export const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(1),
|
||||
flex: 1,
|
||||
fontSize: '0.875rem',
|
||||
'& input': {
|
||||
'&::placeholder': {
|
||||
color: theme.palette.text.disabled,
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export const ActionGroup = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1.5)
|
||||
}));
|
||||
|
||||
export const ActionButton = styled(Button)(({ theme }) => ({
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
height: 40,
|
||||
paddingLeft: theme.spacing(3),
|
||||
paddingRight: theme.spacing(3),
|
||||
borderColor: theme.palette.divider,
|
||||
color: theme.palette.text.secondary,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.text.primary,
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: theme.palette.action.hover
|
||||
}
|
||||
}));
|
||||
|
||||
export const DeleteActionButton = styled(Button)(({ theme }) => ({
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
height: 40,
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
backgroundColor: alpha(theme.palette.error.main, 0.1),
|
||||
color: theme.palette.error.main,
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.error.main, 0.2)
|
||||
}
|
||||
}));
|
||||
|
||||
export const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({
|
||||
height: 40,
|
||||
backgroundColor: theme.palette.action.hover, // Slightly darker than paper
|
||||
padding: 4,
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
border: 'none',
|
||||
gap: 4,
|
||||
'& .MuiToggleButton-root': {
|
||||
border: 'none',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
width: 36,
|
||||
color: theme.palette.text.secondary,
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.primary.main,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.background.paper
|
||||
}
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0,0,0,0.04)'
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
OutlinedInput,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
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' }
|
||||
];
|
||||
|
||||
const EXPORT_FORMATS = [
|
||||
{ value: 'json', label: 'JSON', description: 'evalDatasets.export.jsonDesc' },
|
||||
{ value: 'jsonl', label: 'JSONL', description: 'evalDatasets.export.jsonlDesc' },
|
||||
{ value: 'csv', label: 'CSV', description: 'evalDatasets.export.csvDesc' }
|
||||
];
|
||||
|
||||
export default function ExportEvalDialog({
|
||||
open,
|
||||
onClose,
|
||||
exporting,
|
||||
error,
|
||||
format,
|
||||
setFormat,
|
||||
questionTypes,
|
||||
setQuestionTypes,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
keyword,
|
||||
setKeyword,
|
||||
previewTotal,
|
||||
previewLoading,
|
||||
availableTags,
|
||||
resetFilters,
|
||||
onExport
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasFilters = questionTypes.length > 0 || selectedTags.length > 0 || keyword;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FileDownloadIcon color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{t('evalDatasets.export.title', '导出评估数据集')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={onClose} disabled={exporting} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => {}}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 导出格式选择 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
{t('evalDatasets.export.formatLabel', '导出格式')}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={format}
|
||||
exclusive
|
||||
onChange={(e, newFormat) => newFormat && setFormat(newFormat)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{EXPORT_FORMATS.map(f => (
|
||||
<ToggleButton key={f.value} value={f.value} sx={{ flex: 1 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{f.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t(f.description, f.label)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<FilterAltIcon sx={{ mr: 1, color: 'primary.main', fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||
{t('evalDatasets.export.filterLabel', '筛选条件')}
|
||||
</Typography>
|
||||
{hasFilters && (
|
||||
<Button size="small" startIcon={<ClearIcon />} onClick={resetFilters}>
|
||||
{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={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* 题型和标签筛选 */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* 题型筛选 */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('evalTasks.filterByTypeLabel', '题型筛选')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={questionTypes}
|
||||
onChange={e => setQuestionTypes(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>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('evalTasks.filterByTagLabel', '标签筛选')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={e => setSelectedTags(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>
|
||||
)}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: { maxHeight: 300 }
|
||||
}
|
||||
}}
|
||||
disabled={availableTags.length === 0}
|
||||
>
|
||||
{availableTags.length === 0 ? (
|
||||
<MenuItem disabled>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalDatasets.export.noTagsAvailable', '暂无可用标签')}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
) : (
|
||||
availableTags.map(tag => (
|
||||
<MenuItem key={tag} value={tag}>
|
||||
<Checkbox checked={selectedTags.includes(tag)} />
|
||||
<ListItemText primary={tag} />
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 导出预览 */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalDatasets.export.previewLabel', '将导出数据:')}
|
||||
</Typography>
|
||||
{previewLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{previewTotal} {t('evalDatasets.export.records', '条记录')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{previewTotal > 1000 && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
{t('evalDatasets.export.largeDataHint', '数据量较大,将采用流式导出,请耐心等待')}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onClose} disabled={exporting} color="inherit">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onExport}
|
||||
variant="contained"
|
||||
disabled={exporting || previewLoading || previewTotal === 0}
|
||||
startIcon={exporting ? <CircularProgress size={16} /> : <FileDownloadIcon />}
|
||||
>
|
||||
{exporting ? t('evalDatasets.export.exporting', '导出中...') : t('evalDatasets.export.exportBtn', '导出')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
IconButton,
|
||||
Radio
|
||||
} from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import {
|
||||
QUESTION_TYPES,
|
||||
FORMAT_PREVIEW,
|
||||
getJsonTemplateData,
|
||||
getExcelTemplateData,
|
||||
getColumnWidths
|
||||
} from '../constants';
|
||||
import {
|
||||
StyledDialogTitle,
|
||||
UploadBox,
|
||||
PreviewPaper,
|
||||
CodeBlock,
|
||||
ErrorContainer,
|
||||
TypeRadioGroup,
|
||||
TypeFormControlLabel
|
||||
} from './ImportDialog.styles';
|
||||
|
||||
export default function ImportDialog({ open, onClose, projectId, onSuccess }) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const [questionType, setQuestionType] = useState('open_ended');
|
||||
const [tags, setTags] = useState('');
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [errorDetails, setErrorDetails] = useState([]);
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = e => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile) {
|
||||
const ext = selectedFile.name.split('.').pop().toLowerCase();
|
||||
if (!['json', 'xls', 'xlsx'].includes(ext)) {
|
||||
setError(t('evalDatasets.import.invalidFileType', '不支持的文件格式,请上传 json、xls 或 xlsx 文件'));
|
||||
return;
|
||||
}
|
||||
setFile(selectedFile);
|
||||
setError(null);
|
||||
setErrorDetails([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载模板
|
||||
const handleDownloadTemplate = format => {
|
||||
if (!questionType) {
|
||||
setError(t('evalDatasets.import.selectTypeFirst', '请先选择题型'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
// JSON 模板动态生成并下载
|
||||
const templateData = getJsonTemplateData(questionType);
|
||||
const jsonContent = JSON.stringify(templateData, null, 2);
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `eval-dataset-template-${questionType}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// Excel 模板动态生成
|
||||
const templateData = getExcelTemplateData(questionType);
|
||||
const worksheet = XLSX.utils.json_to_sheet(templateData);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Template');
|
||||
|
||||
// 设置列宽
|
||||
const colWidths = getColumnWidths(questionType);
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
// 下载文件
|
||||
XLSX.writeFile(workbook, `eval-dataset-template-${questionType}.xlsx`);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交导入
|
||||
const handleSubmit = async () => {
|
||||
if (!questionType) {
|
||||
setError(t('evalDatasets.import.selectTypeFirst', '请先选择题型'));
|
||||
return;
|
||||
}
|
||||
if (!file) {
|
||||
setError(t('evalDatasets.import.selectFile', '请选择要导入的文件'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setErrorDetails([]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('questionType', questionType);
|
||||
formData.append('tags', tags);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/import`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
onSuccess?.(result.data);
|
||||
handleClose();
|
||||
} else {
|
||||
setError(result.error || result.message);
|
||||
if (result.details) {
|
||||
setErrorDetails(result.details);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || t('evalDatasets.import.failed', '导入失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
if (loading) return;
|
||||
setQuestionType('open_ended');
|
||||
setTags('');
|
||||
setFile(null);
|
||||
setError(null);
|
||||
setErrorDetails([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 获取当前题型的格式预览
|
||||
const formatPreview = questionType ? FORMAT_PREVIEW[questionType] : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<StyledDialogTitle>
|
||||
{t('evalDatasets.import.title', '导入评估数据集')}
|
||||
<IconButton onClick={handleClose} disabled={loading} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</StyledDialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
{errorDetails.length > 0 && (
|
||||
<ErrorContainer>
|
||||
{errorDetails.map((detail, index) => (
|
||||
<Box key={index} className="item">
|
||||
{detail}
|
||||
</Box>
|
||||
))}
|
||||
{errorDetails.length < 10 && (
|
||||
<Box sx={{ mt: 0.5, color: 'text.secondary', ml: 2 }}>
|
||||
{t('evalDatasets.import.showingErrors', '显示前 {{count}} 条错误', { count: errorDetails.length })}
|
||||
</Box>
|
||||
)}
|
||||
</ErrorContainer>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 题型选择 - 使用封装好的样式组件 */}
|
||||
<Box sx={{ mb: 3, mt: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600, color: 'text.primary' }}>
|
||||
{t('evalDatasets.import.questionType', '选择题型')}
|
||||
</Typography>
|
||||
<TypeRadioGroup value={questionType} onChange={e => setQuestionType(e.target.value)}>
|
||||
{QUESTION_TYPES.map(type => (
|
||||
<TypeFormControlLabel
|
||||
key={type.value}
|
||||
value={type.value}
|
||||
checked={questionType === type.value}
|
||||
control={<Radio size="small" />}
|
||||
label={t(type.label, type.labelZh)}
|
||||
/>
|
||||
))}
|
||||
</TypeRadioGroup>
|
||||
</Box>
|
||||
|
||||
{/* 数据格式预览 */}
|
||||
{formatPreview && (
|
||||
<PreviewPaper variant="outlined">
|
||||
<Typography variant="subtitle2" className="title">
|
||||
{t('evalDatasets.import.formatPreview', '数据格式预览')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
{formatPreview.fields.map(field => (
|
||||
<Chip key={field} label={field} size="small" variant="outlined" color="primary" />
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{formatPreview.description}
|
||||
</Typography>
|
||||
<CodeBlock>
|
||||
<pre style={{ margin: 0 }}>{JSON.stringify(formatPreview.example, null, 2)}</pre>
|
||||
</CodeBlock>
|
||||
|
||||
{/* 下载模板按钮 */}
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => handleDownloadTemplate('json')}
|
||||
>
|
||||
JSON {t('evalDatasets.import.template', '模板')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => handleDownloadTemplate('xlsx')}
|
||||
>
|
||||
Excel {t('evalDatasets.import.template', '模板')}
|
||||
</Button>
|
||||
</Box>
|
||||
</PreviewPaper>
|
||||
)}
|
||||
|
||||
{/* 文件上传 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".json,.xls,.xlsx"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<UploadBox active={false} hasFile={!!file} onClick={() => fileInputRef.current?.click()}>
|
||||
{file ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<InsertDriveFileIcon color="primary" sx={{ fontSize: 40 }} />
|
||||
<Typography color="primary" variant="h6">
|
||||
{file.name}
|
||||
</Typography>
|
||||
<Chip label={`${(file.size / 1024).toFixed(1)} KB`} size="small" color="primary" variant="soft" />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{t('common.clickToReplace', '点击更换文件')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<CloudUploadIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('evalDatasets.import.dropOrClick', '点击或拖拽文件到此处')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalDatasets.import.supportedFormats', '支持 JSON、XLS、XLSX 格式')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</UploadBox>
|
||||
</Box>
|
||||
|
||||
{/* 标签输入 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('evalDatasets.import.tags', '标签(可选)')}
|
||||
placeholder={t('evalDatasets.import.tagsPlaceholder', '为导入的数据添加标签,多个标签用逗号分隔')}
|
||||
value={tags}
|
||||
onChange={e => setTags(e.target.value)}
|
||||
disabled={loading}
|
||||
helperText={t('evalDatasets.import.tagsHelp', '导入的所有数据将打上这些标签')}
|
||||
InputProps={{
|
||||
startAdornment: tags ? <Box sx={{ mr: 1, color: 'text.secondary' }}>#</Box> : null
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={handleClose} disabled={loading} size="large">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !questionType || !file}
|
||||
size="large"
|
||||
disableElevation
|
||||
>
|
||||
{loading ? t('evalDatasets.import.importing', '导入中...') : t('evalDatasets.import.import', '导入')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import { Box, Paper, DialogTitle as MuiDialogTitle, RadioGroup, FormControlLabel } from '@mui/material';
|
||||
|
||||
export const StyledDialogTitle = styled(MuiDialogTitle)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(2, 3),
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
'& .MuiTypography-root': {
|
||||
fontWeight: 600,
|
||||
fontSize: '1.1rem'
|
||||
}
|
||||
}));
|
||||
|
||||
export const TypeRadioGroup = styled(RadioGroup)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(2)
|
||||
}));
|
||||
|
||||
export const TypeFormControlLabel = styled(FormControlLabel, {
|
||||
shouldForwardProp: prop => prop !== 'checked'
|
||||
})(({ theme, checked }) => ({
|
||||
margin: 0,
|
||||
padding: '4px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: checked ? theme.palette.primary.main : theme.palette.divider,
|
||||
backgroundColor: checked ? alpha(theme.palette.primary.main, 0.05) : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
backgroundColor: checked ? alpha(theme.palette.primary.main, 0.08) : theme.palette.action.hover
|
||||
},
|
||||
'& .MuiTypography-root': {
|
||||
fontSize: '0.875rem',
|
||||
color: checked ? theme.palette.primary.main : theme.palette.text.primary,
|
||||
fontWeight: checked ? 600 : 400
|
||||
},
|
||||
'& .MuiRadio-root': {
|
||||
padding: '4px',
|
||||
color: checked ? theme.palette.primary.main : theme.palette.text.secondary
|
||||
}
|
||||
}));
|
||||
|
||||
export const UploadBox = styled(Box, {
|
||||
shouldForwardProp: prop => prop !== 'active' && prop !== 'hasFile'
|
||||
})(({ theme, active, hasFile }) => ({
|
||||
border: '2px dashed',
|
||||
borderColor: active ? theme.palette.primary.main : theme.palette.grey[300],
|
||||
borderRadius: theme.shape.borderRadius * 2,
|
||||
padding: theme.spacing(4),
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: active
|
||||
? alpha(theme.palette.primary.main, 0.05)
|
||||
: hasFile
|
||||
? alpha(theme.palette.primary.main, 0.05)
|
||||
: 'transparent',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.02),
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||
},
|
||||
'& svg': {
|
||||
fontSize: 48,
|
||||
marginBottom: theme.spacing(1),
|
||||
color: active ? theme.palette.primary.main : theme.palette.grey[400],
|
||||
transition: 'color 0.3s ease'
|
||||
}
|
||||
}));
|
||||
|
||||
export const PreviewPaper = styled(Paper)(({ theme }) => ({
|
||||
padding: theme.spacing(2.5),
|
||||
marginBottom: theme.spacing(3),
|
||||
backgroundColor: theme.palette.grey[50],
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
'& .title': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1.5),
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 600
|
||||
}
|
||||
}));
|
||||
|
||||
export const CodeBlock = styled(Box)(({ theme }) => ({
|
||||
backgroundColor: '#1e1e1e', // Dark theme for code
|
||||
color: '#d4d4d4',
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
fontFamily: '"Fira Code", "Roboto Mono", monospace',
|
||||
fontSize: '0.85rem',
|
||||
overflow: 'auto',
|
||||
maxHeight: 300,
|
||||
'&::-webkit-scrollbar': {
|
||||
height: 8,
|
||||
width: 8
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: '#2d2d2d'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: '#555',
|
||||
borderRadius: 4
|
||||
}
|
||||
}));
|
||||
|
||||
export const ErrorContainer = styled(Box)(({ theme }) => ({
|
||||
marginTop: theme.spacing(1),
|
||||
fontSize: '0.85rem',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
'& .item': {
|
||||
padding: theme.spacing(0.5, 0),
|
||||
color: theme.palette.error.main,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1),
|
||||
'&::before': {
|
||||
content: '"•"',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export const TagInputWrapper = styled(Box)(({ theme }) => ({
|
||||
// Custom styles for tag input area if needed
|
||||
}));
|
||||
@@ -0,0 +1,679 @@
|
||||
export const QUESTION_TYPES = [
|
||||
{ value: 'true_false', label: 'eval.questionTypes.true_false', labelZh: '判断题' },
|
||||
{ value: 'single_choice', label: 'eval.questionTypes.single_choice', labelZh: '单选题' },
|
||||
{ value: 'multiple_choice', label: 'eval.questionTypes.multiple_choice', labelZh: '多选题' },
|
||||
{ value: 'short_answer', label: 'eval.questionTypes.short_answer', labelZh: '短答案题' },
|
||||
{ value: 'open_ended', label: 'eval.questionTypes.open_ended', labelZh: '开放式问题' }
|
||||
];
|
||||
|
||||
export const FORMAT_PREVIEW = {
|
||||
true_false: {
|
||||
fields: ['question', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'Artificial Intelligence is a branch of computer science',
|
||||
correctAnswer: '✅ or ❌'
|
||||
},
|
||||
description: 'correctAnswer must be "✅" (correct) or "❌" (incorrect)'
|
||||
},
|
||||
single_choice: {
|
||||
fields: ['question', 'options', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'Which of the following is a core feature of deep learning?',
|
||||
options: '["Option A", "Option B", "Option C", "Option D"]',
|
||||
correctAnswer: 'B'
|
||||
},
|
||||
description: 'options is an array of options, correctAnswer is the letter of the correct option (A/B/C/D)'
|
||||
},
|
||||
multiple_choice: {
|
||||
fields: ['question', 'options', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'Which of the following are commonly used deep learning frameworks?',
|
||||
options: '["TensorFlow", "PyTorch", "Excel", "Keras"]',
|
||||
correctAnswer: '["A", "B", "D"]'
|
||||
},
|
||||
description: 'options is an array of options, correctAnswer is an array of correct option letters'
|
||||
},
|
||||
short_answer: {
|
||||
fields: ['question', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'What is the typical model structure used in deep learning?',
|
||||
correctAnswer: 'Neural Network'
|
||||
},
|
||||
description: 'correctAnswer is a short standard answer'
|
||||
},
|
||||
open_ended: {
|
||||
fields: ['question', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'Analyze the main reasons for the success of deep learning in computer vision.',
|
||||
correctAnswer: 'Reference answer content...'
|
||||
},
|
||||
description: 'correctAnswer is a reference answer (can be long)'
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 JSON 模板数据
|
||||
export const getJsonTemplateData = type => {
|
||||
switch (type) {
|
||||
case 'true_false':
|
||||
return [
|
||||
{ question: 'Artificial Intelligence is a branch of computer science', correctAnswer: '✅' },
|
||||
{ question: 'Deep learning does not require large amounts of data for training', correctAnswer: '❌' }
|
||||
];
|
||||
case 'single_choice':
|
||||
return [
|
||||
{
|
||||
question: 'What is the core feature of deep learning?',
|
||||
options: [
|
||||
'Requires manual feature engineering',
|
||||
'Automatic feature learning',
|
||||
'Only handles structured data',
|
||||
'Does not need large amounts of data'
|
||||
],
|
||||
correctAnswer: 'B'
|
||||
},
|
||||
{
|
||||
question: 'Which of the following is a commonly used deep learning framework?',
|
||||
options: ['Excel', 'Word', 'TensorFlow', 'PowerPoint'],
|
||||
correctAnswer: 'C'
|
||||
}
|
||||
];
|
||||
case 'multiple_choice':
|
||||
return [
|
||||
{
|
||||
question: 'Which of the following are commonly used deep learning frameworks?',
|
||||
options: ['TensorFlow', 'PyTorch', 'Excel', 'Keras', 'Word'],
|
||||
correctAnswer: ['A', 'B', 'D']
|
||||
},
|
||||
{
|
||||
question: 'Which of the following are main types of machine learning?',
|
||||
options: ['Supervised Learning', 'Unsupervised Learning', 'Reinforcement Learning', 'Manual Learning'],
|
||||
correctAnswer: ['A', 'B', 'C']
|
||||
}
|
||||
];
|
||||
case 'short_answer':
|
||||
return [
|
||||
{ question: 'What is the typical model structure used in deep learning?', correctAnswer: 'Neural Network' },
|
||||
{ question: 'What is the maximum sample size mentioned in the text?', correctAnswer: '1000' }
|
||||
];
|
||||
case 'open_ended':
|
||||
return [
|
||||
{
|
||||
question: 'Analyze the main reasons for the success of deep learning in computer vision.',
|
||||
correctAnswer:
|
||||
'The success of deep learning in computer vision can be explained from three dimensions: models, data, and computing power...'
|
||||
},
|
||||
{
|
||||
question: 'Explain the overfitting problem in machine learning and its solutions.',
|
||||
correctAnswer:
|
||||
'Overfitting refers to the phenomenon where a model performs well on training data but poorly on new data...'
|
||||
}
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 Excel 模板数据
|
||||
export const getExcelTemplateData = type => {
|
||||
switch (type) {
|
||||
case 'true_false':
|
||||
return [
|
||||
{ question: 'Artificial Intelligence is a branch of computer science', correctAnswer: '✅' },
|
||||
{ question: 'Deep learning does not require large amounts of data for training', correctAnswer: '❌' }
|
||||
];
|
||||
case 'single_choice':
|
||||
return [
|
||||
{
|
||||
question: 'What is the core feature of deep learning?',
|
||||
options: `["Requires manual feature engineering", "Automatic feature learning", "Only handles structured data", "Does not need large amounts of data"]`,
|
||||
correctAnswer: 'B'
|
||||
},
|
||||
{
|
||||
question: 'Which of the following is a commonly used deep learning framework?',
|
||||
options: `["Excel", "Word", "TensorFlow", "PowerPoint"]`,
|
||||
correctAnswer: 'C'
|
||||
}
|
||||
];
|
||||
case 'multiple_choice':
|
||||
return [
|
||||
{
|
||||
question: 'Which of the following are commonly used deep learning frameworks?',
|
||||
options: `["TensorFlow", "PyTorch", "Excel", "Keras", "Word"]`,
|
||||
correctAnswer: `["A", "B", "D"]`
|
||||
},
|
||||
{
|
||||
question: 'Which of the following are main types of machine learning?',
|
||||
options: `["Supervised Learning", "Unsupervised Learning", "Reinforcement Learning", "Manual Learning"]`,
|
||||
correctAnswer: `["A", "B", "C"]`
|
||||
}
|
||||
];
|
||||
case 'short_answer':
|
||||
return [
|
||||
{ question: 'What is the typical model structure used in deep learning?', correctAnswer: 'Neural Network' },
|
||||
{ question: 'What is the maximum sample size mentioned in the text?', correctAnswer: '1000' }
|
||||
];
|
||||
case 'open_ended':
|
||||
return [
|
||||
{
|
||||
question: 'Analyze the main reasons for the success of deep learning in computer vision.',
|
||||
correctAnswer:
|
||||
'The success of deep learning in computer vision can be explained from three dimensions: models, data, and computing power...'
|
||||
},
|
||||
{
|
||||
question: 'Explain the overfitting problem in machine learning and its solutions.',
|
||||
correctAnswer:
|
||||
'Overfitting refers to the phenomenon where a model performs well on training data but poorly on new data...'
|
||||
}
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 获取列宽配置
|
||||
export const getColumnWidths = type => {
|
||||
if (type === 'single_choice' || type === 'multiple_choice') {
|
||||
return [{ wch: 50 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 15 }];
|
||||
}
|
||||
return [{ wch: 60 }, { wch: 40 }];
|
||||
};
|
||||
|
||||
export const DATA_SETS = [
|
||||
{
|
||||
zh: '生物学',
|
||||
en: 'Biology',
|
||||
file: 'mmlu-pro/biology.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '商业',
|
||||
en: 'Business',
|
||||
file: 'mmlu-pro/business.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '化学',
|
||||
en: 'Chemistry',
|
||||
file: 'mmlu-pro/chemistry.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '计算机科学',
|
||||
en: 'Computer Science',
|
||||
file: 'mmlu-pro/computer_science.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '经济学',
|
||||
en: 'Economics',
|
||||
file: 'mmlu-pro/economics.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '工程学',
|
||||
en: 'Engineering',
|
||||
file: 'mmlu-pro/engineering.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '健康科学',
|
||||
en: 'Health',
|
||||
file: 'mmlu-pro/health.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '历史',
|
||||
en: 'History',
|
||||
file: 'mmlu-pro/history.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '法律',
|
||||
en: 'Law',
|
||||
file: 'mmlu-pro/law.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '数学',
|
||||
en: 'Math',
|
||||
file: 'mmlu-pro/math.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '其他',
|
||||
en: 'Other',
|
||||
file: 'mmlu-pro/other.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '哲学',
|
||||
en: 'Philosophy',
|
||||
file: 'mmlu-pro/philosophy.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '物理',
|
||||
en: 'Physics',
|
||||
file: 'mmlu-pro/physics.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '心理学',
|
||||
en: 'Psychology',
|
||||
file: 'mmlu-pro/psychology.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '抽象代数',
|
||||
en: 'Abstract Algebra',
|
||||
file: 'mmlu/abstract_algebra_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '解剖学',
|
||||
en: 'Anatomy',
|
||||
file: 'mmlu/anatomy_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '天文学',
|
||||
en: 'Astronomy',
|
||||
file: 'mmlu/astronomy_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '商业伦理',
|
||||
en: 'Business Ethics',
|
||||
file: 'mmlu/business_ethics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '临床知识',
|
||||
en: 'Clinical Knowledge',
|
||||
file: 'mmlu/clinical_knowledge_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学生物',
|
||||
en: 'College Biology',
|
||||
file: 'mmlu/college_biology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学化学',
|
||||
en: 'College Chemistry',
|
||||
file: 'mmlu/college_chemistry_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学计算机科学',
|
||||
en: 'College Computer Science',
|
||||
file: 'mmlu/college_computer_science_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学数学',
|
||||
en: 'College Mathematics',
|
||||
file: 'mmlu/college_mathematics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学医学',
|
||||
en: 'College Medicine',
|
||||
file: 'mmlu/college_medicine_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学物理',
|
||||
en: 'College Physics',
|
||||
file: 'mmlu/college_physics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '计算机安全',
|
||||
en: 'Computer Security',
|
||||
file: 'mmlu/computer_security_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '概念物理',
|
||||
en: 'Conceptual Physics',
|
||||
file: 'mmlu/conceptual_physics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '计量经济学',
|
||||
en: 'Econometrics',
|
||||
file: 'mmlu/econometrics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '电气工程',
|
||||
en: 'Electrical Engineering',
|
||||
file: 'mmlu/electrical_engineering_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '初等数学',
|
||||
en: 'Elementary Mathematics',
|
||||
file: 'mmlu/elementary_mathematics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '形式逻辑',
|
||||
en: 'Formal Logic',
|
||||
file: 'mmlu/formal_logic_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '全球事实',
|
||||
en: 'Global Facts',
|
||||
file: 'mmlu/global_facts_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中生物',
|
||||
en: 'High School Biology',
|
||||
file: 'mmlu/high_school_biology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中化学',
|
||||
en: 'High School Chemistry',
|
||||
file: 'mmlu/high_school_chemistry_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中计算机科学',
|
||||
en: 'High School Computer Science',
|
||||
file: 'mmlu/high_school_computer_science_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中欧洲历史',
|
||||
en: 'High School European History',
|
||||
file: 'mmlu/high_school_european_history_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中地理',
|
||||
en: 'High School Geography',
|
||||
file: 'mmlu/high_school_geography_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中政府与政治',
|
||||
en: 'High School Government And Politics',
|
||||
file: 'mmlu/high_school_government_and_politics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中宏观经济学',
|
||||
en: 'High School Macroeconomics',
|
||||
file: 'mmlu/high_school_macroeconomics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中数学',
|
||||
en: 'High School Mathematics',
|
||||
file: 'mmlu/high_school_mathematics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中微观经济学',
|
||||
en: 'High School Microeconomics',
|
||||
file: 'mmlu/high_school_microeconomics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中物理',
|
||||
en: 'High School Physics',
|
||||
file: 'mmlu/high_school_physics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中心理学',
|
||||
en: 'High School Psychology',
|
||||
file: 'mmlu/high_school_psychology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中统计学',
|
||||
en: 'High School Statistics',
|
||||
file: 'mmlu/high_school_statistics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中美国历史',
|
||||
en: 'High School Us History',
|
||||
file: 'mmlu/high_school_us_history_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中世界历史',
|
||||
en: 'High School World History',
|
||||
file: 'mmlu/high_school_world_history_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '人类衰老',
|
||||
en: 'Human Aging',
|
||||
file: 'mmlu/human_aging_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '人类性学',
|
||||
en: 'Human Sexuality',
|
||||
file: 'mmlu/human_sexuality_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '国际法',
|
||||
en: 'International Law',
|
||||
file: 'mmlu/international_law_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '法理学',
|
||||
en: 'Jurisprudence',
|
||||
file: 'mmlu/jurisprudence_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '逻辑谬误',
|
||||
en: 'Logical Fallacies',
|
||||
file: 'mmlu/logical_fallacies_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '机器学习',
|
||||
en: 'Machine Learning',
|
||||
file: 'mmlu/machine_learning_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '管理学',
|
||||
en: 'Management',
|
||||
file: 'mmlu/management_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '市场营销',
|
||||
en: 'Marketing',
|
||||
file: 'mmlu/marketing_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '医学遗传学',
|
||||
en: 'Medical Genetics',
|
||||
file: 'mmlu/medical_genetics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '杂项/综合',
|
||||
en: 'Miscellaneous',
|
||||
file: 'mmlu/miscellaneous_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '道德争议',
|
||||
en: 'Moral Disputes',
|
||||
file: 'mmlu/moral_disputes_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '道德场景',
|
||||
en: 'Moral Scenarios',
|
||||
file: 'mmlu/moral_scenarios_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '营养学',
|
||||
en: 'Nutrition',
|
||||
file: 'mmlu/nutrition_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '哲学',
|
||||
en: 'Philosophy',
|
||||
file: 'mmlu/philosophy_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '史前史',
|
||||
en: 'Prehistory',
|
||||
file: 'mmlu/prehistory_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '专业会计',
|
||||
en: 'Professional Accounting',
|
||||
file: 'mmlu/professional_accounting_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '专业法律',
|
||||
en: 'Professional Law',
|
||||
file: 'mmlu/professional_law_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '专业医学',
|
||||
en: 'Professional Medicine',
|
||||
file: 'mmlu/professional_medicine_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '专业心理学',
|
||||
en: 'Professional Psychology',
|
||||
file: 'mmlu/professional_psychology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '公共关系',
|
||||
en: 'Public Relations',
|
||||
file: 'mmlu/public_relations_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '安全研究',
|
||||
en: 'Security Studies',
|
||||
file: 'mmlu/security_studies_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '社会学',
|
||||
en: 'Sociology',
|
||||
file: 'mmlu/sociology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '美国外交政策',
|
||||
en: 'Us Foreign Policy',
|
||||
file: 'mmlu/us_foreign_policy_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '病毒学',
|
||||
en: 'Virology',
|
||||
file: 'mmlu/virology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '世界宗教测试',
|
||||
en: 'World Religions',
|
||||
file: 'mmlu/world_religions_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Eval datasets list hook
|
||||
* @param {string} projectId
|
||||
*/
|
||||
export default function useEvalDatasets(projectId) {
|
||||
const [data, setData] = useState({ items: [], total: 0, stats: null, totalPages: 1 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const isInitialMount = useRef(true);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const [questionType, setQuestionType] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState('');
|
||||
const [chunkId, setChunkId] = useState('');
|
||||
const [tags, setTags] = useState([]);
|
||||
|
||||
const setQuestionTypeWithReset = useCallback(value => {
|
||||
setQuestionType(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const setKeywordWithReset = useCallback(value => {
|
||||
setKeyword(value);
|
||||
}, []);
|
||||
|
||||
const setChunkIdWithReset = useCallback(value => {
|
||||
setChunkId(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const setTagsWithReset = useCallback(value => {
|
||||
setTags(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const [viewMode, setViewMode] = useState('card');
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword);
|
||||
if (keyword !== debouncedKeyword) {
|
||||
setPage(1);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
const fetchDataRef = useRef(null);
|
||||
fetchDataRef.current = async (showLoading = true, options = {}) => {
|
||||
if (!projectId) return;
|
||||
|
||||
const includeStats = options.forceStats || showLoading;
|
||||
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setSearching(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
includeStats: includeStats ? 'true' : 'false'
|
||||
});
|
||||
|
||||
if (questionType) params.append('questionType', questionType);
|
||||
if (debouncedKeyword) params.append('keyword', debouncedKeyword);
|
||||
if (chunkId) params.append('chunkId', chunkId);
|
||||
if (tags.length > 0) {
|
||||
tags.forEach(tag => params.append('tags', tag));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets?${params}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch eval datasets');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(prev => ({
|
||||
...result,
|
||||
stats: result.stats ?? prev.stats
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') return;
|
||||
setError(err.message);
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
setLoading(false);
|
||||
} else {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = useCallback((showLoading = true, options = {}) => {
|
||||
return fetchDataRef.current?.(showLoading, options);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
fetchDataRef.current?.(true, { forceStats: true });
|
||||
} else {
|
||||
fetchDataRef.current?.(false, { forceStats: false });
|
||||
}
|
||||
}, [projectId, page, pageSize, questionType, debouncedKeyword, chunkId, tags]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deleteItems = useCallback(
|
||||
async ids => {
|
||||
if (!ids || ids.length === 0) return;
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete items');
|
||||
}
|
||||
|
||||
await fetchData(true, { forceStats: true });
|
||||
setSelectedIds([]);
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
[projectId, fetchData]
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setQuestionType('');
|
||||
setKeyword('');
|
||||
setChunkId('');
|
||||
setTags([]);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const toggleSelect = useCallback(id => {
|
||||
setSelectedIds(prev => (prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]));
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedIds.length === data.items.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(data.items.map(item => item.id));
|
||||
}
|
||||
}, [selectedIds, data.items]);
|
||||
|
||||
return {
|
||||
items: data.items,
|
||||
total: data.total,
|
||||
stats: data.stats,
|
||||
totalPages: data.totalPages || 1,
|
||||
|
||||
loading,
|
||||
searching,
|
||||
error,
|
||||
|
||||
page,
|
||||
pageSize,
|
||||
setPage,
|
||||
setPageSize,
|
||||
|
||||
questionType,
|
||||
keyword,
|
||||
chunkId,
|
||||
tags,
|
||||
setQuestionType: setQuestionTypeWithReset,
|
||||
setKeyword: setKeywordWithReset,
|
||||
setChunkId: setChunkIdWithReset,
|
||||
setTags: setTagsWithReset,
|
||||
resetFilters,
|
||||
|
||||
viewMode,
|
||||
setViewMode,
|
||||
|
||||
selectedIds,
|
||||
toggleSelect,
|
||||
toggleSelectAll,
|
||||
setSelectedIds,
|
||||
|
||||
fetchData,
|
||||
deleteItems
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 评估数据集导出 Hook
|
||||
* 管理导出对话框状态、筛选条件和导出逻辑
|
||||
*/
|
||||
export default function useExportEvalDatasets(projectId, stats = {}) {
|
||||
// 对话框状态
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 导出配置
|
||||
const [format, setFormat] = useState('json');
|
||||
const [questionTypes, setQuestionTypes] = useState([]);
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// 预览数据
|
||||
const [previewTotal, setPreviewTotal] = useState(0);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
// 从 stats 中获取可用的标签列表
|
||||
const availableTags = stats?.byTag ? Object.keys(stats.byTag).sort() : [];
|
||||
|
||||
// 当筛选条件变化时,获取预览数量
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || !projectId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
setPreviewLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (questionTypes.length > 0) {
|
||||
questionTypes.forEach(t => params.append('questionTypes', t));
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
selectedTags.forEach(t => params.append('tags', t));
|
||||
}
|
||||
if (keyword.trim()) {
|
||||
params.append('keyword', keyword.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/export?${params.toString()}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setPreviewTotal(result?.data?.total ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('获取导出预览失败:', err);
|
||||
}
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPreview();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [dialogOpen, projectId, questionTypes, selectedTags, keyword]);
|
||||
|
||||
// 打开对话框
|
||||
const openDialog = useCallback(() => {
|
||||
setDialogOpen(true);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
// 关闭对话框
|
||||
const closeDialog = useCallback(() => {
|
||||
if (exporting) return;
|
||||
setDialogOpen(false);
|
||||
// 重置状态
|
||||
setFormat('json');
|
||||
setQuestionTypes([]);
|
||||
setSelectedTags([]);
|
||||
setKeyword('');
|
||||
setError('');
|
||||
}, [exporting]);
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = useCallback(() => {
|
||||
setQuestionTypes([]);
|
||||
setSelectedTags([]);
|
||||
setKeyword('');
|
||||
}, []);
|
||||
|
||||
// 执行导出
|
||||
const handleExport = useCallback(async () => {
|
||||
if (previewTotal === 0) {
|
||||
setError('没有符合条件的数据可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setExporting(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
questionTypes,
|
||||
tags: selectedTags,
|
||||
keyword: keyword.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json();
|
||||
throw new Error(result.error || '导出失败');
|
||||
}
|
||||
|
||||
// 获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `eval-datasets-${Date.now()}.${format}`;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
if (match) {
|
||||
filename = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
// 导出成功,关闭对话框
|
||||
closeDialog();
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('导出失败:', err);
|
||||
setError(err.message || '导出失败');
|
||||
return false;
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [projectId, format, questionTypes, selectedTags, keyword, previewTotal, closeDialog]);
|
||||
|
||||
return {
|
||||
// 对话框状态
|
||||
dialogOpen,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
|
||||
// 导出状态
|
||||
exporting,
|
||||
error,
|
||||
setError,
|
||||
|
||||
// 导出配置
|
||||
format,
|
||||
setFormat,
|
||||
questionTypes,
|
||||
setQuestionTypes,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
keyword,
|
||||
setKeyword,
|
||||
|
||||
// 预览数据
|
||||
previewTotal,
|
||||
previewLoading,
|
||||
availableTags,
|
||||
|
||||
// 操作
|
||||
resetFilters,
|
||||
handleExport
|
||||
};
|
||||
}
|
||||
322
easy-dataset-main/app/projects/[projectId]/eval-datasets/page.js
Normal file
322
easy-dataset-main/app/projects/[projectId]/eval-datasets/page.js
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Grid,
|
||||
Pagination,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Snackbar
|
||||
} from '@mui/material';
|
||||
import { Masonry } from '@mui/lab';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useEvalDatasets from './hooks/useEvalDatasets';
|
||||
import useExportEvalDatasets from './hooks/useExportEvalDatasets';
|
||||
import EvalToolbar from './components/EvalToolbar';
|
||||
import EvalDatasetCard from './components/EvalDatasetCard';
|
||||
import EvalDatasetList from './components/EvalDatasetList';
|
||||
import ImportDialog from './components/ImportDialog';
|
||||
import BuiltinDatasetDialog from './components/BuiltinDatasetDialog';
|
||||
import ExportEvalDialog from './components/ExportEvalDialog';
|
||||
|
||||
export default function EvalDatasetsPage() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
stats,
|
||||
totalPages,
|
||||
loading,
|
||||
searching,
|
||||
error,
|
||||
page,
|
||||
setPage,
|
||||
questionType,
|
||||
setQuestionType,
|
||||
tags,
|
||||
setTags,
|
||||
keyword,
|
||||
setKeyword,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
selectedIds,
|
||||
toggleSelect,
|
||||
toggleSelectAll,
|
||||
fetchData,
|
||||
deleteItems
|
||||
} = useEvalDatasets(projectId);
|
||||
|
||||
// 导出 Hook
|
||||
const {
|
||||
dialogOpen: exportDialogOpen,
|
||||
openDialog: openExportDialog,
|
||||
closeDialog: closeExportDialog,
|
||||
exporting,
|
||||
error: exportError,
|
||||
format: exportFormat,
|
||||
setFormat: setExportFormat,
|
||||
questionTypes: exportQuestionTypes,
|
||||
setQuestionTypes: setExportQuestionTypes,
|
||||
selectedTags: exportSelectedTags,
|
||||
setSelectedTags: setExportSelectedTags,
|
||||
keyword: exportKeyword,
|
||||
setKeyword: setExportKeyword,
|
||||
previewTotal,
|
||||
previewLoading,
|
||||
availableTags: exportAvailableTags,
|
||||
resetFilters: resetExportFilters,
|
||||
handleExport
|
||||
} = useExportEvalDatasets(projectId, stats);
|
||||
|
||||
// 删除确认对话框
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, ids: [] });
|
||||
|
||||
// 导入对话框
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [builtinImportOpen, setBuiltinImportOpen] = useState(false);
|
||||
|
||||
// Toast 提示
|
||||
const [toast, setToast] = useState({ open: false, message: '', severity: 'success' });
|
||||
|
||||
// 处理导入成功
|
||||
const handleImportSuccess = result => {
|
||||
setToast({
|
||||
open: true,
|
||||
message: t('evalDatasets.import.successMessage', { count: result.total }),
|
||||
severity: 'success'
|
||||
});
|
||||
fetchData(); // 刷新数据
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = async ids => {
|
||||
setDeleteDialog({ open: true, ids: Array.isArray(ids) ? ids : [ids] });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await deleteItems(deleteDialog.ids);
|
||||
setDeleteDialog({ open: false, ids: [] });
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = item => {
|
||||
router.push(`/projects/${projectId}/eval-datasets/${item.id}`);
|
||||
};
|
||||
|
||||
// 处理查看
|
||||
const handleView = item => {
|
||||
router.push(`/projects/${projectId}/eval-datasets/${item.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 工具栏(包含统计筛选) */}
|
||||
<EvalToolbar
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
selectedCount={selectedIds.length}
|
||||
onDeleteSelected={() => handleDelete(selectedIds)}
|
||||
stats={stats}
|
||||
questionType={questionType}
|
||||
onTypeChange={setQuestionType}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
onRefresh={fetchData}
|
||||
loading={loading}
|
||||
onImport={() => setImportDialogOpen(true)}
|
||||
onBuiltinImport={() => setBuiltinImportOpen(true)}
|
||||
onExport={openExportDialog}
|
||||
/>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 内容区域 */}
|
||||
{!loading && (
|
||||
<Box sx={{ position: 'relative', minHeight: searching ? 200 : 'auto' }}>
|
||||
{/* 搜索加载遮罩 */}
|
||||
{searching && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.7)',
|
||||
zIndex: 10,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{viewMode === 'card' ? (
|
||||
<Box>
|
||||
<Masonry
|
||||
columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
spacing={3}
|
||||
sx={{ opacity: searching ? 0.5 : 1, transition: 'opacity 0.2s', width: 'auto' }}
|
||||
>
|
||||
{items.map(item => (
|
||||
<EvalDatasetCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedIds.includes(item.id)}
|
||||
onSelect={toggleSelect}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
projectId={projectId}
|
||||
/>
|
||||
))}
|
||||
</Masonry>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ opacity: searching ? 0.5 : 1, transition: 'opacity 0.2s' }}>
|
||||
<EvalDatasetList
|
||||
items={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={toggleSelect}
|
||||
onSelectAll={toggleSelectAll}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onView={handleView}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{items.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
py: 8
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('eval.noData')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
{t('eval.noDataHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={(e, value) => setPage(value)}
|
||||
color="primary"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialog.open} onClose={() => setDeleteDialog({ open: false, ids: [] })}>
|
||||
<DialogTitle>{t('eval.deleteConfirmTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{t('eval.deleteConfirmMessage', { count: deleteDialog.ids.length })}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, ids: [] })}>{t('common.cancel')}</Button>
|
||||
<Button color="error" variant="contained" onClick={confirmDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 导入对话框 */}
|
||||
<ImportDialog
|
||||
open={importDialogOpen}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={handleImportSuccess}
|
||||
/>
|
||||
|
||||
{/* 内置数据集导入对话框 */}
|
||||
<BuiltinDatasetDialog
|
||||
open={builtinImportOpen}
|
||||
onClose={() => setBuiltinImportOpen(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={handleImportSuccess}
|
||||
/>
|
||||
|
||||
{/* 导出对话框 */}
|
||||
<ExportEvalDialog
|
||||
open={exportDialogOpen}
|
||||
onClose={closeExportDialog}
|
||||
exporting={exporting}
|
||||
error={exportError}
|
||||
format={exportFormat}
|
||||
setFormat={setExportFormat}
|
||||
questionTypes={exportQuestionTypes}
|
||||
setQuestionTypes={setExportQuestionTypes}
|
||||
selectedTags={exportSelectedTags}
|
||||
setSelectedTags={setExportSelectedTags}
|
||||
keyword={exportKeyword}
|
||||
setKeyword={setExportKeyword}
|
||||
previewTotal={previewTotal}
|
||||
previewLoading={previewLoading}
|
||||
availableTags={exportAvailableTags}
|
||||
resetFilters={resetExportFilters}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
|
||||
{/* Toast 提示 */}
|
||||
<Snackbar
|
||||
open={toast.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setToast({ ...toast, open: false })}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={toast.severity} onClose={() => setToast({ ...toast, open: false })}>
|
||||
{toast.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user