304 lines
9.2 KiB
JavaScript
304 lines
9.2 KiB
JavaScript
|
|
'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>
|
|||
|
|
);
|
|||
|
|
}
|