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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View 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: '未评分' }
};