first-update
This commit is contained in:
97
easy-dataset-main/components/datasets/DatasetHeader.js
Normal file
97
easy-dataset-main/components/datasets/DatasetHeader.js
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Divider, Typography, IconButton, CircularProgress, 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 UndoIcon from '@mui/icons-material/Undo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 数据集详情页面的头部导航组件
|
||||
*/
|
||||
export default function DatasetHeader({
|
||||
projectId,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
confirming,
|
||||
unconfirming,
|
||||
currentDataset,
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
onNavigate,
|
||||
onConfirm,
|
||||
onUnconfirm,
|
||||
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}/datasets`)}>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="h6">{t('datasets.datasetDetail')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('datasets.stats', {
|
||||
total: datasetsAllCount,
|
||||
confirmed: datasetsConfirmCount,
|
||||
percentage: ((datasetsConfirmCount / datasetsAllCount) * 100).toFixed(2)
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* 快捷键启用选项 - 已注释掉,保持原代码结构 */}
|
||||
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body1">{t('datasets.enableShortcuts')}</Typography>
|
||||
<Tooltip title={t('datasets.shortcutsHelp')}>
|
||||
<IconButton size="small" color="info">
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>?</Typography>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant={shortcutsEnabled ? 'contained' : 'outlined'}
|
||||
onClick={() => setShortcutsEnabled((prev) => !prev)}
|
||||
>
|
||||
{shortcutsEnabled ? t('common.enabled') : t('common.disabled')}
|
||||
</Button>
|
||||
</Box> */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton onClick={() => onNavigate('prev')}>
|
||||
<NavigateBeforeIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onNavigate('next')}>
|
||||
<NavigateNextIcon />
|
||||
</IconButton>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* 确认/取消确认按钮 */}
|
||||
{currentDataset.confirmed ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={unconfirming}
|
||||
onClick={onUnconfirm}
|
||||
startIcon={unconfirming ? <CircularProgress size={16} /> : <UndoIcon />}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{unconfirming ? t('datasets.unconfirming') : t('datasets.unconfirm')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" color="primary" disabled={confirming} onClick={onConfirm} sx={{ mr: 1 }}>
|
||||
{confirming ? <CircularProgress size={24} /> : t('datasets.confirmSave')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
77
easy-dataset-main/components/datasets/DatasetMetadata.js
Normal file
77
easy-dataset-main/components/datasets/DatasetMetadata.js
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Chip, Tooltip, alpha, CircularProgress } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* 数据集元数据展示组件
|
||||
*/
|
||||
export default function DatasetMetadata({ currentDataset, onViewChunk }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('datasets.metadata')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Chip label={`${t('datasets.model')}: ${currentDataset.model}`} variant="outlined" />
|
||||
{currentDataset.questionLabel && (
|
||||
<Chip label={`${t('common.label')}: ${currentDataset.questionLabel}`} color="primary" variant="outlined" />
|
||||
)}
|
||||
<Chip
|
||||
label={`${t('datasets.createdAt')}: ${new Date(currentDataset.createAt).toLocaleString('zh-CN')}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Tooltip title={t('textSplit.viewChunk')}>
|
||||
<Chip
|
||||
label={`${t('datasets.chunkId')}: ${currentDataset.chunkName}`}
|
||||
variant="outlined"
|
||||
color="info"
|
||||
onClick={async () => {
|
||||
try {
|
||||
// 使用新API接口获取文本块内容
|
||||
const response = await fetch(
|
||||
`/api/projects/${currentDataset.projectId}/chunks/name?chunkName=${encodeURIComponent(currentDataset.chunkName)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取文本块失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const chunkData = await response.json();
|
||||
|
||||
// 调用父组件的方法显示文本块
|
||||
onViewChunk({
|
||||
name: currentDataset.chunkName,
|
||||
content: chunkData.content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取文本块内容失败:', error);
|
||||
// 即使API请求失败,也尝试调用查看方法
|
||||
onViewChunk({
|
||||
name: currentDataset.chunkName,
|
||||
content: '内容加载失败,请重试'
|
||||
});
|
||||
}
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{currentDataset.confirmed && (
|
||||
<Chip
|
||||
label={t('datasets.confirmed')}
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.success.main, 0.1),
|
||||
color: theme.palette.success.dark,
|
||||
fontWeight: 'medium'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
330
easy-dataset-main/components/datasets/DatasetRatingSection.js
Normal file
330
easy-dataset-main/components/datasets/DatasetRatingSection.js
Normal file
@@ -0,0 +1,330 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Divider, Paper, Button, Stack } from '@mui/material';
|
||||
import { toast } from 'sonner';
|
||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import StarRating from './StarRating';
|
||||
import TagSelector from './TagSelector';
|
||||
import NoteInput from './NoteInput';
|
||||
import EvalVariantDialog from './EvalVariantDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
|
||||
/**
|
||||
* 数据集评分、标签、备注综合组件
|
||||
*/
|
||||
export default function DatasetRatingSection({ dataset, projectId, onUpdate, currentDataset }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addingToEval, setAddingToEval] = useState(false);
|
||||
const [generatingVariant, setGeneratingVariant] = useState(false);
|
||||
const [variantDialog, setVariantDialog] = useState({
|
||||
open: false,
|
||||
data: null
|
||||
});
|
||||
|
||||
const selectedModel = useAtomValue(selectedModelInfoAtom);
|
||||
|
||||
// 解析数据集中的标签
|
||||
const parseDatasetTags = tagsString => {
|
||||
try {
|
||||
return JSON.parse(tagsString || '[]');
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 本地状态管理,从 props 初始化
|
||||
const [localScore, setLocalScore] = useState(dataset.score || 0);
|
||||
const [localTags, setLocalTags] = useState(() => parseDatasetTags(dataset.tags));
|
||||
const [localNote, setLocalNote] = useState(dataset.note || '');
|
||||
|
||||
// 获取项目中已使用的标签
|
||||
useEffect(() => {
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/tags`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableTags(data.tags || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取可用标签失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
fetchAvailableTags();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 同步props中的dataset到本地状态
|
||||
useEffect(() => {
|
||||
setLocalScore(dataset.score || 0);
|
||||
setLocalTags(parseDatasetTags(dataset.tags));
|
||||
setLocalNote(dataset.note || '');
|
||||
}, [dataset]);
|
||||
|
||||
// 更新数据集元数据
|
||||
const updateMetadata = async updates => {
|
||||
if (loading) return;
|
||||
|
||||
// 立即更新本地状态,提升响应速度
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(updates.score);
|
||||
}
|
||||
if (updates.tags !== undefined) {
|
||||
setLocalTags(updates.tags);
|
||||
}
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(updates.note);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/${dataset.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('更新失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 显示成功提示
|
||||
toast.success(t('datasets.updateSuccess', '更新成功'));
|
||||
|
||||
// 如果有父组件的更新回调,调用它
|
||||
if (onUpdate) {
|
||||
onUpdate(result.dataset);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新数据集元数据失败:', error);
|
||||
// 显示错误提示
|
||||
toast.error(t('datasets.updateFailed', '更新失败'));
|
||||
|
||||
// 出错时恢复本地状态
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(dataset.score || 0);
|
||||
}
|
||||
if (updates.tags !== undefined) {
|
||||
setLocalTags(parseDatasetTags(dataset.tags));
|
||||
}
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(dataset.note || '');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理评分变更
|
||||
const handleScoreChange = newScore => {
|
||||
updateMetadata({ score: newScore });
|
||||
};
|
||||
|
||||
// 处理标签变更
|
||||
const handleTagsChange = newTags => {
|
||||
updateMetadata({ tags: newTags });
|
||||
};
|
||||
|
||||
// 处理备注变更
|
||||
const handleNoteChange = newNote => {
|
||||
updateMetadata({ note: newNote });
|
||||
};
|
||||
|
||||
// 添加到评估数据集
|
||||
const handleAddToEval = async () => {
|
||||
if (addingToEval) return;
|
||||
|
||||
setAddingToEval(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/${dataset.id}/copy-to-eval`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add to eval dataset');
|
||||
}
|
||||
|
||||
toast.success(t('datasets.addToEvalSuccess', '成功添加到评估数据集'));
|
||||
|
||||
// 更新本地标签显示
|
||||
const currentTags = localTags || [];
|
||||
if (!currentTags.includes('Eval')) {
|
||||
setLocalTags([...currentTags, 'Eval']);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加评估数据集失败:', error);
|
||||
toast.error(t('datasets.addToEvalFailed', '添加失败'));
|
||||
} finally {
|
||||
setAddingToEval(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成评估集变体
|
||||
const handleGenerateEvalVariant = async config => {
|
||||
if (!selectedModel) {
|
||||
toast.error(t('datasets.selectModelFirst', '请先选择模型'));
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
|
||||
try {
|
||||
const language = i18n.language === 'zh-CN' ? 'zh-CN' : 'en';
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/generate-eval-variant`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
datasetId: dataset.id,
|
||||
model: selectedModel,
|
||||
language,
|
||||
questionType: config.questionType,
|
||||
count: config.count
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate variant');
|
||||
}
|
||||
|
||||
const { data } = await response.json();
|
||||
|
||||
// 为每个生成的项添加题型信息,以便保存时使用
|
||||
return Array.isArray(data) ? data.map(item => ({ ...item, questionType: config.questionType })) : [];
|
||||
} catch (error) {
|
||||
console.error('生成变体失败:', error);
|
||||
toast.error(t('datasets.generateVariantFailed', '生成变体失败'));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存评估集变体
|
||||
const handleSaveEvalVariant = async variantItems => {
|
||||
try {
|
||||
// 过滤掉 'Eval' 标签,并确保转为逗号分隔的字符串
|
||||
const tagsToSync = (localTags || []).filter(tag => tag !== 'Eval').join(',');
|
||||
|
||||
const itemsToSave = variantItems.map(item => ({
|
||||
question: item.question,
|
||||
correctAnswer: item.correctAnswer,
|
||||
questionType: item.questionType || 'open_ended',
|
||||
options: item.options,
|
||||
tags: tagsToSync,
|
||||
note: dataset.note,
|
||||
chunkId: null // 变体暂时不关联原始文本块
|
||||
}));
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ items: itemsToSave })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save eval dataset');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast.success(t('datasets.saveVariantSuccess', '已保存到评估数据集'));
|
||||
|
||||
// 关闭对话框
|
||||
setVariantDialog({ open: false, data: null });
|
||||
} catch (error) {
|
||||
console.error('保存变体失败:', error);
|
||||
toast.error(t('datasets.saveVariantFailed', '保存失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
{/* 评分区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('datasets.rating', '评分')}
|
||||
</Typography>
|
||||
<StarRating value={localScore} onChange={handleScoreChange} readOnly={loading} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 标签区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('datasets.customTags', '自定义标签')}
|
||||
</Typography>
|
||||
<TagSelector
|
||||
value={localTags}
|
||||
onChange={handleTagsChange}
|
||||
availableTags={availableTags}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addCustomTag', '添加自定义标签...')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 备注区域 */}
|
||||
<NoteInput
|
||||
value={localNote}
|
||||
onChange={handleNoteChange}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addNote', '添加备注...')}
|
||||
/>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<PlaylistAddIcon />}
|
||||
onClick={handleAddToEval}
|
||||
disabled={addingToEval}
|
||||
sx={{ py: 1, flex: 1 }}
|
||||
>
|
||||
{addingToEval ? t('common.processing') : t('datasets.addToEval')}
|
||||
</Button>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<AutoFixHighIcon />}
|
||||
onClick={() => setVariantDialog({ open: true, data: null })}
|
||||
disabled={loading}
|
||||
sx={{ py: 1, flex: 1 }}
|
||||
>
|
||||
{t('datasets.generateEvalVariant')}
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{currentDataset.aiEvaluation && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom color="primary">
|
||||
{t('datasets.aiEvaluation')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{currentDataset.aiEvaluation}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<EvalVariantDialog
|
||||
open={variantDialog.open}
|
||||
onClose={() => setVariantDialog({ open: false, data: null })}
|
||||
onGenerate={handleGenerateEvalVariant}
|
||||
onSave={handleSaveEvalVariant}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
286
easy-dataset-main/components/datasets/EditableField.js
Normal file
286
easy-dataset-main/components/datasets/EditableField.js
Normal file
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
IconButton,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
CircularProgress,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
function getValue(value, answerType, useMarkdown, t, onOptimize) {
|
||||
if (value) {
|
||||
if (answerType === 'custom_format' && onOptimize) {
|
||||
try {
|
||||
const data = JSON.parse(value);
|
||||
value = JSON.stringify(data, null, 2);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'grey.50',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 2,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
<Typography component="pre" variant="body2" sx={{ m: 0 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
if (answerType === 'label' && onOptimize) {
|
||||
try {
|
||||
const labels = JSON.parse(value);
|
||||
if (Array.isArray(labels)) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{labels.map((label, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={String(label)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
sx={{ height: 22, '& .MuiChip-label': { px: 1 } }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return <Typography variant="body1">{value}</Typography>;
|
||||
}
|
||||
}
|
||||
return useMarkdown ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown>{value}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Typography variant="body1">{value}</Typography>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('common.noData')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可编辑字段组件,支持 Markdown 和原始文本两种展示方式
|
||||
*/
|
||||
export default function EditableField({
|
||||
label,
|
||||
value,
|
||||
multiline = true,
|
||||
editing,
|
||||
onEdit,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onOptimize,
|
||||
tokenCount,
|
||||
optimizing = false,
|
||||
dataset
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { answerType } = dataset;
|
||||
const custom = answerType === 'custom_format' || answerType === 'label';
|
||||
|
||||
// 从 localStorage 读取 Markdown 展示设置,默认为 false
|
||||
const [useMarkdown, setUseMarkdown] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('dataset-use-markdown');
|
||||
return saved ? JSON.parse(saved) : false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 当 useMarkdown 状态改变时,保存到 localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('dataset-use-markdown', JSON.stringify(useMarkdown));
|
||||
}
|
||||
}, [useMarkdown]);
|
||||
|
||||
const toggleMarkdown = () => {
|
||||
setUseMarkdown(!useMarkdown);
|
||||
};
|
||||
|
||||
const getAnswerTypeLabel = type => {
|
||||
switch (type) {
|
||||
case 'label':
|
||||
return t('imageDatasets.typeLabel', '标签');
|
||||
case 'custom_format':
|
||||
return t('imageDatasets.typeCustom', '自定义');
|
||||
default:
|
||||
return t('imageDatasets.typeText', '文本');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mr: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{!editing && value && (
|
||||
<>
|
||||
{onOptimize && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'info.50',
|
||||
color: 'info.main',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
border: '1px solid',
|
||||
borderColor: 'info.100',
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
{getAnswerTypeLabel(answerType)}
|
||||
</Box>
|
||||
)}
|
||||
{/* 字符数标签 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'info.50',
|
||||
color: 'info.main',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
border: '1px solid',
|
||||
borderColor: 'info.100',
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
{value.length} Characters
|
||||
</Box>
|
||||
|
||||
{/* Token 标签 */}
|
||||
{tokenCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'primary.50',
|
||||
color: 'primary.main',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.100',
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
{tokenCount} Tokens
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!editing && (
|
||||
<>
|
||||
<IconButton size="small" onClick={onEdit} disabled={optimizing}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{onOptimize && !custom && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onOptimize}
|
||||
disabled={optimizing}
|
||||
sx={{ ml: 0.5, position: 'relative' }}
|
||||
title={`optimizing=${optimizing}`}
|
||||
>
|
||||
{optimizing ? <CircularProgress size={20} /> : <AutoFixHighIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
)}
|
||||
{!custom && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={useMarkdown}
|
||||
onChange={toggleMarkdown}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={optimizing}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="caption">{useMarkdown ? 'Markdown' : 'Text'}</Typography>}
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{editing ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline={multiline}
|
||||
rows={10}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)'
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button variant="outlined" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)',
|
||||
'& img': {
|
||||
maxWidth: '100%'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getValue(value, answerType, useMarkdown, t, onOptimize)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
238
easy-dataset-main/components/datasets/EvalVariantDialog.js
Normal file
238
easy-dataset-main/components/datasets/EvalVariantDialog.js
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Slider,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 评估集变体编辑对话框
|
||||
*/
|
||||
export default function EvalVariantDialog({ open, onClose, onGenerate, onSave }) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState('config'); // 'config' | 'preview'
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [config, setConfig] = useState({
|
||||
questionType: 'open_ended',
|
||||
count: 1
|
||||
});
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('config');
|
||||
setConfig({ questionType: 'open_ended', count: 1 });
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await onGenerate(config);
|
||||
// Ensure data is an array
|
||||
const newItems = Array.isArray(data) ? data : [data];
|
||||
setItems(newItems);
|
||||
setStep('preview');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(items);
|
||||
};
|
||||
|
||||
const handleItemChange = (index, field, value) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleDeleteItem = index => {
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
if (newItems.length === 0) {
|
||||
setStep('config');
|
||||
}
|
||||
};
|
||||
|
||||
const renderConfigStep = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('datasets.evalVariantConfigHint', '请选择生成的题目类型和数量,AI 将基于当前问答对进行改写。')}
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('datasets.questionType', '题目类型')}</InputLabel>
|
||||
<Select
|
||||
value={config.questionType}
|
||||
label={t('datasets.questionType', '题目类型')}
|
||||
onChange={e => setConfig({ ...config, questionType: e.target.value })}
|
||||
>
|
||||
<MenuItem value="open_ended">{t('datasets.typeOpenEnded', '开放式问答')}</MenuItem>
|
||||
<MenuItem value="single_choice">{t('datasets.typeSingleChoice', '单选题')}</MenuItem>
|
||||
<MenuItem value="multiple_choice">{t('datasets.typeMultipleChoice', '多选题')}</MenuItem>
|
||||
<MenuItem value="true_false">{t('datasets.typeTrueFalse', '判断题')}</MenuItem>
|
||||
<MenuItem value="short_answer">{t('datasets.typeShortAnswer', '简答题')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>
|
||||
{t('datasets.generateCount', '生成数量')}: {config.count}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.count}
|
||||
onChange={(_, value) => setConfig({ ...config, count: value })}
|
||||
step={1}
|
||||
marks
|
||||
min={1}
|
||||
max={5}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderPreviewStep = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('datasets.evalVariantPreviewHint', '您可以编辑生成的题目,确认无误后保存到评估集。')}
|
||||
</Typography>
|
||||
|
||||
{items.map((item, index) => (
|
||||
<Card key={index} variant="outlined">
|
||||
<CardContent sx={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDeleteItem(index)}
|
||||
sx={{ position: 'absolute', right: 8, top: 8 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="subtitle2" color="primary">
|
||||
{t('datasets.questionIndex', '题目 {{index}}', { index: index + 1 })}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('datasets.question', '问题')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={item.question || ''}
|
||||
onChange={e => handleItemChange(index, 'question', e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* Render Options for choice questions */}
|
||||
{(item.options || config.questionType.includes('choice')) && (
|
||||
<TextField
|
||||
label={t('datasets.options', '选项 (JSON数组)')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={Array.isArray(item.options) ? JSON.stringify(item.options) : item.options || ''}
|
||||
onChange={e => {
|
||||
let val = e.target.value;
|
||||
try {
|
||||
// Try to parse if user inputs valid JSON, otherwise keep string
|
||||
const parsed = JSON.parse(val);
|
||||
if (Array.isArray(parsed)) val = parsed;
|
||||
} catch (e) {}
|
||||
handleItemChange(index, 'options', val);
|
||||
}}
|
||||
helperText={t('datasets.optionsHint', '例如: ["选项A", "选项B"]')}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label={t('datasets.answer', '答案')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={Array.isArray(item.correctAnswer) ? JSON.stringify(item.correctAnswer) : item.correctAnswer || ''}
|
||||
onChange={e => {
|
||||
let val = e.target.value;
|
||||
// For multiple choice, answer might be array
|
||||
if (config.questionType === 'multiple_choice') {
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (Array.isArray(parsed)) val = parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
handleItemChange(index, 'correctAnswer', val);
|
||||
}}
|
||||
helperText={
|
||||
config.questionType === 'multiple_choice'
|
||||
? t('datasets.answerArrayHint', '多选题答案请输入数组,如 ["A", "C"]')
|
||||
: config.questionType === 'true_false'
|
||||
? t('datasets.answerBoolHint', '判断题答案请输入 ✅ 或 ❌')
|
||||
: ''
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{step === 'config'
|
||||
? t('datasets.evalVariantTitle', '生成评估集变体')
|
||||
: t('datasets.evalVariantPreviewTitle', '确认生成的题目')}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>{step === 'config' ? renderConfigStep() : renderPreviewStep()}</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
|
||||
{step === 'config' ? (
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
startIcon={loading && <CircularProgress size={20} color="inherit" />}
|
||||
>
|
||||
{loading ? t('common.generating', '生成中...') : t('datasets.generate', '生成')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} variant="contained" color="primary" disabled={items.length === 0}>
|
||||
{t('datasets.saveToEval', '保存到评估集')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
169
easy-dataset-main/components/datasets/ImportDatasetDialog.js
Normal file
169
easy-dataset-main/components/datasets/ImportDatasetDialog.js
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileUploadStep from './import/FileUploadStep';
|
||||
// import DatasetSourceStep from './import/DatasetSourceStep'; // 不再需要
|
||||
import FieldMappingStep from './import/FieldMappingStep';
|
||||
import ImportProgressStep from './import/ImportProgressStep';
|
||||
|
||||
/**
|
||||
* 数据集导入对话框
|
||||
*/
|
||||
export default function ImportDatasetDialog({ open, onClose, projectId, onImportSuccess }) {
|
||||
const { t } = useTranslation();
|
||||
const [importType, setImportType] = useState('file'); // 只支持文件上传
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [importData, setImportData] = useState({
|
||||
rawData: null,
|
||||
previewData: null,
|
||||
fieldMapping: {},
|
||||
sourceInfo: null
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const steps = [
|
||||
t('import.fileUpload', '文件上传'),
|
||||
t('import.mapFields', '字段映射'),
|
||||
t('import.importing', '导入中')
|
||||
];
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep(prev => prev - 1);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0);
|
||||
setImportData({
|
||||
rawData: null,
|
||||
previewData: null,
|
||||
fieldMapping: {},
|
||||
sourceInfo: null
|
||||
});
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDataLoaded = (data, preview, source) => {
|
||||
setImportData({
|
||||
...importData,
|
||||
rawData: data,
|
||||
previewData: preview,
|
||||
sourceInfo: source
|
||||
});
|
||||
setError('');
|
||||
handleNext();
|
||||
};
|
||||
|
||||
const handleFieldMappingComplete = mapping => {
|
||||
setImportData({
|
||||
...importData,
|
||||
fieldMapping: mapping
|
||||
});
|
||||
handleNext();
|
||||
};
|
||||
|
||||
const handleImportComplete = () => {
|
||||
handleClose();
|
||||
if (onImportSuccess) {
|
||||
onImportSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return <FileUploadStep onDataLoaded={handleDataLoaded} onError={setError} />;
|
||||
case 1:
|
||||
return (
|
||||
<FieldMappingStep
|
||||
previewData={importData.previewData}
|
||||
onMappingComplete={handleFieldMappingComplete}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ImportProgressStep
|
||||
projectId={projectId}
|
||||
rawData={importData.rawData}
|
||||
fieldMapping={importData.fieldMapping}
|
||||
sourceInfo={importData.sourceInfo}
|
||||
onComplete={handleImportComplete}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { minHeight: 600 }
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t('import.title', '导入数据集')}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{/* 导入类型选择 - 只保留文件上传 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.fileUpload', '文件上传')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.fileUploadDescription', '上传本地文件导入数据集')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Stepper activeStep={currentStep} alternativeLabel>
|
||||
{steps.map(label => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 步骤内容 */}
|
||||
<Box sx={{ minHeight: 300 }}>{renderStepContent()}</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{t('common.cancel', '取消')}</Button>
|
||||
{currentStep > 0 && currentStep < 2 && <Button onClick={handleBack}>{t('common.back', '上一步')}</Button>}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
199
easy-dataset-main/components/datasets/NoteInput.js
Normal file
199
easy-dataset-main/components/datasets/NoteInput.js
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, TextField, Typography, IconButton, Tooltip, Collapse } 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 NotesIcon from '@mui/icons-material/Notes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 备注输入组件
|
||||
*/
|
||||
export default function NoteInput({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
maxLength = 500,
|
||||
minRows = 3,
|
||||
maxRows = 6
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [noteValue, setNoteValue] = useState(value);
|
||||
const [tempValue, setTempValue] = useState(value);
|
||||
|
||||
// 同步外部value变化
|
||||
useEffect(() => {
|
||||
setNoteValue(value);
|
||||
setTempValue(value);
|
||||
}, [value]);
|
||||
|
||||
// 开始编辑
|
||||
const handleStartEdit = () => {
|
||||
setIsEditing(true);
|
||||
setTempValue(noteValue);
|
||||
};
|
||||
|
||||
// 保存备注
|
||||
const handleSave = () => {
|
||||
setNoteValue(tempValue);
|
||||
setIsEditing(false);
|
||||
if (onChange) {
|
||||
onChange(tempValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancel = () => {
|
||||
setTempValue(noteValue);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 处理键盘快捷键
|
||||
const handleKeyDown = event => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<NotesIcon fontSize="small" color="action" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('datasets.note', '备注')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{noteValue ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', pl: 3 }}>
|
||||
{noteValue}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled" sx={{ pl: 3 }}>
|
||||
{t('datasets.noNote', '暂无备注')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 标题和操作按钮 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<NotesIcon fontSize="small" color="action" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('datasets.note', '备注')}
|
||||
</Typography>
|
||||
{noteValue && !isEditing && (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
({noteValue.length} / {maxLength})
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!isEditing && (
|
||||
<Tooltip title={t('common.edit', '编辑')}>
|
||||
<IconButton size="small" onClick={handleStartEdit}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 显示模式 */}
|
||||
<Collapse in={!isEditing}>
|
||||
<Box sx={{ pl: 3, mb: 2 }}>
|
||||
{noteValue ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover'
|
||||
},
|
||||
p: 1,
|
||||
borderRadius: 1
|
||||
}}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{noteValue}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover'
|
||||
},
|
||||
p: 1,
|
||||
borderRadius: 1
|
||||
}}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{placeholder || t('datasets.clickToAddNote', '点击添加备注...')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* 编辑模式 */}
|
||||
<Collapse in={isEditing}>
|
||||
<Box sx={{ pl: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={minRows}
|
||||
maxRows={maxRows}
|
||||
value={tempValue}
|
||||
onChange={e => setTempValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || t('datasets.enterNote', '请输入备注...')}
|
||||
inputProps={{ maxLength }}
|
||||
helperText={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('datasets.noteShortcuts', 'Ctrl+Enter 保存,Esc 取消')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={tempValue.length > maxLength * 0.9 ? 'warning.main' : 'text.secondary'}
|
||||
>
|
||||
{tempValue.length} / {maxLength}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Tooltip title={t('common.cancel', '取消')}>
|
||||
<IconButton size="small" onClick={handleCancel}>
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.save', '保存')}>
|
||||
<IconButton size="small" onClick={handleSave} color="primary" disabled={tempValue.length > maxLength}>
|
||||
<SaveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
easy-dataset-main/components/datasets/OptimizeDialog.js
Normal file
50
easy-dataset-main/components/datasets/OptimizeDialog.js
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* AI优化对话框组件
|
||||
*/
|
||||
export default function OptimizeDialog({ open, onClose, onConfirm }) {
|
||||
const [advice, setAdvice] = useState('');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(advice);
|
||||
setAdvice('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setAdvice('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('datasets.optimizeTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label={t('datasets.optimizeAdvice')}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows={4}
|
||||
value={advice}
|
||||
onChange={e => setAdvice(e.target.value)}
|
||||
placeholder={t('datasets.optimizePlaceholder')}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" color="primary" disabled={!advice.trim()}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
69
easy-dataset-main/components/datasets/StarRating.js
Normal file
69
easy-dataset-main/components/datasets/StarRating.js
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Rating, Typography } from '@mui/material';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 五星评分组件
|
||||
*/
|
||||
export default function StarRating({ value = 0, onChange, readOnly = false, size = 'medium', showLabel = true }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = useState(-1);
|
||||
|
||||
const labels = {
|
||||
0.5: t('rating.veryPoor', '很差'),
|
||||
1: t('rating.poor', '差'),
|
||||
1.5: t('rating.belowAverage', '偏差'),
|
||||
2: t('rating.fair', '一般'),
|
||||
2.5: t('rating.average', '中等'),
|
||||
3: t('rating.good', '良好'),
|
||||
3.5: t('rating.veryGood', '很好'),
|
||||
4: t('rating.excellent', '优秀'),
|
||||
4.5: t('rating.outstanding', '杰出'),
|
||||
5: t('rating.perfect', '完美')
|
||||
};
|
||||
|
||||
const getLabelText = value => {
|
||||
return `${value} Star${value !== 1 ? 's' : ''}, ${labels[value]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Rating
|
||||
name="dataset-rating"
|
||||
value={value}
|
||||
precision={0.5}
|
||||
getLabelText={getLabelText}
|
||||
onChange={(event, newValue) => {
|
||||
if (!readOnly && onChange) {
|
||||
onChange(newValue || 0);
|
||||
}
|
||||
}}
|
||||
onChangeActive={(event, newHover) => {
|
||||
if (!readOnly) {
|
||||
setHover(newHover);
|
||||
}
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
size={size}
|
||||
icon={<StarIcon fontSize="inherit" />}
|
||||
emptyIcon={<StarIcon fontSize="inherit" />}
|
||||
sx={{
|
||||
'& .MuiRating-iconFilled': {
|
||||
color: '#ffc107'
|
||||
},
|
||||
'& .MuiRating-iconHover': {
|
||||
color: '#ffb300'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showLabel && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 60 }}>
|
||||
{labels[hover !== -1 ? hover : value] || (value === 0 ? t('rating.unrated', '未评分') : '')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
185
easy-dataset-main/components/datasets/TagSelector.js
Normal file
185
easy-dataset-main/components/datasets/TagSelector.js
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Chip, TextField, Autocomplete, Typography, IconButton, Tooltip } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 标签选择器组件
|
||||
* 支持从已有标签选择和自定义添加新标签
|
||||
*/
|
||||
export default function TagSelector({
|
||||
value = [],
|
||||
onChange,
|
||||
availableTags = [],
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
maxTags = 10
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// 确保 value 始终是数组
|
||||
const normalizeValue = val => {
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState(() => normalizeValue(value));
|
||||
|
||||
// 同步外部value变化
|
||||
useEffect(() => {
|
||||
setSelectedTags(normalizeValue(value));
|
||||
}, [value]);
|
||||
|
||||
// 处理标签变更
|
||||
const handleTagsChange = newTags => {
|
||||
setSelectedTags(newTags);
|
||||
if (onChange) {
|
||||
onChange(newTags);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加新标签
|
||||
const handleAddTag = newTag => {
|
||||
if (!newTag || newTag.trim() === '') return;
|
||||
|
||||
const trimmedTag = newTag.trim();
|
||||
if (selectedTags.includes(trimmedTag)) return;
|
||||
|
||||
if (selectedTags.length >= maxTags) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTags = [...selectedTags, trimmedTag];
|
||||
handleTagsChange(updatedTags);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
// 删除标签
|
||||
const handleDeleteTag = tagToDelete => {
|
||||
const updatedTags = selectedTags.filter(tag => tag !== tagToDelete);
|
||||
handleTagsChange(updatedTags);
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyPress = event => {
|
||||
if (event.key === 'Enter' && inputValue.trim()) {
|
||||
event.preventDefault();
|
||||
handleAddTag(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取可选的标签选项(排除已选择的)
|
||||
const getAvailableOptions = () => {
|
||||
return availableTags.filter(tag => !selectedTags.includes(tag));
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{selectedTags.length > 0 ? (
|
||||
selectedTags.map((tag, index) => (
|
||||
<Chip key={index} label={tag} size="small" variant="outlined" color="primary" />
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('tags.noTags', '暂无标签')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 已选择的标签 */}
|
||||
{selectedTags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
{selectedTags.map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onDelete={() => handleDeleteTag(tag)}
|
||||
deleteIcon={<CloseIcon />}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 标签输入区域 */}
|
||||
{selectedTags.length < maxTags && (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={getAvailableOptions()}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(event, newInputValue) => {
|
||||
setInputValue(newInputValue);
|
||||
}}
|
||||
onChange={(event, newValue) => {
|
||||
if (newValue) {
|
||||
handleAddTag(newValue);
|
||||
}
|
||||
}}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
size="small"
|
||||
placeholder={placeholder || t('tags.addTag', '添加标签...')}
|
||||
onKeyPress={handleKeyPress}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<Box component="li" {...props}>
|
||||
<Typography variant="body2">{option}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
|
||||
<Tooltip title={t('tags.addCustomTag', '添加自定义标签')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleAddTag(inputValue)}
|
||||
disabled={!inputValue.trim()}
|
||||
color="primary"
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 标签数量提示 */}
|
||||
{selectedTags.length >= maxTags && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{t('tags.maxTagsReached', `最多可添加 ${maxTags} 个标签`)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 可用标签提示 */}
|
||||
{availableTags.length > 0 && selectedTags.length < maxTags && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{t('tags.availableTagsHint', '可从已有标签中选择,或输入新标签')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
314
easy-dataset-main/components/datasets/import/FieldMappingStep.js
Normal file
314
easy-dataset-main/components/datasets/import/FieldMappingStep.js
Normal file
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Alert,
|
||||
Button,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 字段映射步骤组件
|
||||
*/
|
||||
export default function FieldMappingStep({ previewData, onMappingComplete, onError }) {
|
||||
const { t } = useTranslation();
|
||||
const [fieldMapping, setFieldMapping] = useState({
|
||||
question: '',
|
||||
answer: '',
|
||||
cot: '',
|
||||
tags: ''
|
||||
});
|
||||
const [availableFields, setAvailableFields] = useState([]);
|
||||
const [mappingValid, setMappingValid] = useState(false);
|
||||
|
||||
// 智能字段识别(支持 Alpaca: instruction + input -> question,output -> answer)
|
||||
const smartFieldMapping = fields => {
|
||||
const mapping = {
|
||||
question: '',
|
||||
answer: '',
|
||||
cot: '',
|
||||
tags: ''
|
||||
};
|
||||
|
||||
const lower = fields.map(f => f.toLowerCase());
|
||||
const instructionIdx = lower.findIndex(f => f.includes('instruction'));
|
||||
const inputIdx = lower.findIndex(f => f.includes('input'));
|
||||
const outputIdx = lower.findIndex(f => f.includes('output'));
|
||||
|
||||
// Alpaca 格式的优先识别
|
||||
if (instructionIdx !== -1 && inputIdx !== -1) {
|
||||
// 如果同时有instruction和input字段,将它们组合为question
|
||||
mapping.question = [fields[instructionIdx], fields[inputIdx]];
|
||||
} else if (instructionIdx !== -1) {
|
||||
// 如果只有instruction字段(比如从ShareGPT转换而来),直接映射为question
|
||||
mapping.question = fields[instructionIdx];
|
||||
}
|
||||
|
||||
if (outputIdx !== -1) {
|
||||
mapping.answer = fields[outputIdx];
|
||||
}
|
||||
|
||||
const questionKeywords = ['question', 'input', 'query', 'prompt', 'instruction', '问题', '输入', '指令'];
|
||||
const answerKeywords = ['answer', 'output', 'response', 'completion', 'target', '答案', '输出', '回答'];
|
||||
const cotKeywords = ['cot', 'reasoning', 'explanation', 'thinking', 'rationale', '思维链', '推理', '解释'];
|
||||
const tagKeywords = ['tag', 'tags', 'label', 'labels', 'category', 'categories', '标签', '类别'];
|
||||
|
||||
fields.forEach(field => {
|
||||
const fieldLower = field.toLowerCase();
|
||||
|
||||
if (!mapping.question || (typeof mapping.question === 'string' && !mapping.question)) {
|
||||
if (questionKeywords.some(keyword => fieldLower.includes(keyword))) {
|
||||
mapping.question = field;
|
||||
}
|
||||
} else if (!mapping.answer) {
|
||||
if (answerKeywords.some(keyword => fieldLower.includes(keyword))) {
|
||||
mapping.answer = field;
|
||||
}
|
||||
} else if (!mapping.cot) {
|
||||
if (cotKeywords.some(keyword => fieldLower.includes(keyword))) {
|
||||
mapping.cot = field;
|
||||
}
|
||||
} else if (!mapping.tags) {
|
||||
if (tagKeywords.some(keyword => fieldLower.includes(keyword))) {
|
||||
mapping.tags = field;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return mapping;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (previewData && previewData.length > 0) {
|
||||
const fields = Object.keys(previewData[0]);
|
||||
setAvailableFields(fields);
|
||||
|
||||
// 智能识别字段映射
|
||||
const smartMapping = smartFieldMapping(fields);
|
||||
setFieldMapping(smartMapping);
|
||||
}
|
||||
}, [previewData]);
|
||||
|
||||
useEffect(() => {
|
||||
// 验证映射是否有效(问题和答案字段必须选择)
|
||||
const hasQuestion = Array.isArray(fieldMapping.question)
|
||||
? fieldMapping.question.length > 0
|
||||
: !!fieldMapping.question;
|
||||
const hasAnswer = !!fieldMapping.answer;
|
||||
const isValid = hasQuestion && hasAnswer;
|
||||
setMappingValid(isValid);
|
||||
}, [fieldMapping]);
|
||||
|
||||
const handleFieldChange = (targetField, sourceField) => {
|
||||
setFieldMapping(prev => ({
|
||||
...prev,
|
||||
[targetField]:
|
||||
targetField === 'question'
|
||||
? Array.isArray(sourceField)
|
||||
? sourceField.filter(Boolean)
|
||||
: sourceField
|
||||
: sourceField
|
||||
}));
|
||||
};
|
||||
|
||||
const handleConfirmMapping = () => {
|
||||
if (!mappingValid) {
|
||||
onError(t('import.mappingRequired', '问题和答案字段为必选项'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有重复映射(兼容数组)
|
||||
const flatFields = Object.values(fieldMapping)
|
||||
.filter(Boolean)
|
||||
.flatMap(f => (Array.isArray(f) ? f.filter(Boolean) : [f]));
|
||||
const uniqueFields = [...new Set(flatFields)];
|
||||
if (flatFields.length !== uniqueFields.length) {
|
||||
onError(t('import.duplicateMapping', '不能将多个目标字段映射到同一个源字段'));
|
||||
return;
|
||||
}
|
||||
|
||||
onMappingComplete(fieldMapping);
|
||||
};
|
||||
|
||||
const getFieldDescription = field => {
|
||||
switch (field) {
|
||||
case 'question':
|
||||
return t('import.questionDesc', '用户的问题或输入内容(必选,可多选)');
|
||||
case 'answer':
|
||||
return t('import.answerDesc', 'AI的回答或输出内容(必选)');
|
||||
case 'cot':
|
||||
return t('import.cotDesc', '思维链或推理过程(可选)');
|
||||
case 'tags':
|
||||
return t('import.tagsDesc', '标签数组,多个标签用逗号分隔(可选)');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isFieldRequired = field => {
|
||||
return field === 'question' || field === 'answer';
|
||||
};
|
||||
|
||||
if (!previewData || previewData.length === 0) {
|
||||
return <Alert severity="error">{t('import.noPreviewData', '没有可预览的数据')}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.fieldMapping', '字段映射')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t(
|
||||
'import.mappingDescription',
|
||||
'请将源数据的字段映射到目标字段。系统已自动识别可能的映射关系,您可以根据需要调整。'
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{/* 字段映射选择 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('import.selectMapping', '选择字段映射')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
|
||||
{Object.keys(fieldMapping).map(targetField => (
|
||||
<FormControl key={targetField} fullWidth>
|
||||
<InputLabel>
|
||||
{t(`import.${targetField}Field`, targetField)}
|
||||
{isFieldRequired(targetField) && <span style={{ color: 'red' }}>*</span>}
|
||||
</InputLabel>
|
||||
{targetField === 'question' ? (
|
||||
<Select
|
||||
multiple
|
||||
value={
|
||||
Array.isArray(fieldMapping.question)
|
||||
? fieldMapping.question
|
||||
: fieldMapping.question
|
||||
? [fieldMapping.question]
|
||||
: []
|
||||
}
|
||||
label={t(`import.${targetField}Field`, targetField)}
|
||||
onChange={e => handleFieldChange(targetField, e.target.value)}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{availableFields.map(field => (
|
||||
<MenuItem key={field} value={field}>
|
||||
{field}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Select
|
||||
value={fieldMapping[targetField]}
|
||||
label={t(`import.${targetField}Field`, targetField)}
|
||||
onChange={e => handleFieldChange(targetField, e.target.value)}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>{t('import.selectField', '选择字段')}</em>
|
||||
</MenuItem>
|
||||
{availableFields.map(field => (
|
||||
<MenuItem key={field} value={field}>
|
||||
{field}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{getFieldDescription(targetField)}
|
||||
</Typography>
|
||||
</FormControl>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* 数据预览 */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle1">{t('import.dataPreview', '数据预览')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.previewNote', '显示前3条记录,每个字段值最多显示100个字符')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TableContainer sx={{ maxHeight: 400 }}>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{availableFields.map(field => (
|
||||
<TableCell key={field} sx={{ minWidth: 150 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">{field}</Typography>
|
||||
{Object.entries(fieldMapping).map(([targetField, sourceField]) => {
|
||||
const match = Array.isArray(sourceField) ? sourceField.includes(field) : sourceField === field;
|
||||
if (match) {
|
||||
return (
|
||||
<Chip
|
||||
key={targetField}
|
||||
label={t(`import.${targetField}Field`, targetField)}
|
||||
size="small"
|
||||
color={isFieldRequired(targetField) ? 'primary' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{previewData.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{availableFields.map(field => (
|
||||
<TableCell key={field}>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
|
||||
{row[field] || '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="contained" onClick={handleConfirmMapping} disabled={!mappingValid}>
|
||||
{t('import.confirmMapping', '确认映射')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!mappingValid && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
{t('import.requiredFields', '请至少选择问题和答案字段的映射')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
344
easy-dataset-main/components/datasets/import/FileUploadStep.js
Normal file
344
easy-dataset-main/components/datasets/import/FileUploadStep.js
Normal file
@@ -0,0 +1,344 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
LinearProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { CloudUpload as UploadIcon, Description as FileIcon, CheckCircle as CheckIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// import { useDropzone } from 'react-dropzone';
|
||||
|
||||
/**
|
||||
* 文件上传步骤组件
|
||||
*/
|
||||
export default function FileUploadStep({ onDataLoaded, onError }) {
|
||||
const { t } = useTranslation();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
|
||||
// 健壮的CSV解析函数,支持多行字段和引号转义
|
||||
const parseCSV = text => {
|
||||
const result = [];
|
||||
const lines = [];
|
||||
let currentLine = '';
|
||||
let inQuotes = false;
|
||||
|
||||
// 逐字符解析,正确处理引号内的换行符
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
// 转义的引号
|
||||
currentLine += '"';
|
||||
i++; // 跳过下一个引号
|
||||
} else {
|
||||
// 切换引号状态
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === '\n' && !inQuotes) {
|
||||
// 行结束(不在引号内)
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
currentLine = '';
|
||||
} else {
|
||||
currentLine += char;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一行
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV文件格式不正确,至少需要标题行和一行数据');
|
||||
}
|
||||
|
||||
// 解析标题行
|
||||
const headers = parseCSVLine(lines[0]);
|
||||
|
||||
// 解析数据行
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = parseCSVLine(lines[i]);
|
||||
if (values.length > 0) {
|
||||
const obj = {};
|
||||
headers.forEach((header, index) => {
|
||||
obj[header] = values[index] || '';
|
||||
});
|
||||
result.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 解析单行CSV,处理逗号分隔和引号转义
|
||||
const parseCSVLine = line => {
|
||||
const result = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
// 转义的引号
|
||||
current += '"';
|
||||
i++; // 跳过下一个引号
|
||||
} else {
|
||||
// 切换引号状态
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
// 字段分隔符(不在引号内)
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一个字段
|
||||
result.push(current.trim());
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 检测并转换ShareGPT格式为Alpaca格式
|
||||
const convertShareGPTToAlpaca = item => {
|
||||
// 检查是否包含conversations字段且格式正确
|
||||
if (item.conversations && Array.isArray(item.conversations)) {
|
||||
const conversations = item.conversations;
|
||||
|
||||
// 查找system、human、gpt消息
|
||||
let systemMessage = '';
|
||||
let instruction = '';
|
||||
let output = '';
|
||||
|
||||
for (const conv of conversations) {
|
||||
if (conv.from === 'system' && conv.value) {
|
||||
systemMessage = conv.value;
|
||||
} else if (conv.from === 'human' && conv.value) {
|
||||
instruction = conv.value;
|
||||
} else if (conv.from === 'gpt' && conv.value) {
|
||||
output = conv.value;
|
||||
break; // 只取第一轮对话
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有system消息,将其作为instruction的前缀
|
||||
if (systemMessage && instruction) {
|
||||
instruction = `${systemMessage}\n\n${instruction}`;
|
||||
} else if (systemMessage && !instruction) {
|
||||
instruction = systemMessage;
|
||||
}
|
||||
|
||||
// 转换为Alpaca格式
|
||||
return {
|
||||
instruction: instruction || '',
|
||||
input: '', // ShareGPT格式通常没有单独的input字段
|
||||
output: output || '',
|
||||
// 保留其他字段
|
||||
...Object.fromEntries(Object.entries(item).filter(([key]) => key !== 'conversations'))
|
||||
};
|
||||
}
|
||||
|
||||
return item; // 如果不是ShareGPT格式,返回原始数据
|
||||
};
|
||||
|
||||
const parseFileContent = async file => {
|
||||
const text = await file.text();
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
try {
|
||||
let data = [];
|
||||
|
||||
if (extension === 'json') {
|
||||
const parsed = JSON.parse(text);
|
||||
data = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} else if (extension === 'jsonl') {
|
||||
data = text
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => JSON.parse(line));
|
||||
} else if (extension === 'csv') {
|
||||
// 更健壮的CSV解析,支持多行字段和引号转义
|
||||
data = parseCSV(text);
|
||||
if (data.length === 0) {
|
||||
throw new Error('CSV文件格式不正确或没有数据');
|
||||
}
|
||||
} else {
|
||||
throw new Error('不支持的文件格式');
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error('文件中没有找到有效数据');
|
||||
}
|
||||
|
||||
// 检测并转换ShareGPT格式为Alpaca格式
|
||||
data = data.map(convertShareGPTToAlpaca);
|
||||
|
||||
// 生成预览数据(取前3条记录,每个字段值截取前100字符)
|
||||
const previewData = data.slice(0, 3).map(item => {
|
||||
const preview = {};
|
||||
Object.keys(item).forEach(key => {
|
||||
const value = String(item[key] || '');
|
||||
preview[key] = value.length > 100 ? value.substring(0, 100) + '...' : value;
|
||||
});
|
||||
return preview;
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
preview: previewData,
|
||||
source: {
|
||||
type: 'file',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
totalRecords: data.length
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`解析文件失败: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async event => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const result = await parseFileContent(file);
|
||||
setUploadedFiles([
|
||||
{
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: 'success'
|
||||
}
|
||||
]);
|
||||
|
||||
onDataLoaded(result.data, result.preview, result.source);
|
||||
} catch (error) {
|
||||
setUploadedFiles([
|
||||
{
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: 'error',
|
||||
error: error.message
|
||||
}
|
||||
]);
|
||||
onError(error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = bytes => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.uploadFile', '上传文件')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('import.supportedFormats', '支持 JSON、JSONL、CSV 格式文件')}
|
||||
</Typography>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
border: '2px dashed',
|
||||
borderColor: 'divider',
|
||||
backgroundColor: 'background.paper',
|
||||
transition: 'all 0.2s ease',
|
||||
mb: 3,
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
backgroundColor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
onClick={() => document.getElementById('file-upload-input').click()}
|
||||
>
|
||||
<input
|
||||
id="file-upload-input"
|
||||
type="file"
|
||||
accept=".json,.jsonl,.csv"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<UploadIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.dragDropFile', '拖拽文件到此处或点击选择文件')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.maxFileSize', '最大文件大小: 50MB')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploading && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{t('import.processingFile', '正在处理文件...')}
|
||||
</Typography>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 已上传文件列表 */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('import.uploadedFiles', '已上传文件')}
|
||||
</Typography>
|
||||
<List>
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ListItemIcon>
|
||||
{file.status === 'success' ? <CheckIcon color="success" /> : <FileIcon color="error" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={file.name}
|
||||
secondary={file.status === 'success' ? `${formatFileSize(file.size)}` : file.error}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{uploadedFiles.some(f => f.status === 'error') && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{t('import.uploadError', '文件上传失败,请检查文件格式是否正确')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { CheckCircle as CheckIcon, Error as ErrorIcon, Info as InfoIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 导入进度步骤组件
|
||||
*/
|
||||
export default function ImportProgressStep({ projectId, rawData, fieldMapping, sourceInfo, onComplete, onError }) {
|
||||
const { t } = useTranslation();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentStep, setCurrentStep] = useState('');
|
||||
const [importStats, setImportStats] = useState({
|
||||
total: 0,
|
||||
processed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: []
|
||||
});
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const startedRef = useRef(false); // 防止在开发模式下因严格模式导致重复执行
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedRef.current && rawData && fieldMapping && projectId) {
|
||||
startedRef.current = true;
|
||||
startImport();
|
||||
}
|
||||
}, [rawData, fieldMapping, projectId]);
|
||||
|
||||
const startImport = async () => {
|
||||
try {
|
||||
setCurrentStep(t('import.preparingData', '准备数据...'));
|
||||
setImportStats(prev => ({ ...prev, total: rawData.length }));
|
||||
|
||||
// 转换数据格式
|
||||
const convertedData = rawData.map(item => {
|
||||
// 支持 question 映射多个字段,拼接为一个字符串
|
||||
const qFields = fieldMapping.question;
|
||||
const question = Array.isArray(qFields)
|
||||
? qFields
|
||||
.map(f => item[f] || '')
|
||||
.filter(v => v && String(v).trim())
|
||||
.join('\n')
|
||||
: item[qFields] || '';
|
||||
|
||||
const converted = {
|
||||
question,
|
||||
answer: item[fieldMapping.answer] || '',
|
||||
cot: fieldMapping.cot ? item[fieldMapping.cot] || '' : '',
|
||||
questionLabel: '', // 默认标签,后续可以通过AI生成
|
||||
chunkName: sourceInfo?.datasetName || sourceInfo?.fileName || 'Imported Data',
|
||||
chunkContent: `Imported from ${sourceInfo?.type || 'file'}`,
|
||||
model: 'imported',
|
||||
confirmed: false,
|
||||
score: 0,
|
||||
tags: fieldMapping.tags ? JSON.stringify(parseTagsField(item[fieldMapping.tags])) : '[]',
|
||||
note: '',
|
||||
other: JSON.stringify(getOtherFields(item, fieldMapping))
|
||||
};
|
||||
|
||||
// 不在前端抛错,由后端负责校验并统计 skipped
|
||||
return converted;
|
||||
});
|
||||
|
||||
setProgress(25);
|
||||
setCurrentStep(t('import.uploadingData', '上传数据...'));
|
||||
|
||||
// 分批上传数据
|
||||
const batchSize = 500;
|
||||
let processed = 0;
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < convertedData.length; i += batchSize) {
|
||||
const batch = convertedData.slice(i, i + batchSize);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
datasets: batch,
|
||||
sourceInfo
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`批次上传失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
success += result.success || 0;
|
||||
failed += typeof result.failed === 'number' ? result.failed : result.errors?.length || 0;
|
||||
skipped += result.skipped || 0;
|
||||
processed += batch.length;
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
failed += batch.length;
|
||||
processed += batch.length;
|
||||
errors.push(`批次 ${Math.floor(i / batchSize) + 1}: ${error.message}`);
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
const progressPercent = 25 + (processed / convertedData.length) * 70;
|
||||
setProgress(progressPercent);
|
||||
setImportStats({
|
||||
total: convertedData.length,
|
||||
processed,
|
||||
success,
|
||||
failed,
|
||||
skipped,
|
||||
errors
|
||||
});
|
||||
|
||||
setCurrentStep(
|
||||
t('import.processing', '处理中... {{processed}}/{{total}}', {
|
||||
processed,
|
||||
total: convertedData.length
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setProgress(100);
|
||||
setCurrentStep(t('import.completed', '导入完成'));
|
||||
setCompleted(true);
|
||||
|
||||
// 延迟一下再调用完成回调,让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
onError(error.message);
|
||||
setImportStats(prev => ({
|
||||
...prev,
|
||||
errors: [...prev.errors, error.message]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 解析标签字段
|
||||
const parseTagsField = tagsValue => {
|
||||
if (!tagsValue) return [];
|
||||
|
||||
if (Array.isArray(tagsValue)) {
|
||||
return tagsValue;
|
||||
}
|
||||
|
||||
if (typeof tagsValue === 'string') {
|
||||
return tagsValue
|
||||
.split(',')
|
||||
.map(tag => tag.trim())
|
||||
.filter(tag => tag);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// 获取其他字段(兼容数组映射)
|
||||
const getOtherFields = (item, mapping) => {
|
||||
const used = [];
|
||||
Object.values(mapping).forEach(field => {
|
||||
if (!field) return;
|
||||
if (Array.isArray(field)) used.push(...field);
|
||||
else used.push(field);
|
||||
});
|
||||
const mappedFields = new Set(used);
|
||||
const otherFields = {};
|
||||
|
||||
Object.keys(item).forEach(key => {
|
||||
if (!mappedFields.has(key)) {
|
||||
otherFields[key] = item[key];
|
||||
}
|
||||
});
|
||||
|
||||
return otherFields;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.importing', '正在导入数据集')}
|
||||
</Typography>
|
||||
|
||||
{/* 进度条 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
{currentStep}
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={progress} sx={{ height: 8, borderRadius: 4, mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(progress)}% {t('import.complete', '完成')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* 导入统计 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('import.importStats', '导入统计')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
icon={<InfoIcon />}
|
||||
label={t('import.total', '总计: {{count}}', { count: importStats.total })}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
icon={<CheckIcon />}
|
||||
label={t('import.success', '成功: {{count}}', { count: importStats.success })}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
/>
|
||||
{importStats.skipped > 0 && (
|
||||
<Chip
|
||||
icon={<InfoIcon />}
|
||||
label={t('import.skipped', '跳过: {{count}}', { count: importStats.skipped })}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{importStats.failed > 0 && (
|
||||
<Chip
|
||||
icon={<ErrorIcon />}
|
||||
label={t('import.failed', '失败: {{count}}', { count: importStats.failed })}
|
||||
color="error"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{sourceInfo && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.source', '数据源')}:{' '}
|
||||
{sourceInfo.type === 'file' ? sourceInfo.fileName : sourceInfo.datasetName}
|
||||
</Typography>
|
||||
{sourceInfo.description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.description', '描述')}: {sourceInfo.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* 错误列表 */}
|
||||
{importStats.errors.length > 0 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom color="error">
|
||||
{t('import.errors', '错误信息')}
|
||||
</Typography>
|
||||
<List dense>
|
||||
{importStats.errors.slice(0, 10).map((error, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ListItemIcon>
|
||||
<ErrorIcon color="error" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={error} primaryTypographyProps={{ variant: 'body2' }} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{importStats.errors.length > 10 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.moreErrors', '还有 {{count}} 个错误未显示...', {
|
||||
count: importStats.errors.length - 10
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* 完成提示 */}
|
||||
{completed && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{t('import.importSuccess', '数据集导入完成!成功导入 {{success}} 条记录。', {
|
||||
success: importStats.success
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
135
easy-dataset-main/components/datasets/utils/ratingUtils.js
Normal file
135
easy-dataset-main/components/datasets/utils/ratingUtils.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 评分相关的工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 根据评分获取对应的颜色和标签(不包含国际化)
|
||||
* @param {number} score - 评分 (0-5)
|
||||
* @returns {object} - 包含颜色、背景色和标签的对象
|
||||
*/
|
||||
export const getRatingConfig = score => {
|
||||
if (score >= 4.5) {
|
||||
return {
|
||||
color: '#2e7d32', // 深绿色
|
||||
backgroundColor: '#e8f5e8',
|
||||
label: '优秀',
|
||||
variant: 'excellent'
|
||||
};
|
||||
} else if (score >= 3.5) {
|
||||
return {
|
||||
color: '#388e3c', // 绿色
|
||||
backgroundColor: '#f1f8e9',
|
||||
label: '良好',
|
||||
variant: 'good'
|
||||
};
|
||||
} else if (score >= 2.5) {
|
||||
return {
|
||||
color: '#f57c00', // 橙色
|
||||
backgroundColor: '#fff3e0',
|
||||
label: '一般',
|
||||
variant: 'average'
|
||||
};
|
||||
} else if (score >= 1.5) {
|
||||
return {
|
||||
color: '#f44336', // 红色
|
||||
backgroundColor: '#ffebee',
|
||||
label: '较差',
|
||||
variant: 'poor'
|
||||
};
|
||||
} else if (score > 0) {
|
||||
return {
|
||||
color: '#d32f2f', // 深红色
|
||||
backgroundColor: '#ffebee',
|
||||
label: '很差',
|
||||
variant: 'very-poor'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
color: '#757575', // 灰色
|
||||
backgroundColor: '#f5f5f5',
|
||||
label: '未评分',
|
||||
variant: 'unrated'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据评分获取对应的颜色和国际化标签
|
||||
* @param {number} score - 评分 (0-5)
|
||||
* @param {function} t - 国际化翻译函数
|
||||
* @returns {object} - 包含颜色、背景色和国际化标签的对象
|
||||
*/
|
||||
export const getRatingConfigI18n = (score, t) => {
|
||||
const baseConfig = getRatingConfig(score);
|
||||
|
||||
// 根据variant获取对应的翻译键
|
||||
let translationKey;
|
||||
let fallbackText;
|
||||
|
||||
switch (baseConfig.variant) {
|
||||
case 'excellent':
|
||||
translationKey = 'datasets.ratingExcellent';
|
||||
fallbackText = '优秀';
|
||||
break;
|
||||
case 'good':
|
||||
translationKey = 'datasets.ratingGood';
|
||||
fallbackText = '良好';
|
||||
break;
|
||||
case 'average':
|
||||
translationKey = 'datasets.ratingAverage';
|
||||
fallbackText = '一般';
|
||||
break;
|
||||
case 'poor':
|
||||
translationKey = 'datasets.ratingPoor';
|
||||
fallbackText = '较差';
|
||||
break;
|
||||
case 'very-poor':
|
||||
translationKey = 'datasets.ratingVeryPoor';
|
||||
fallbackText = '很差';
|
||||
break;
|
||||
case 'unrated':
|
||||
translationKey = 'datasets.ratingUnrated';
|
||||
fallbackText = '未评分';
|
||||
break;
|
||||
default:
|
||||
translationKey = 'datasets.ratingUnrated';
|
||||
fallbackText = '未评分';
|
||||
}
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
label: t(translationKey, fallbackText)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化评分显示
|
||||
* @param {number} score - 评分
|
||||
* @returns {string} - 格式化后的评分字符串
|
||||
*/
|
||||
export const formatScore = score => {
|
||||
if (score === 0) return '';
|
||||
return score.toFixed(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评分范围的描述
|
||||
* @param {number} score - 评分
|
||||
* @returns {string} - 评分范围描述
|
||||
*/
|
||||
export const getScoreDescription = score => {
|
||||
const config = getRatingConfig(score);
|
||||
return `${formatScore(score)} - ${config.label}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 评分范围常量
|
||||
*/
|
||||
export const SCORE_RANGES = {
|
||||
EXCELLENT: { min: 4.5, max: 5.0, label: '优秀' },
|
||||
GOOD: { min: 3.5, max: 4.4, label: '良好' },
|
||||
AVERAGE: { min: 2.5, max: 3.4, label: '一般' },
|
||||
POOR: { min: 1.5, max: 2.4, label: '较差' },
|
||||
VERY_POOR: { min: 0.1, max: 1.4, label: '很差' },
|
||||
UNRATED: { min: 0, max: 0, label: '未评分' }
|
||||
};
|
||||
Reference in New Issue
Block a user