first-update

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

View File

@@ -0,0 +1,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>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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