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,226 @@
// ExportDatasetDialog.js 组件
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Tabs, Tab } from '@mui/material';
// 导入拆分后的组件
import LocalExportTab from './export/LocalExportTab';
import LlamaFactoryTab from './export/LlamaFactoryTab';
import HuggingFaceTab from './export/HuggingFaceTab';
const ExportDatasetDialog = ({ open, onClose, onExport, projectId }) => {
const { t } = useTranslation();
const [formatType, setFormatType] = useState('alpaca');
const [systemPrompt, setSystemPrompt] = useState('');
const [reasoningLanguage, setReasoningLanguage] = useState('');
const [confirmedOnly, setConfirmedOnly] = useState(false);
const [fileFormat, setFileFormat] = useState('json');
const [includeCOT, setIncludeCOT] = useState(true);
const [currentTab, setCurrentTab] = useState(0);
// alpaca 格式特有的设置
const [alpacaFieldType, setAlpacaFieldType] = useState('instruction'); // 'instruction' 或 'input'
const [customInstruction, setCustomInstruction] = useState(''); // 当选择 input 时使用的自定义 instruction
const [customFields, setCustomFields] = useState({
questionField: 'instruction',
answerField: 'output',
cotField: 'complexCOT', // 添加思维链字段名
includeLabels: false,
includeChunk: false, // 添加是否包含chunk字段
questionOnly: false // 添加仅导出问题选项
});
const handleFileFormatChange = event => {
setFileFormat(event.target.value);
};
const handleFormatChange = event => {
setFormatType(event.target.value);
// 根据格式类型设置默认字段名
if (event.target.value === 'alpaca') {
setCustomFields({
...customFields,
questionField: 'instruction',
answerField: 'output'
});
} else if (event.target.value === 'sharegpt') {
setCustomFields({
...customFields,
questionField: 'content',
answerField: 'content'
});
} else if (event.target.value === 'multilingual-thinking') {
setCustomFields({
...customFields,
questionField: 'content',
answerField: 'content'
});
} else if (event.target.value === 'custom') {
// 自定义格式保持当前值
}
};
const handleSystemPromptChange = event => {
setSystemPrompt(event.target.value);
};
const handleReasoningLanguageChange = event => {
setReasoningLanguage(event.target.value);
};
const handleConfirmedOnlyChange = event => {
setConfirmedOnly(event.target.checked);
};
// 新增处理函数
const handleIncludeCOTChange = event => {
setIncludeCOT(event.target.checked);
};
const handleCustomFieldChange = field => event => {
setCustomFields({
...customFields,
[field]: event.target.value
});
};
const handleIncludeLabelsChange = event => {
setCustomFields({
...customFields,
includeLabels: event.target.checked
});
};
const handleIncludeChunkChange = event => {
setCustomFields({
...customFields,
includeChunk: event.target.checked
});
};
const handleQuestionOnlyChange = event => {
setCustomFields({
...customFields,
questionOnly: event.target.checked
});
};
// 处理 Alpaca 字段类型变更
const handleAlpacaFieldTypeChange = event => {
setAlpacaFieldType(event.target.value);
};
// 处理自定义 instruction 变更
const handleCustomInstructionChange = event => {
setCustomInstruction(event.target.value);
};
const handleExport = options => {
// 如果 LocalExportTab 传入了完整的导出配置(例如平衡导出),直接使用该配置
if (options && typeof options === 'object' && options.balanceMode) {
onExport(options);
return;
}
// 否则使用当前对话框内的状态组装导出配置
onExport({
formatType,
systemPrompt,
reasoningLanguage,
confirmedOnly,
fileFormat,
includeCOT,
alpacaFieldType, // 添加 alpaca 字段类型
customInstruction, // 添加自定义 instruction
customFields: formatType === 'custom' ? customFields : undefined
});
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 2
}
}}
>
<DialogTitle>{t('export.title')}</DialogTitle>
<DialogContent>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={currentTab} onChange={(e, newValue) => setCurrentTab(newValue)} aria-label="export tabs">
<Tab label={t('export.localTab')} />
<Tab label={t('export.llamaFactoryTab')} />
<Tab label={t('export.huggingFaceTab')} />
</Tabs>
</Box>
{/* 第一个标签页:本地导出 */}
{currentTab === 0 && (
<LocalExportTab
fileFormat={fileFormat}
formatType={formatType}
systemPrompt={systemPrompt}
reasoningLanguage={reasoningLanguage}
confirmedOnly={confirmedOnly}
includeCOT={includeCOT}
customFields={customFields}
alpacaFieldType={alpacaFieldType}
customInstruction={customInstruction}
handleFileFormatChange={handleFileFormatChange}
handleFormatChange={handleFormatChange}
handleSystemPromptChange={handleSystemPromptChange}
handleReasoningLanguageChange={handleReasoningLanguageChange}
handleConfirmedOnlyChange={handleConfirmedOnlyChange}
handleIncludeCOTChange={handleIncludeCOTChange}
handleCustomFieldChange={handleCustomFieldChange}
handleIncludeLabelsChange={handleIncludeLabelsChange}
handleIncludeChunkChange={handleIncludeChunkChange}
handleQuestionOnlyChange={handleQuestionOnlyChange}
handleAlpacaFieldTypeChange={handleAlpacaFieldTypeChange}
handleCustomInstructionChange={handleCustomInstructionChange}
handleExport={handleExport}
projectId={projectId}
/>
)}
{/* 第二个标签页Llama Factory */}
{currentTab === 1 && (
<LlamaFactoryTab
projectId={projectId}
systemPrompt={systemPrompt}
reasoningLanguage={reasoningLanguage}
confirmedOnly={confirmedOnly}
includeCOT={includeCOT}
formatType={formatType}
handleSystemPromptChange={handleSystemPromptChange}
handleReasoningLanguageChange={handleReasoningLanguageChange}
handleConfirmedOnlyChange={handleConfirmedOnlyChange}
handleIncludeCOTChange={handleIncludeCOTChange}
/>
)}
{/* 第三个标签页HuggingFace */}
{currentTab === 2 && (
<HuggingFaceTab
projectId={projectId}
systemPrompt={systemPrompt}
reasoningLanguage={reasoningLanguage}
confirmedOnly={confirmedOnly}
includeCOT={includeCOT}
formatType={formatType}
fileFormat={fileFormat}
customFields={customFields}
handleSystemPromptChange={handleSystemPromptChange}
handleReasoningLanguageChange={handleReasoningLanguageChange}
handleConfirmedOnlyChange={handleConfirmedOnlyChange}
handleIncludeCOTChange={handleIncludeCOTChange}
/>
)}
</DialogContent>
</Dialog>
);
};
export default ExportDatasetDialog;

View File

@@ -0,0 +1,104 @@
'use client';
import React from 'react';
import { Dialog, DialogTitle, DialogContent, Box, LinearProgress, Typography, CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next';
const ExportProgressDialog = ({ open, progress }) => {
const { t } = useTranslation();
const { processed, total, hasMore } = progress;
// 计算进度百分比
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
return (
<Dialog
open={open}
disableEscapeKeyDown
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 2,
minHeight: 200
}
}}
>
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>{t('datasets.exportProgress')}</DialogTitle>
<DialogContent>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 3,
py: 2
}}
>
{/* 圆形进度指示器 */}
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<CircularProgress
variant="determinate"
value={percentage}
size={80}
thickness={4}
sx={{
color: 'primary.main'
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="h6" component="div" color="text.secondary" sx={{ fontWeight: 'bold' }}>
{`${percentage}%`}
</Typography>
</Box>
</Box>
{/* 进度详情 */}
<Box sx={{ textAlign: 'center', width: '100%' }}>
<Typography variant="body1" sx={{ mb: 1 }}>
{t('datasets.exportingData')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('datasets.processedCount', { processed, total })}
</Typography>
{/* 线性进度条 */}
<LinearProgress
variant="determinate"
value={percentage}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: 'grey.200',
'& .MuiLinearProgress-bar': {
borderRadius: 4
}
}}
/>
</Box>
{/* 状态提示 */}
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
{hasMore ? t('datasets.exportInProgress') : t('datasets.exportFinalizing')}
</Typography>
</Box>
</DialogContent>
</Dialog>
);
};
export default ExportProgressDialog;

View File

@@ -0,0 +1,16 @@
'use client';
import { useEffect } from 'react';
import i18n from '@/lib/i18n';
import { I18nextProvider } from 'react-i18next';
export default function I18nProvider({ children }) {
useEffect(() => {
// 确保i18n只在客户端初始化
if (typeof window !== 'undefined') {
// 这里可以添加任何客户端特定的i18n初始化逻辑
}
}, []);
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useTranslation } from 'react-i18next';
import { IconButton, Menu, MenuItem, Tooltip, useTheme, Typography } from '@mui/material';
import { useState } from 'react';
import TranslateIcon from '@mui/icons-material/Translate';
export default function LanguageSwitcher() {
const { i18n, t } = useTranslation();
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const languages = [
{ code: 'en', label: t('language.english', 'English'), short: 'EN' },
{ code: 'zh-CN', label: t('language.chineseSimplified', '简体中文'), short: '中文' },
{ code: 'tr', label: t('language.turkish', 'Türkçe'), short: 'TR' },
{ code: 'pt-BR', label: t('language.portugues', 'Portugues'), short: 'pt-BR' }
];
const normalizedCurrentLanguage =
i18n.language && String(i18n.language).toLowerCase().startsWith('zh') ? 'zh-CN' : i18n.language;
const currentLanguage = languages.find(lang => lang.code === normalizedCurrentLanguage) || languages[0];
const handleClick = event => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleLanguageChange = langCode => {
i18n.changeLanguage(langCode);
handleClose();
};
return (
<>
<Tooltip title={t('language.switcherTitle', 'Change Language / 切换语言 / Dil Değiştir')}>
<IconButton
onClick={handleClick}
size="small"
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.15)',
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
p: 1,
borderRadius: 1.5,
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.25)'
}
}}
>
<Typography variant="body2" fontWeight="medium" sx={{ mr: 0.5 }}>
{currentLanguage.short}
</Typography>
<TranslateIcon fontSize="small" />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
>
{languages.map(lang => (
<MenuItem
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
selected={normalizedCurrentLanguage === lang.code}
>
<Typography variant="body2" sx={{ mr: 1, minWidth: 28 }}>
{lang.short}
</Typography>
{lang.label}
</MenuItem>
))}
</Menu>
</>
);
}

View File

@@ -0,0 +1,346 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import { FormControl, Select, MenuItem, useTheme, ListSubheader, Box, IconButton, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue } from 'jotai/index';
import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store';
import axios from 'axios';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import { getModelIcon } from '@/lib/util/modelIcon';
export default function ModelSelect({
size = 'small',
minWidth = 50,
projectId,
minHeight = 36,
required = false,
onError
}) {
const theme = useTheme();
const { t } = useTranslation();
const models = useAtomValue(modelConfigListAtom);
const [selectedModelInfo, setSelectedModelInfo] = useAtom(selectedModelInfoAtom);
const [selectedModel, setSelectedModel] = useState(() => {
if (selectedModelInfo && selectedModelInfo.id) {
return selectedModelInfo.id;
}
return '';
});
const [error, setError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const handleModelChange = event => {
if (!event || !event.target) return;
const newModelId = event.target.value;
if (error) {
setError(false);
if (onError) onError(false);
}
if (!newModelId) {
setSelectedModel('');
setSelectedModelInfo(null);
updateDefaultModel(null);
} else {
const selectedModelObj = models.find(model => model.id === newModelId);
if (selectedModelObj) {
setSelectedModel(newModelId);
setSelectedModelInfo(selectedModelObj);
updateDefaultModel(newModelId);
} else {
setSelectedModel(newModelId);
setSelectedModelInfo({ id: newModelId });
}
}
setTimeout(() => {
setIsHovered(false);
setIsOpen(false);
}, 200);
};
const updateDefaultModel = async id => {
const res = await axios.put(`/api/projects/${projectId}`, { projectId, defaultModelConfigId: id });
if (res.status === 200) {
console.log('更新成功');
}
};
const validateModel = () => {
if (required && (!selectedModel || selectedModel === '')) {
setError(true);
if (onError) onError(true);
return false;
}
return true;
};
useEffect(() => {
if (selectedModelInfo && selectedModelInfo.id) {
setSelectedModel(selectedModelInfo.id);
} else {
setSelectedModel('');
}
}, [selectedModelInfo]);
useEffect(() => {
if (required) {
validateModel();
}
}, [required]);
const renderSelectedValue = value => {
if (!value) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SmartToyIcon fontSize="small" />
{t('models.unselectedModel', t('playground.selectModelFirst'))}
</Box>
);
}
const selectedModelObj = models.find(model => model.id === value);
if (!selectedModelObj) return null;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
component="img"
src={getModelIcon(selectedModelObj.modelName || selectedModelObj.modelId)}
alt={selectedModelObj.modelName}
sx={{
width: 20,
height: 20,
objectFit: 'contain',
flexShrink: 0,
background: '#ffffffc9',
borderRadius: '50%',
marginBottom: '-2px'
}}
onError={e => {
e.target.src = '/imgs/models/default.svg';
}}
/>
{selectedModelObj.modelName}
</Box>
);
};
const currentModelIcon = useMemo(() => {
const selectedModelObj = models.find(model => model.id === selectedModel);
return selectedModelObj ? getModelIcon(selectedModelObj.modelName, selectedModelObj.modelId) : null;
}, [selectedModel, models]);
const shouldShowFullSelect = isHovered || isOpen;
return (
<Box
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => {
setIsHovered(false);
if (!isOpen) {
setIsOpen(false);
}
}}
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center'
}}
>
{!shouldShowFullSelect && (
<Tooltip
title={
selectedModel
? models.find(m => m.id === selectedModel)?.modelName
: t('playground.selectModelFirst', '请先选择模型')
}
placement="bottom"
>
<IconButton
size="medium"
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.69)',
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
borderRadius: 1.5,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.35)'
},
...(error && {
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes pulse': {
'0%, 100%': {
opacity: 1
},
'50%': {
opacity: 0.5
}
}
})
}}
>
{currentModelIcon ? (
<Box
component="img"
src={currentModelIcon}
alt="model icon"
sx={{
width: 20,
height: 20,
objectFit: 'contain'
}}
onError={e => {
e.target.src = '/imgs/models/default.svg';
}}
/>
) : (
<SmartToyIcon
fontSize="small"
color="red"
sx={{
color: error ? 'red' : 'red'
}}
/>
)}
</IconButton>
</Tooltip>
)}
<FormControl
size={size}
sx={{
minWidth: shouldShowFullSelect ? 200 : 0,
minHeight,
opacity: shouldShowFullSelect ? 1 : 0,
width: shouldShowFullSelect ? 'auto' : 0,
overflow: 'hidden',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: shouldShowFullSelect ? 'relative' : 'absolute',
pointerEvents: shouldShowFullSelect ? 'auto' : 'none'
}}
error={error}
>
<Select
value={selectedModel}
onChange={handleModelChange}
displayEmpty
variant="outlined"
onBlur={validateModel}
renderValue={renderSelectedValue}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.2)',
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
borderRadius: 1.5,
'& .MuiSelect-select': {
display: 'flex',
alignItems: 'center',
padding: '6px 32px 6px 12px'
},
'& .MuiSelect-icon': {
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
right: '8px'
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent'
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent'
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'dark' ? 'primary.main' : 'rgba(255, 255, 255, 0.5)'
},
minHeight: '36px'
}}
MenuProps={{
PaperProps: {
elevation: 2,
sx: {
mt: 1,
borderRadius: 2,
'& .MuiMenuItem-root': {
minHeight: '30px'
}
}
}
}}
>
<MenuItem value="">
{error ? t('models.pleaseSelectModel') : t('models.unselectedModel', t('playground.selectModelFirst'))}
</MenuItem>
{(() => {
const filteredModels = models.filter(m => {
if (m.providerId?.toLowerCase() === 'ollama') {
return m.modelName && m.endpoint;
} else {
return m.modelName && m.endpoint && m.apiKey;
}
});
const providers = [...new Set(filteredModels.map(m => m.providerName || 'Other'))];
return providers.map(provider => {
const providerModels = filteredModels.filter(m => (m.providerName || 'Other') === provider);
return [
<ListSubheader
key={`header-${provider}`}
sx={{
pl: 2,
color: theme.palette.text.secondary,
fontWeight: 500,
mt: 1,
mb: 0.5
}}
>
{provider || 'Other'}
</ListSubheader>,
...providerModels.map(model => (
<MenuItem
key={model.id}
value={model.id}
sx={{
pl: 3,
display: 'flex',
alignItems: 'center',
gap: 2,
minHeight: '30px',
'&.Mui-selected': {
bgcolor: theme.palette.action.selected,
'&:hover': {
bgcolor: theme.palette.action.selected
}
}
}}
>
<Box
component="img"
src={getModelIcon(model.modelName || model.modelId)}
alt={model.modelName}
sx={{
width: 20,
height: 20,
objectFit: 'contain',
flexShrink: 0
}}
onError={e => {
e.target.src = '/imgs/models/default.svg';
}}
/>
<Box component="span" sx={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{model.modelName}
</Box>
</MenuItem>
))
];
});
})()}
</Select>
</FormControl>
</Box>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import React from 'react';
import { Box, IconButton, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import GitHubIcon from '@mui/icons-material/GitHub';
import BarChartIcon from '@mui/icons-material/BarChart';
import LanguageSwitcher from '../LanguageSwitcher';
import UpdateChecker from '../UpdateChecker';
import TaskIcon from '../TaskIcon';
import ModelSelect from '../ModelSelect';
import * as styles from './styles';
/**
* ActionButtons 组件
* 右侧操作区按钮语言切换、主题切换、文档、GitHub、更新检查
*/
export default function ActionButtons({
theme,
resolvedTheme,
toggleTheme,
isProjectDetail,
currentProject,
onActionAreaEnter
}) {
const { t, i18n } = useTranslation();
const isZhLanguage = String(i18n.language || '')
.toLowerCase()
.startsWith('zh');
return (
<Box sx={styles.actionAreaStyles} onMouseEnter={onActionAreaEnter}>
{isProjectDetail && <ModelSelect projectId={currentProject} />}
{isProjectDetail && <TaskIcon theme={theme} projectId={currentProject} />}
{/* Monitoring Dashboard - Only visible on Home page */}
{!isProjectDetail && (
<Tooltip title={t('monitoring.title', 'Resource Monitoring')}>
<IconButton component="a" href="/monitoring" size="medium" sx={styles.getIconButtonStyles(theme)}>
<BarChartIcon />
</IconButton>
</Tooltip>
)}
{/* Language Switcher - Always visible */}
<LanguageSwitcher />
{/* Theme Toggle - Always visible */}
<Tooltip
title={
resolvedTheme === 'dark'
? t('theme.switchToLight', 'Switch to light mode')
: t('theme.switchToDark', 'Switch to dark mode')
}
>
<IconButton
onClick={toggleTheme}
size="medium"
aria-label={
resolvedTheme === 'dark'
? t('theme.switchToLight', 'Switch to light mode')
: t('theme.switchToDark', 'Switch to dark mode')
}
sx={styles.getIconButtonStyles(theme)}
>
{resolvedTheme === 'dark' ? (
<LightModeOutlinedIcon fontSize="small" />
) : (
<DarkModeOutlinedIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
{/* Documentation - Hide below xl */}
<Tooltip title={t('documentation')}>
<IconButton
component="a"
href={isZhLanguage ? 'https://docs.easy-dataset.com/' : 'https://docs.easy-dataset.com/ed/en'}
target="_blank"
rel="noopener noreferrer"
size="medium"
sx={styles.getIconButtonStyles(theme)}
>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* GitHub - Hide at larger tablet screens and below */}
<Tooltip title={t('common.visitGitHub', 'View on GitHub')}>
<IconButton
component="a"
href="https://github.com/ConardLi/easy-dataset"
target="_blank"
rel="noopener noreferrer"
size="medium"
aria-label={t('common.visitGitHub', 'Open GitHub repository')}
sx={styles.getIconButtonStyles(theme)}
>
<GitHubIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* Update Checker - Hide below xl */}
<Box sx={{ display: { xs: 'none', xl: 'flex' } }}>
<UpdateChecker />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,175 @@
'use client';
import React, { useState } from 'react';
import {
Box,
Chip,
Typography,
useTheme,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Paper,
Tooltip
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
import { useSetAtom } from 'jotai';
import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store';
import { toast } from 'sonner';
import axios from 'axios';
// Icons
import FolderIcon from '@mui/icons-material/Folder';
import CheckIcon from '@mui/icons-material/Check';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
// 样式
import * as styles from './contextBarStyles';
export default function ContextBar({ projects = [], currentProjectId, onMouseLeave }) {
const { t } = useTranslation();
const theme = useTheme();
const router = useRouter();
// State
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null);
// Jotai atoms
const setConfigList = useSetAtom(modelConfigListAtom);
const setSelectedModelInfo = useSetAtom(selectedModelInfoAtom);
// Get current project
const currentProject = projects.find(p => p.id === currentProjectId);
// Handlers
const handleProjectMenuOpen = event => {
event.preventDefault();
setProjectMenuAnchor(event.currentTarget);
};
const handleProjectMenuClose = () => {
setProjectMenuAnchor(null);
// 菜单关闭时,如果提供了 onMouseLeave 回调,则调用它
if (onMouseLeave) {
onMouseLeave();
}
};
const handleProjectChange = async newProjectId => {
handleProjectMenuClose();
try {
// Fetch model config for new project
const response = await axios.get(`/api/projects/${newProjectId}/model-config`);
setConfigList(response.data.data);
if (response.data.defaultModelConfigId) {
const defaultModel = response.data.data.find(item => item.id === response.data.defaultModelConfigId);
setSelectedModelInfo(defaultModel || null);
} else {
setSelectedModelInfo(null);
}
// Navigate to the new project's text-split page
router.push(`/projects/${newProjectId}/text-split`);
} catch (error) {
console.error('Error switching project:', error);
toast.error(t('common.error', 'Error switching project'));
}
};
if (!currentProjectId || !currentProject) {
return null;
}
return (
<Paper
elevation={0}
component="nav"
aria-label={t('common.contextNavigation', 'Context navigation')}
sx={styles.getContextBarPaperStyles(theme)}
>
<Box sx={styles.contextBarContainerStyles}>
{/* Project Selector */}
<Box sx={styles.selectorContainerStyles}>
<Typography variant="caption" sx={styles.labelTypographyStyles}>
{t('common.project', 'Project')}:
</Typography>
<Tooltip
title={currentProject?.name || t('projects.selectProject', 'Select Project')}
placement="bottom-start"
arrow
>
<Chip
icon={<FolderIcon fontSize="small" />}
label={
<Box sx={styles.chipLabelBoxStyles}>
<Typography variant="body2" noWrap sx={styles.chipTextStyles}>
{currentProject?.name || t('projects.selectProject', 'Select Project')}
</Typography>
<ArrowDropDownIcon fontSize="small" sx={styles.chipArrowStyles} />
</Box>
}
onClick={handleProjectMenuOpen}
clickable
variant="outlined"
size="medium"
sx={styles.getProjectChipStyles(theme)}
aria-label={t('projects.selectProject', 'Select project')}
aria-controls={projectMenuAnchor ? 'project-menu' : undefined}
aria-haspopup="true"
aria-expanded={Boolean(projectMenuAnchor)}
/>
</Tooltip>
</Box>
</Box>
{/* Project Menu */}
<Menu
id="project-menu"
anchorEl={projectMenuAnchor}
open={Boolean(projectMenuAnchor)}
onClose={handleProjectMenuClose}
role="menu"
aria-label={t('projects.projectMenu', 'Project menu')}
PaperProps={{
elevation: 8,
sx: styles.getMenuPaperStyles(theme)
}}
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
MenuListProps={{
'aria-labelledby': 'project-selector',
...styles.menuListPropsStyles
}}
>
<Typography variant="caption" sx={styles.menuHeaderTypographyStyles}>
{t('projects.allProjects', 'All Projects')}
</Typography>
{projects.map((project, index) => (
<MenuItem
key={project.id}
onClick={() => handleProjectChange(project.id)}
selected={project.id === currentProjectId}
role="menuitem"
sx={styles.getMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.menuItemIconStyles}>
{project.id === currentProjectId ? (
<CheckIcon fontSize="small" color="primary" />
) : (
<FolderIcon fontSize="small" />
)}
</ListItemIcon>
<ListItemText
primary={project.name}
primaryTypographyProps={styles.getMenuItemTextPrimaryProps(project.id === currentProjectId)}
/>
</MenuItem>
))}
</Menu>
</Paper>
);
}

View File

@@ -0,0 +1,315 @@
'use client';
import React from 'react';
import { Menu, MenuItem, ListItemIcon, ListItemText, Divider } from '@mui/material';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import ImageIcon from '@mui/icons-material/Image';
import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined';
import ChatIcon from '@mui/icons-material/Chat';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined';
import StorageIcon from '@mui/icons-material/Storage';
import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined';
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import VisibilityIcon from '@mui/icons-material/Visibility';
import * as styles from './styles';
/**
* DesktopMenus 缂備礁瀚▎?
* 婵℃鐭傚鎵博椤栨稑浜鹃柛瀣矎瑜板秹宕¢弴顏嗙闁告牕鎳庨幆鍫ュ极閻楀牆绁︽繝褎鍔戦埀顑跨劍閺嗙喖骞戦鈧▔锔剧不閿涘嫭鍊為柕鍡曠劍濞叉寧寰勫顐ょ憦濞戞搩浜hぐ宥夊础?
*/
export default function DesktopMenus({
theme,
menuState,
isMenuOpen,
handleMenuClose,
currentProject,
onNavigateStart
}) {
const { t } = useTranslation();
return (
<>
{/* 闁轰胶澧楀畵浣糕攦閹邦垰缍呴柛?*/}
<Menu
anchorEl={menuState.anchorEl}
open={isMenuOpen('source')}
onClose={handleMenuClose}
hideBackdrop
disableScrollLock
sx={{ pointerEvents: 'none' }}
aria-label={t('common.dataSource', 'Data source menu')}
PaperProps={{
elevation: 8,
sx: {
...styles.getMenuPaperStyles(theme),
pointerEvents: 'auto'
},
onMouseLeave: handleMenuClose
}}
transformOrigin={{ horizontal: 'center', vertical: 'top' }}
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
MenuListProps={{
dense: false,
onMouseLeave: handleMenuClose,
sx: styles.menuListStyles,
role: 'menu'
}}
transitionDuration={200}
>
<MenuItem
component={Link}
href={`/projects/${currentProject}/text-split`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
role="menuitem"
sx={styles.getMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<DescriptionOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('textSplit.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</MenuItem>
<Divider sx={{ my: 0.75, mx: 1.5 }} />
<MenuItem
component={Link}
href={`/projects/${currentProject}/images`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
role="menuitem"
sx={styles.getMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<ImageIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('images.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</MenuItem>
</Menu>
{/* 闁轰胶澧楀畵渚€姊块崱娆樺悁闁荤偛妫滆ぐ宥夊础?*/}
<Menu
anchorEl={menuState.anchorEl}
open={isMenuOpen('dataset')}
onClose={handleMenuClose}
hideBackdrop
disableScrollLock
sx={{ pointerEvents: 'none' }}
PaperProps={{
elevation: 8,
sx: {
...styles.getSimpleMenuPaperStyles(theme),
pointerEvents: 'auto'
},
onMouseLeave: handleMenuClose
}}
transformOrigin={{ horizontal: 'center', vertical: 'top' }}
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
MenuListProps={{
dense: true,
onMouseLeave: handleMenuClose,
sx: styles.simpleMenuListStyles
}}
>
<MenuItem
component={Link}
href={`/projects/${currentProject}/datasets`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<DatasetOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText
primary={t('datasets.singleTurn', '单轮问答数据集')}
primaryTypographyProps={styles.smallListItemTextStyles}
/>
</MenuItem>
<Divider sx={{ my: 0.5, mx: 1 }} />
<MenuItem
component={Link}
href={`/projects/${currentProject}/multi-turn`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<ChatIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText
primary={t('datasets.multiTurn', '多轮对话数据集')}
primaryTypographyProps={styles.smallListItemTextStyles}
/>
</MenuItem>
<Divider sx={{ my: 0.5, mx: 1 }} />
<MenuItem
component={Link}
href={`/projects/${currentProject}/image-datasets`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<ImageIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText
primary={t('datasets.imageQA', '图片问答数据集')}
primaryTypographyProps={styles.smallListItemTextStyles}
/>
</MenuItem>
</Menu>
{/* 閻犲洤瀚崣濠囨嚕濠婂啫绀?*/}
<Menu
anchorEl={menuState.anchorEl}
open={isMenuOpen('eval')}
onClose={handleMenuClose}
hideBackdrop
disableScrollLock
sx={{ pointerEvents: 'none' }}
PaperProps={{
elevation: 8,
sx: {
...styles.getSimpleMenuPaperStyles(theme),
pointerEvents: 'auto'
},
onMouseLeave: handleMenuClose
}}
transformOrigin={{ horizontal: 'center', vertical: 'top' }}
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
MenuListProps={{
dense: true,
onMouseLeave: handleMenuClose,
sx: styles.simpleMenuListStyles
}}
>
<MenuItem
component={Link}
href={`/projects/${currentProject}/eval-datasets`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<AssessmentOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('eval.datasets')} primaryTypographyProps={styles.smallListItemTextStyles} />
</MenuItem>
<Divider sx={{ my: 0.5, mx: 1 }} />
<MenuItem
component={Link}
href={`/projects/${currentProject}/eval-tasks`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<PlaylistPlayIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('eval.tasks')} primaryTypographyProps={styles.smallListItemTextStyles} />
</MenuItem>
<Divider sx={{ my: 0.5, mx: 1 }} />
<MenuItem
component={Link}
href={`/projects/${currentProject}/blind-test-tasks`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<VisibilityIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('blindTest.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</MenuItem>
</Menu>
{/* 闁哄洦娼欓ˇ鍧楁嚕濠婂啫绀?*/}
<Menu
anchorEl={menuState.anchorEl}
open={isMenuOpen('more')}
onClose={handleMenuClose}
hideBackdrop
disableScrollLock
sx={{ pointerEvents: 'none' }}
PaperProps={{
elevation: 8,
sx: {
...styles.getSimpleMenuPaperStyles(theme),
pointerEvents: 'auto'
},
onMouseLeave: handleMenuClose
}}
transformOrigin={{ horizontal: 'center', vertical: 'top' }}
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
MenuListProps={{
dense: true,
onMouseLeave: handleMenuClose,
sx: styles.simpleMenuListStyles
}}
>
<MenuItem
component={Link}
href={`/projects/${currentProject}/settings`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<SettingsOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('settings.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</MenuItem>
<Divider sx={{ my: 0.5, mx: 1 }} />
<MenuItem
component={Link}
href={`/projects/${currentProject}/playground`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<ScienceOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('playground.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</MenuItem>
<Divider sx={{ my: 0.5, mx: 1 }} />
<MenuItem
component={Link}
href="/dataset-square"
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.getSimpleMenuItemStyles(theme)}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<StorageIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('datasetSquare.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</MenuItem>
</Menu>
</>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import React from 'react';
import { Box, Typography, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import * as styles from './styles';
/**
* Logo 组件
* 显示应用 Logo 和标题,支持点击跳转到首页
*/
export default function Logo({ theme }) {
const { t } = useTranslation();
return (
<Tooltip title={t('common.goHome', 'Go to Home')} placement="bottom">
<Box
component="a"
href="/"
role="link"
aria-label={t('common.goToHomePage', 'Go to home page')}
tabIndex={0}
sx={styles.getLogoLinkStyles(theme)}
onClick={e => {
e.preventDefault();
window.location.href = '/';
}}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
window.location.href = '/';
}
}}
>
<Box component="img" src="/imgs/logo.svg" alt="Easy Dataset Logo" sx={styles.logoImageStyles} />
<Typography variant="h6" sx={styles.getLogoTextStyles(theme)}>
Easy DataSet
</Typography>
</Box>
</Tooltip>
);
}

View File

@@ -0,0 +1,405 @@
'use client';
import React from 'react';
import {
Drawer,
Box,
Typography,
IconButton,
Tooltip,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Collapse
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import CloseIcon from '@mui/icons-material/Close';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import TokenOutlinedIcon from '@mui/icons-material/TokenOutlined';
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ChatIcon from '@mui/icons-material/Chat';
import ImageIcon from '@mui/icons-material/Image';
import StorageIcon from '@mui/icons-material/Storage';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import GitHubIcon from '@mui/icons-material/GitHub';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined';
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import VisibilityIcon from '@mui/icons-material/Visibility';
import UpdateChecker from '../UpdateChecker';
import * as styles from './styles';
/**
* MobileDrawer 组件
* 移动端抽屉菜单,包含所有导航项
*/
export default function MobileDrawer({
theme,
drawerOpen,
toggleDrawer,
expandedMenu,
toggleMobileSubmenu,
currentProject,
onNavigateStart
}) {
const { t, i18n } = useTranslation();
const handleNavigateStart = () => {
onNavigateStart?.();
toggleDrawer();
};
return (
<Drawer
id="mobile-navigation-drawer"
anchor="left"
open={drawerOpen}
onClose={toggleDrawer}
PaperProps={{
role: 'navigation',
'aria-label': t('common.mobileNavigation', 'Mobile navigation menu'),
sx: styles.getDrawerPaperStyles(theme)
}}
ModalProps={{
keepMounted: true // Better mobile performance
}}
transitionDuration={300}
SlideProps={{
easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
}}
>
{/* Drawer Header */}
<Box sx={styles.getDrawerHeaderStyles(theme)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box component="img" src="/imgs/logo.svg" alt="Easy Dataset Logo" sx={{ width: 32, height: 32 }} />
<Typography variant="h6" component="h2" sx={{ fontWeight: 700, fontSize: '1.15rem' }}>
{t('common.navigation', 'Navigation')}
</Typography>
</Box>
<Tooltip title={t('common.closeMenu', 'Close menu')}>
<IconButton
onClick={toggleDrawer}
aria-label={t('common.closeMenu', 'Close menu')}
size="medium"
sx={styles.getDrawerCloseButtonStyles(theme)}
>
<CloseIcon />
</IconButton>
</Tooltip>
</Box>
{/* Drawer Menu List */}
<List sx={styles.drawerListStyles} role="menu">
{/* 数据源菜单 */}
<ListItem disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
onClick={() => toggleMobileSubmenu('source')}
aria-expanded={expandedMenu === 'source'}
aria-controls="source-submenu"
role="menuitem"
sx={styles.getDrawerListItemButtonStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<DescriptionOutlinedIcon sx={styles.getIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('common.dataSource')} primaryTypographyProps={styles.listItemTextStyles} />
{expandedMenu === 'source' ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</ListItemButton>
</ListItem>
<Collapse id="source-submenu" in={expandedMenu === 'source'} timeout="auto" unmountOnExit>
<List component="div" disablePadding role="menu" sx={styles.getDrawerSubmenuContainerStyles(theme)}>
<ListItemButton
role="menuitem"
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/text-split`}
onClick={toggleDrawer}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<DescriptionOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('textSplit.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</ListItemButton>
<ListItemButton
role="menuitem"
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/images`}
onClick={toggleDrawer}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<ImageIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('images.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</ListItemButton>
</List>
</Collapse>
{/* 数据蒸馏 */}
<ListItem disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
component={Link}
href={`/projects/${currentProject}/distill`}
onClick={toggleDrawer}
role="menuitem"
sx={styles.getDrawerListItemButtonStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<TokenOutlinedIcon sx={styles.getIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('distill.title')} primaryTypographyProps={styles.listItemTextStyles} />
</ListItemButton>
</ListItem>
{/* 问题管理 */}
<ListItem disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
component={Link}
href={`/projects/${currentProject}/questions`}
onClick={toggleDrawer}
role="menuitem"
sx={styles.getDrawerListItemButtonStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<QuestionAnswerOutlinedIcon sx={styles.getIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('questions.title')} primaryTypographyProps={styles.listItemTextStyles} />
</ListItemButton>
</ListItem>
{/* 数据集管理 */}
<ListItem disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
onClick={() => toggleMobileSubmenu('dataset')}
role="menuitem"
aria-expanded={expandedMenu === 'dataset'}
aria-controls="dataset-submenu"
sx={styles.getDrawerListItemButtonStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<DatasetOutlinedIcon sx={styles.getIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('datasets.management')} primaryTypographyProps={styles.listItemTextStyles} />
{expandedMenu === 'dataset' ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</ListItemButton>
</ListItem>
<Collapse in={expandedMenu === 'dataset'} timeout="auto" unmountOnExit id="dataset-submenu">
<List component="div" disablePadding sx={styles.getDrawerSubmenuContainerStyles(theme)}>
<ListItemButton
role="menuitem"
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/datasets`}
onClick={toggleDrawer}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<DatasetOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={t('datasets.singleTurn', '单轮问答数据集')}
primaryTypographyProps={styles.smallListItemTextStyles}
/>
</ListItemButton>
<ListItemButton
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/multi-turn`}
onClick={handleNavigateStart}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<ChatIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={t('datasets.multiTurn', '多轮对话数据集')}
primaryTypographyProps={styles.smallListItemTextStyles}
/>
</ListItemButton>
<ListItemButton
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/image-datasets`}
onClick={toggleDrawer}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<ImageIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={t('datasets.imageQA', '图片问答数据集')}
primaryTypographyProps={styles.smallListItemTextStyles}
/>
</ListItemButton>
</List>
</Collapse>
{/* 评估菜单 */}
<ListItem disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
onClick={() => toggleMobileSubmenu('eval')}
role="menuitem"
aria-expanded={expandedMenu === 'eval'}
aria-controls="eval-submenu"
sx={styles.getDrawerListItemButtonStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<AssessmentOutlinedIcon sx={styles.getIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('eval.title')} primaryTypographyProps={styles.listItemTextStyles} />
{expandedMenu === 'eval' ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</ListItemButton>
</ListItem>
<Collapse in={expandedMenu === 'eval'} timeout="auto" unmountOnExit id="eval-submenu">
<List component="div" disablePadding sx={styles.getDrawerSubmenuContainerStyles(theme)}>
<ListItemButton
role="menuitem"
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/eval-datasets`}
onClick={handleNavigateStart}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<AssessmentOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('eval.datasets')} primaryTypographyProps={styles.smallListItemTextStyles} />
</ListItemButton>
<ListItemButton
role="menuitem"
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/eval-tasks`}
onClick={handleNavigateStart}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<PlaylistPlayIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('eval.tasks')} primaryTypographyProps={styles.smallListItemTextStyles} />
</ListItemButton>
<ListItemButton
role="menuitem"
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/blind-test-tasks`}
onClick={toggleDrawer}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<VisibilityIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('blindTest.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</ListItemButton>
</List>
</Collapse>
{/* 更多菜单 */}
<ListItem disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
onClick={() => toggleMobileSubmenu('more')}
role="menuitem"
aria-expanded={expandedMenu === 'more'}
aria-controls="more-submenu"
sx={styles.getDrawerListItemButtonStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<MoreVertIcon sx={styles.getIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText primary={t('common.more')} primaryTypographyProps={styles.listItemTextStyles} />
{expandedMenu === 'more' ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</ListItemButton>
</ListItem>
<Collapse in={expandedMenu === 'more'} timeout="auto" unmountOnExit id="more-submenu">
<List component="div" disablePadding sx={styles.getDrawerSubmenuContainerStyles(theme)}>
<ListItemButton
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/settings`}
onClick={toggleDrawer}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('settings.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</ListItemButton>
<ListItemButton
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href={`/projects/${currentProject}/playground`}
onClick={toggleDrawer}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<ScienceOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('playground.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
</ListItemButton>
<ListItemButton
sx={styles.getDrawerSubmenuItemStyles(theme)}
component={Link}
href="/dataset-square"
onClick={toggleDrawer}
>
<ListItemIcon sx={styles.smallListItemIconStyles}>
<StorageIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={t('datasetSquare.title')}
primaryTypographyProps={styles.smallListItemTextStyles}
/>
</ListItemButton>
</List>
</Collapse>
{/* Utilities Section */}
<Box sx={styles.getDrawerUtilitiesStyles(theme)}>
<ListItem disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
component="a"
href={
i18n.language === 'zh-CN' ? 'https://docs.easy-dataset.com/' : 'https://docs.easy-dataset.com/ed/en'
}
target="_blank"
rel="noopener noreferrer"
onClick={toggleDrawer}
sx={styles.getDrawerListItemButtonStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<HelpOutlineIcon sx={styles.getIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText
primary={t('common.documentation', 'Documentation')}
primaryTypographyProps={styles.listItemTextStyles}
/>
</ListItemButton>
</ListItem>
<ListItem disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
onClick={() => {
window.open('https://github.com/ConardLi/easy-dataset', '_blank');
toggleDrawer();
}}
sx={styles.getDrawerListItemButtonStyles(theme)}
>
<ListItemIcon sx={styles.listItemIconStyles}>
<GitHubIcon sx={styles.getIconColorStyles(theme)} />
</ListItemIcon>
<ListItemText
primary={t('common.viewOnGitHub', 'View on GitHub')}
primaryTypographyProps={styles.listItemTextStyles}
/>
</ListItemButton>
</ListItem>
<ListItem disablePadding sx={{ mb: 1 }}>
<Box sx={{ px: 1, width: '100%' }}>
<UpdateChecker />
</Box>
</ListItem>
</Box>
</List>
</Drawer>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import React from 'react';
import { Box, Tabs, Tab } from '@mui/material';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import TokenOutlinedIcon from '@mui/icons-material/TokenOutlined';
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined';
import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import * as styles from './styles';
/**
* NavigationTabs 组件
* 桌面端导航 Tabs包含数据源、数据蒸馏、问题管理、数据集管理、更多等 Tab
*/
export default function NavigationTabs({
theme,
pathname,
currentProject,
handleMenuOpen,
handleMenuClose,
onNavigateStart
}) {
const { t } = useTranslation();
// 计算当前 Tab 值
const getCurrentTabValue = () => {
if (pathname.includes('/settings') || pathname.includes('/playground') || pathname.includes('/datasets-sq')) {
return 'more';
}
if (pathname.includes('/eval-datasets') || pathname.includes('/eval-tasks')) {
return 'eval';
}
if (pathname.includes('/datasets') || pathname.includes('/multi-turn') || pathname.includes('/image-datasets')) {
return 'datasets';
}
if (pathname.includes('/text-split') || pathname.includes('/images')) {
return 'source';
}
return pathname;
};
return (
<Box sx={styles.navContainerStyles}>
<Tabs
value={getCurrentTabValue()}
textColor="inherit"
indicatorColor="secondary"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={styles.getTabsStyles(theme)}
>
<Tab
icon={<DescriptionOutlinedIcon fontSize="small" />}
iconPosition="start"
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{t('common.dataSource')}
<ArrowDropDownIcon fontSize="small" sx={{ ml: 0.25 }} />
</Box>
}
value="source"
onMouseEnter={e => handleMenuOpen(e, 'source')}
sx={styles.tabIconWrapperStyles}
/>
<Tab
icon={<TokenOutlinedIcon fontSize="small" />}
iconPosition="start"
label={t('distill.title')}
value={`/projects/${currentProject}/distill`}
component={Link}
href={`/projects/${currentProject}/distill`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.tabIconWrapperStyles}
/>
<Tab
icon={<QuestionAnswerOutlinedIcon fontSize="small" />}
iconPosition="start"
label={t('questions.title')}
value={`/projects/${currentProject}/questions`}
component={Link}
href={`/projects/${currentProject}/questions`}
onClick={() => {
onNavigateStart?.();
handleMenuClose();
}}
sx={styles.tabIconWrapperStyles}
/>
<Tab
icon={<DatasetOutlinedIcon fontSize="small" />}
iconPosition="start"
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{t('datasets.management')}
<ArrowDropDownIcon fontSize="small" sx={{ ml: 0.25 }} />
</Box>
}
value="datasets"
onMouseEnter={e => handleMenuOpen(e, 'dataset')}
sx={styles.tabIconWrapperStyles}
/>
<Tab
icon={<AssessmentOutlinedIcon fontSize="small" />}
iconPosition="start"
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{t('eval.title')}
<ArrowDropDownIcon fontSize="small" sx={{ ml: 0.25 }} />
</Box>
}
value="eval"
onMouseEnter={e => handleMenuOpen(e, 'eval')}
sx={styles.tabIconWrapperStyles}
/>
<Tab
icon={<MoreVertIcon fontSize="small" />}
iconPosition="start"
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{t('common.more')}
<ArrowDropDownIcon fontSize="small" sx={{ ml: 0.25 }} />
</Box>
}
onMouseEnter={e => handleMenuOpen(e, 'more')}
value="more"
sx={styles.tabIconWrapperStyles}
/>
</Tabs>
</Box>
);
}

View File

@@ -0,0 +1,247 @@
/**
* ContextBar 组件样式
* 包含项目选择器和模型选择器的所有样式
*/
import { alpha } from '@mui/material';
// ===== 主容器样式 =====
export const getContextBarPaperStyles = theme => ({
position: 'absolute',
top: 64, // Below navbar
left: 0,
zIndex: 1100,
borderBottom: 1,
borderColor: 'divider',
bgcolor:
theme.palette.mode === 'dark'
? alpha(theme.palette.background.paper, 0.9)
: alpha(theme.palette.background.paper, 0.95),
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
px: { xs: 2, sm: 3, md: 4 },
py: { xs: 1.25, sm: 1.5 },
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: theme.palette.mode === 'dark' ? '0 1px 3px rgba(0, 0, 0, 0.2)' : '0 1px 3px rgba(0, 0, 0, 0.08)',
width: 'auto'
});
export const contextBarContainerStyles = {
display: 'flex',
alignItems: 'center',
gap: { xs: 1, sm: 1.5, md: 2 },
flexWrap: 'nowrap',
width: 'auto'
};
// ===== 选择器容器样式 =====
export const selectorContainerStyles = {
display: 'flex',
alignItems: 'center',
gap: 1
};
// ===== 标签样式 =====
export const labelTypographyStyles = {
color: 'text.secondary',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.7rem',
display: { xs: 'none', sm: 'block' }
};
// ===== Chip 内部文本样式 =====
export const chipLabelBoxStyles = {
display: 'flex',
alignItems: 'center',
gap: 0.5
};
export const chipTextStyles = {
fontWeight: 600,
fontSize: { xs: '0.8rem', sm: '0.875rem' },
maxWidth: { xs: '80px', sm: '120px', md: '150px' },
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
};
export const chipArrowStyles = {
ml: -0.25,
flexShrink: 0
};
// ===== 项目选择器 Chip 样式 =====
export const getProjectChipStyles = theme => ({
minWidth: 'auto',
maxWidth: { xs: '120px', sm: '150px', md: '180px' },
height: { xs: 32, sm: 36 },
minWidth: { xs: 120, sm: 150, md: 180 },
maxWidth: { xs: '120px', sm: '150px', md: '180px' },
borderRadius: 1.5,
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.23)' : 'rgba(0, 0, 0, 0.23)',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
borderColor: 'primary.main',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)',
transform: 'translateY(-1px)',
boxShadow:
theme.palette.mode === 'dark' ? '0 4px 12px rgba(144, 202, 249, 0.15)' : '0 4px 12px rgba(25, 118, 210, 0.15)'
},
'&:active': {
transform: 'translateY(0)'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2
},
'& .MuiChip-icon': {
color: 'text.primary',
fontSize: '1.1rem',
ml: 0.5,
flexShrink: 0
},
'& .MuiChip-label': {
px: 1,
overflow: 'hidden'
}
});
// ===== 模型选择器 Chip 样式 =====
export const getModelChipStyles = theme => ({
minWidth: { xs: 140, sm: 160, md: 180 },
maxWidth: { xs: 200, sm: 280, md: 360 },
height: { xs: 36, sm: 40 },
borderRadius: 2,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.15)' : 'rgba(25, 118, 210, 0.08)',
transform: 'translateY(-1px)',
boxShadow:
theme.palette.mode === 'dark' ? '0 4px 12px rgba(144, 202, 249, 0.25)' : '0 4px 12px rgba(25, 118, 210, 0.25)'
},
'&:active': {
transform: 'translateY(0)'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2
},
'& .MuiChip-icon': {
color: 'primary.main',
fontSize: '1.1rem',
ml: 0.5,
flexShrink: 0
},
'& .MuiChip-label': {
px: 1,
overflow: 'hidden'
}
});
// ===== 菜单样式 =====
export const getMenuPaperStyles = theme => ({
mt: 1,
minWidth: 240,
maxWidth: 400,
maxHeight: 400,
borderRadius: 2,
overflow: 'visible',
bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'background.paper',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
boxShadow:
theme.palette.mode === 'dark'
? '0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)'
: '0 12px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)',
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
top: -6,
left: 24,
width: 12,
height: 12,
bgcolor: 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
borderLeft: `1px solid ${theme.palette.divider}`,
borderTop: `1px solid ${theme.palette.divider}`
}
});
export const menuListPropsStyles = {
dense: false,
sx: { py: 1 }
};
// ===== 菜单标题样式 =====
export const menuHeaderTypographyStyles = {
px: 2,
py: 1,
display: 'block',
color: 'text.secondary',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.7rem'
};
// ===== 菜单项样式 =====
export const getMenuItemStyles = theme => ({
mx: 1,
borderRadius: 1.5,
minHeight: 44,
py: 1.25,
px: 1.5,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)',
transform: 'translateX(4px)'
},
'&.Mui-selected': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.16)' : 'rgba(25, 118, 210, 0.08)',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.24)' : 'rgba(25, 118, 210, 0.12)'
}
}
});
export const menuItemIconStyles = {
minWidth: 36
};
export const getMenuItemTextPrimaryProps = isSelected => ({
variant: 'body2',
fontWeight: isSelected ? 600 : 400
});
export const menuItemTextSecondaryProps = {
variant: 'caption',
sx: { fontSize: '0.7rem' }
};
// ===== 模型图标样式 =====
export const modelIconStyles = {
width: 20,
height: 20,
objectFit: 'contain',
flexShrink: 0,
borderRadius: '50%',
mr: 1
};
// ===== 分组标题样式 =====
export const getProviderHeaderStyles = theme => ({
pl: 2,
color: theme.palette.text.secondary,
fontWeight: 600,
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.5px',
mt: 1,
mb: 0.5
});

View File

@@ -0,0 +1,257 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import {
AppBar,
Toolbar,
Box,
IconButton,
useTheme as useMuiTheme,
Tooltip,
useMediaQuery,
LinearProgress
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import MenuIcon from '@mui/icons-material/Menu';
// 样式
import * as styles from './styles';
// 子组件
import Logo from './Logo';
import ActionButtons from './ActionButtons';
import NavigationTabs from './NavigationTabs';
import MobileDrawer from './MobileDrawer';
import DesktopMenus from './DesktopMenus';
import ContextBar from './ContextBar';
export default function Navbar({ projects = [], currentProject }) {
const { t } = useTranslation();
const pathname = usePathname();
const router = useRouter();
const theme = useMuiTheme();
const { resolvedTheme, setTheme } = useTheme();
const isProjectDetail = pathname.includes('/projects/') && pathname.split('/').length > 3;
// 检测移动设备
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
// 移动端抽屉状态
const [drawerOpen, setDrawerOpen] = useState(false);
const [expandedMenu, setExpandedMenu] = useState(null);
// 桌面端菜单状态
const [menuState, setMenuState] = useState({ anchorEl: null, menuType: null });
const [navLoading, setNavLoading] = useState(false);
const navLoadingTimeoutRef = useRef(null);
// ContextBar 悬浮状态
const [contextBarHovered, setContextBarHovered] = useState(false);
const contextTriggerRef = useRef(null);
const contextBarRef = useRef(null);
useEffect(() => {
if (!contextBarHovered) return;
const handleOutsideClick = event => {
if (contextBarRef.current?.contains(event.target)) return;
if (contextTriggerRef.current?.contains(event.target)) return;
const projectMenuEl = document.getElementById('project-menu');
if (projectMenuEl?.contains(event.target)) return;
setContextBarHovered(false);
};
document.addEventListener('pointerdown', handleOutsideClick, true);
return () => {
document.removeEventListener('pointerdown', handleOutsideClick, true);
};
}, [contextBarHovered]);
useEffect(() => {
if (!menuState.menuType) return;
const handleOutsideMenuClick = event => {
if (menuState.anchorEl?.contains(event.target)) return;
if (event.target?.closest?.('.MuiMenu-root')) return;
setMenuState({ anchorEl: null, menuType: null });
};
document.addEventListener('pointerdown', handleOutsideMenuClick, true);
return () => {
document.removeEventListener('pointerdown', handleOutsideMenuClick, true);
};
}, [menuState.anchorEl, menuState.menuType]);
useEffect(() => {
setNavLoading(false);
if (navLoadingTimeoutRef.current) {
clearTimeout(navLoadingTimeoutRef.current);
navLoadingTimeoutRef.current = null;
}
}, [pathname]);
useEffect(() => {
if (!isProjectDetail || !currentProject) return;
const prefetchRoutes = [
`/projects/${currentProject}/multi-turn`,
`/projects/${currentProject}/eval-datasets`,
`/projects/${currentProject}/eval-tasks`
];
prefetchRoutes.forEach(route => router.prefetch(route));
}, [router, currentProject, isProjectDetail]);
useEffect(() => {
return () => {
if (navLoadingTimeoutRef.current) {
clearTimeout(navLoadingTimeoutRef.current);
}
};
}, []);
const handleNavigateStart = () => {
setNavLoading(true);
if (navLoadingTimeoutRef.current) {
clearTimeout(navLoadingTimeoutRef.current);
}
navLoadingTimeoutRef.current = setTimeout(() => {
setNavLoading(false);
navLoadingTimeoutRef.current = null;
}, 12000);
};
const handleMenuOpen = (event, menuType) => {
setMenuState({ anchorEl: event.currentTarget, menuType });
};
const handleMenuClose = () => {
setMenuState({ anchorEl: null, menuType: null });
};
const isMenuOpen = menuType => menuState.menuType === menuType;
const toggleDrawer = () => {
setDrawerOpen(!drawerOpen);
setExpandedMenu(null);
};
const toggleMobileSubmenu = menuType => {
setExpandedMenu(expandedMenu === menuType ? null : menuType);
};
const toggleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return (
<>
<AppBar
component="nav"
position="sticky"
elevation={0}
color={theme.palette.mode === 'dark' ? 'transparent' : 'primary'}
sx={styles.getAppBarStyles(theme)}
style={{ borderRadius: 0, zIndex: 1200 }}
role="navigation"
aria-label={t('common.mainNavigation', 'Main navigation')}
>
<Toolbar sx={styles.toolbarStyles}>
{/* 左侧: 汉堡菜单(移动端) + Logo */}
<Box
ref={contextTriggerRef}
sx={styles.logoContainerStyles}
onMouseEnter={() => isProjectDetail && setContextBarHovered(true)}
>
{/* 汉堡菜单按钮 */}
{isProjectDetail && isMobile && (
<Tooltip title={t('common.menu', 'Menu')} placement="bottom">
<IconButton
onClick={toggleDrawer}
size="medium"
aria-label={t('common.openMenu', 'Open navigation menu')}
aria-expanded={drawerOpen}
aria-controls="mobile-navigation-drawer"
sx={styles.getHamburgerButtonStyles(theme)}
>
<MenuIcon />
</IconButton>
</Tooltip>
)}
{/* Logo 组件 */}
<Logo theme={theme} />
</Box>
{/* 中间导航 - 仅桌面端 */}
{isProjectDetail && !isMobile && (
<NavigationTabs
theme={theme}
pathname={pathname}
currentProject={currentProject}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
onNavigateStart={handleNavigateStart}
/>
)}
{/* 右侧操作区 */}
<ActionButtons
theme={theme}
resolvedTheme={resolvedTheme}
toggleTheme={toggleTheme}
isProjectDetail={isProjectDetail}
currentProject={currentProject}
onActionAreaEnter={!isMobile ? handleMenuClose : undefined}
/>
</Toolbar>
{isProjectDetail && (
<LinearProgress
color="secondary"
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 2,
opacity: navLoading ? 1 : 0,
transition: 'opacity 180ms ease'
}}
/>
)}
</AppBar>
{/* ContextBar - 在 Logo 或 ContextBar 悬浮时展示 */}
{isProjectDetail && contextBarHovered && (
<Box ref={contextBarRef} onMouseLeave={() => setContextBarHovered(false)}>
<ContextBar
projects={projects}
currentProjectId={currentProject}
onMouseLeave={() => setContextBarHovered(false)}
/>
</Box>
)}
{/* 移动端抽屉组件 */}
<MobileDrawer
theme={theme}
drawerOpen={drawerOpen}
toggleDrawer={toggleDrawer}
expandedMenu={expandedMenu}
toggleMobileSubmenu={toggleMobileSubmenu}
currentProject={currentProject}
onNavigateStart={handleNavigateStart}
/>
{/* 桌面端菜单组件 */}
<DesktopMenus
theme={theme}
menuState={menuState}
isMenuOpen={isMenuOpen}
handleMenuClose={handleMenuClose}
currentProject={currentProject}
onNavigateStart={handleNavigateStart}
/>
</>
);
}

View File

@@ -0,0 +1,374 @@
/**
* Navbar 组件样式配置
*/
// AppBar 样式
export const getAppBarStyles = theme => ({
borderBottom: `1px solid ${theme.palette.divider}`,
bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'primary.main',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: theme.palette.mode === 'dark' ? '0 1px 3px rgba(0, 0, 0, 0.3)' : '0 1px 3px rgba(0, 0, 0, 0.1)'
});
// Toolbar 样式
export const toolbarStyles = {
height: '64px',
minHeight: '64px !important',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: { xs: 2, sm: 2, md: 3 },
gap: 2
};
// Logo 容器样式
export const logoContainerStyles = {
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexShrink: 0
};
// 汉堡菜单按钮样式
export const getHamburgerButtonStyles = theme => ({
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
minWidth: 44,
minHeight: 44,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'scale(1.1)',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.15)'
},
'&:active': {
transform: 'scale(0.95)'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`,
outlineOffset: 2
}
});
// Logo 链接样式
export const getLogoLinkStyles = theme => ({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
textDecoration: 'none',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
borderRadius: 1.5,
px: 0.5,
'&:hover': {
opacity: 0.85,
transform: 'translateY(-1px)'
},
'&:active': {
transform: 'translateY(0)'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`,
outlineOffset: 2
}
});
// Logo 图片样式
export const logoImageStyles = {
width: 32,
height: 32,
mr: 1.5,
transition: 'transform 0.2s ease'
};
// Logo 文字样式
export const getLogoTextStyles = theme => ({
fontWeight: 700,
letterSpacing: '-0.5px',
fontSize: '1.125rem',
display: { xs: 'none', md: 'block' },
color: 'white',
...(theme.palette.mode === 'dark' && {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
})
});
// 中间导航容器样式
export const navContainerStyles = {
flexGrow: 1,
display: 'flex',
justifyContent: 'center',
mx: { lg: 1, xl: 3 },
overflow: 'hidden'
};
// Tabs 样式
export const getTabsStyles = theme => ({
minHeight: '64px',
'& .MuiTab-root': {
minWidth: 100,
maxWidth: 180,
fontSize: '0.875rem',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
color: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(255, 255, 255, 1)',
px: 2,
minHeight: '64px',
textTransform: 'none',
letterSpacing: '0.3px',
'&:hover': {
color: 'white',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.15)'
}
},
'& .Mui-selected': {
color: 'white !important',
fontWeight: 600,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.2)'
},
'& .MuiTabs-indicator': {
height: 3,
borderRadius: '3px 3px 0 0',
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white',
boxShadow: theme.palette.mode === 'dark' ? '0 0 8px rgba(103, 126, 234, 0.5)' : '0 0 8px rgba(255, 255, 255, 0.5)'
}
});
// Tab 图标包装器样式
export const tabIconWrapperStyles = {
'& .MuiTab-iconWrapper': { mr: 1 }
};
// 右侧操作区容器样式
export const actionAreaStyles = {
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0
};
// 文档/GitHub 按钮样式
export const getIconButtonStyles = theme => ({
display: { xs: 'none', xl: 'flex' },
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.2)',
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
borderRadius: 1.5,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.35)'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`,
outlineOffset: 2
}
});
// Drawer Paper 样式
export const getDrawerPaperStyles = theme => ({
width: { xs: '85vw', sm: 320 },
maxWidth: 380,
bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'background.default',
backgroundImage:
theme.palette.mode === 'dark' ? 'linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05))' : 'none',
boxShadow: theme.palette.mode === 'dark' ? '0 8px 32px rgba(0, 0, 0, 0.6)' : '0 8px 32px rgba(0, 0, 0, 0.15)'
});
// Drawer 头部样式
export const getDrawerHeaderStyles = theme => ({
p: 2.5,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: `1px solid ${theme.palette.divider}`,
minHeight: 64
});
// Drawer 关闭按钮样式
export const getDrawerCloseButtonStyles = theme => ({
minWidth: 44,
minHeight: 44,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'rotate(90deg)',
bgcolor: 'action.hover'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2
}
});
// Drawer 列表样式
export const drawerListStyles = {
pt: 1,
px: 1
};
// Drawer 列表项按钮样式
export const getDrawerListItemButtonStyles = theme => ({
borderRadius: '8px',
minHeight: 48,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.12)' : 'rgba(103, 126, 234, 0.08)'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: -2
}
});
// Drawer 子菜单容器样式
export const getDrawerSubmenuContainerStyles = theme => ({
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)',
borderRadius: '8px',
my: 0.5
});
// Drawer 子菜单项样式
export const getDrawerSubmenuItemStyles = theme => ({
pl: 4,
mx: 1,
borderRadius: '8px',
minHeight: 44,
py: 1.5,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.08)' : 'rgba(103, 126, 234, 0.05)'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: -2
}
});
// Drawer 工具区域样式
export const getDrawerUtilitiesStyles = theme => ({
mt: 'auto',
pt: 2,
borderTop: `1px solid ${theme.palette.divider}`
});
// Menu Paper 样式
export const getMenuPaperStyles = theme => ({
mt: 1.5,
borderRadius: '12px',
minWidth: 220,
overflow: 'visible',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
boxShadow:
theme.palette.mode === 'dark'
? '0 12px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.1)'
: '0 12px 40px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.05)',
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
right: '50%',
width: 12,
height: 12,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)',
transform: 'translateY(-50%) translateX(50%) rotate(45deg)',
zIndex: 0,
boxShadow: theme.palette.mode === 'dark' ? '-2px -2px 4px rgba(0, 0, 0, 0.3)' : '-2px -2px 4px rgba(0, 0, 0, 0.1)'
}
});
// Menu 列表样式
export const menuListStyles = {
py: 1.5
};
// Menu 项样式
export const getMenuItemStyles = theme => ({
mx: 1,
borderRadius: '8px',
py: 1.25,
minHeight: 44,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.15)' : 'rgba(103, 126, 234, 0.1)',
transform: 'translateX(4px)'
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: -2
}
});
// Dataset/More Menu Paper 样式(简化版)
export const getSimpleMenuPaperStyles = theme => ({
mt: 1.5,
borderRadius: '12px',
minWidth: 220,
overflow: 'visible',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)',
backdropFilter: 'blur(20px)',
boxShadow:
theme.palette.mode === 'dark'
? '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)'
: '0 8px 32px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)',
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
right: '50%',
width: 12,
height: 12,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)',
transform: 'translateY(-50%) translateX(50%) rotate(45deg)',
zIndex: 0
}
});
// 简化 Menu 列表样式
export const simpleMenuListStyles = {
py: 1
};
// 简化 Menu 项样式
export const getSimpleMenuItemStyles = theme => ({
mx: 0.75,
borderRadius: '8px',
py: 1,
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.15)' : 'rgba(103, 126, 234, 0.1)',
transform: 'translateX(4px)'
}
});
// ListItemIcon 样式
export const listItemIconStyles = {
minWidth: 40
};
export const smallListItemIconStyles = {
minWidth: 36
};
// ListItemText 样式
export const listItemTextStyles = {
fontWeight: 600,
fontSize: '0.95rem'
};
export const smallListItemTextStyles = {
fontSize: '0.9rem',
fontWeight: 500
};
// 图标颜色样式
export const getIconColorStyles = theme => ({
color: theme.palette.mode === 'dark' ? 'primary.light' : 'primary.main'
});
export const getPrimaryIconColorStyles = theme => ({
color: theme.palette.primary.main
});

View File

@@ -0,0 +1,223 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Badge, IconButton, Tooltip, CircularProgress, Menu, MenuItem, Divider, ListItemIcon } from '@mui/material';
import TaskAltIcon from '@mui/icons-material/TaskAlt';
import ListAltIcon from '@mui/icons-material/ListAlt';
import QuizIcon from '@mui/icons-material/Quiz';
import AssessmentIcon from '@mui/icons-material/Assessment';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import { useTranslation } from 'react-i18next';
import { useRouter, usePathname } from 'next/navigation';
import useFileProcessingStatus from '@/hooks/useFileProcessingStatus';
import { useAtomValue } from 'jotai/index';
import { selectedModelInfoAtom } from '@/lib/store';
import axios from 'axios';
import { toast } from 'sonner';
export default function TaskIcon({ projectId, theme }) {
const { t, i18n } = useTranslation();
const router = useRouter();
const pathname = usePathname();
const [tasks, setTasks] = useState([]);
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const isMenuOpen = Boolean(menuAnchorEl);
const selectedModel = useAtomValue(selectedModelInfoAtom);
const { setTaskFileProcessing, setTask } = useFileProcessingStatus();
const fetchPendingTasks = async () => {
if (!projectId) return;
try {
const response = await axios.get(`/api/projects/${projectId}/tasks/list?status=0`);
if (response.data?.code === 0) {
const pendingTasks = response.data.data || [];
setTasks(pendingTasks);
const hasActiveFileTask = pendingTasks.some(
task => task.projectId === projectId && task.taskType === 'file-processing'
);
setTaskFileProcessing(hasActiveFileTask);
if (hasActiveFileTask) {
const activeTask = pendingTasks.find(
task => task.projectId === projectId && task.taskType === 'file-processing'
);
try {
const detailInfo = JSON.parse(activeTask?.detail || '{}');
setTask(detailInfo);
} catch {
setTask(null);
}
}
}
} catch (error) {
console.error('Failed to fetch task list:', error);
}
};
useEffect(() => {
if (!projectId) return;
fetchPendingTasks();
const intervalId = setInterval(() => {
fetchPendingTasks();
}, 10000);
return () => {
clearInterval(intervalId);
};
}, [projectId]);
useEffect(() => {
setMenuAnchorEl(null);
}, [pathname]);
const handleOpenTaskList = () => {
setMenuAnchorEl(null);
router.push(`/projects/${projectId}/tasks`);
};
const handleMenuOpen = event => {
if (isMenuOpen) {
setMenuAnchorEl(null);
return;
}
setMenuAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
};
const createBatchTask = async (taskType, detail) => {
if (!projectId || !selectedModel?.id) {
toast.error(t('textSplit.selectModelFirst'));
return;
}
try {
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
taskType,
modelInfo: selectedModel,
language: i18n.language,
detail
});
if (response.data?.code === 0) {
toast.success(t('tasks.createSuccess'));
await fetchPendingTasks();
} else {
toast.error(`${t('tasks.createFailed')}: ${response.data?.message || ''}`);
}
} catch (error) {
console.error('Create batch task failed:', error);
toast.error(`${t('tasks.createFailed')}: ${error.message}`);
}
};
const handleCreateAutoQuestionTask = async () => {
await createBatchTask('question-generation', '批量生成问题任务');
handleMenuClose();
};
const handleCreateAutoEvalTask = async () => {
await createBatchTask('eval-generation', '批量生成评估集任务');
handleMenuClose();
};
const handleCreateAutoCleaningTask = async () => {
await createBatchTask('data-cleaning', '批量数据清洗任务');
handleMenuClose();
};
const renderTaskIcon = () => {
const pendingTasks = tasks.filter(task => task.status === 0);
if (pendingTasks.length > 0) {
return (
<Badge badgeContent={pendingTasks.length} color="error">
<CircularProgress size={20} color="inherit" />
</Badge>
);
}
return <TaskAltIcon fontSize="small" />;
};
const getTooltipText = () => {
const pendingTasks = tasks.filter(task => task.status === 0);
if (pendingTasks.length > 0) {
return t('tasks.pending', { count: pendingTasks.length });
}
return t('tasks.completed');
};
if (!projectId) return null;
return (
<>
<Tooltip title={getTooltipText()}>
<IconButton
onClick={handleMenuOpen}
size="small"
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.15)',
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
p: 1,
borderRadius: 1.5,
'&:hover': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.25)'
}
}}
>
{renderTaskIcon()}
</IconButton>
</Tooltip>
<Menu
anchorEl={menuAnchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
hideBackdrop
disableScrollLock
keepMounted
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem onClick={handleOpenTaskList}>
<ListItemIcon>
<ListAltIcon fontSize="small" />
</ListItemIcon>
{t('tasks.title')}
</MenuItem>
<Divider />
<MenuItem onClick={handleCreateAutoQuestionTask} disabled={!selectedModel?.id}>
<ListItemIcon>
<QuizIcon fontSize="small" />
</ListItemIcon>
{t('textSplit.autoGenerateQuestions', { defaultValue: '自动提取问题' })}
</MenuItem>
<MenuItem onClick={handleCreateAutoEvalTask} disabled={!selectedModel?.id}>
<ListItemIcon>
<AssessmentIcon fontSize="small" />
</ListItemIcon>
{t('textSplit.autoEvalGeneration', { defaultValue: '自动生成评估集' })}
</MenuItem>
<MenuItem onClick={handleCreateAutoCleaningTask} disabled={!selectedModel?.id}>
<ListItemIcon>
<CleaningServicesIcon fontSize="small" />
</ListItemIcon>
{t('textSplit.autoDataCleaning', { defaultValue: '自动数据清洗' })}
</MenuItem>
</Menu>
</>
);
}

View File

@@ -0,0 +1,342 @@
'use client';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider as NextThemeProvider, useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
// 导入字体
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
// 创建主题配置
const getTheme = mode => {
// 主色调
const mainBlue = '#2A5CAA';
const darkGray = '#2D2D2D';
// 辅助色 - 数据可视化色谱
const dataVizColors = [
'#6366F1', // 紫蓝色
'#10B981', // 绿色
'#F59E0B', // 琥珀色
'#EC4899', // 粉色
'#8B5CF6', // 紫色
'#3B82F6' // 蓝色
];
// 状态色
const successColor = '#10B981'; // 翡翠绿
const warningColor = '#F59E0B'; // 琥珀色
const errorColor = '#EF4444'; // 珊瑚红
// 渐变色
const gradientPrimary = 'linear-gradient(90deg, #2A5CAA 0%, #8B5CF6 100%)';
// 根据模式调整颜色
return createTheme({
palette: {
mode,
primary: {
main: mainBlue,
dark: '#1E4785',
light: '#4878C6',
contrastText: '#FFFFFF'
},
secondary: {
main: '#8B5CF6',
dark: '#7039F2',
light: '#A78BFA',
contrastText: '#FFFFFF'
},
error: {
main: errorColor,
dark: '#DC2626',
light: '#F87171'
},
warning: {
main: warningColor,
dark: '#D97706',
light: '#FBBF24'
},
success: {
main: successColor,
dark: '#059669',
light: '#34D399'
},
background: {
default: mode === 'dark' ? '#121212' : '#F8F9FA',
paper: mode === 'dark' ? '#1E1E1E' : '#FFFFFF',
subtle: mode === 'dark' ? '#2A2A2A' : '#F3F4F6'
},
text: {
primary: mode === 'dark' ? '#F3F4F6' : darkGray,
secondary: mode === 'dark' ? '#9CA3AF' : '#6B7280',
disabled: mode === 'dark' ? '#4B5563' : '#9CA3AF'
},
divider: mode === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)',
dataViz: dataVizColors,
gradient: {
primary: gradientPrimary
}
},
typography: {
fontFamily:
'"Inter", "HarmonyOS Sans", "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontSize: 14,
fontWeightLight: 300,
fontWeightRegular: 400,
fontWeightMedium: 500,
fontWeightBold: 600,
h1: {
fontSize: '2rem', // 32px
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: '-0.01em'
},
h2: {
fontSize: '1.5rem', // 24px
fontWeight: 600,
lineHeight: 1.3,
letterSpacing: '-0.005em'
},
h3: {
fontSize: '1.25rem', // 20px
fontWeight: 600,
lineHeight: 1.4
},
h4: {
fontSize: '1.125rem', // 18px
fontWeight: 600,
lineHeight: 1.4
},
h5: {
fontSize: '1rem', // 16px
fontWeight: 600,
lineHeight: 1.5
},
h6: {
fontSize: '0.875rem', // 14px
fontWeight: 600,
lineHeight: 1.5
},
body1: {
fontSize: '1rem', // 16px
lineHeight: 1.5
},
body2: {
fontSize: '0.875rem', // 14px
lineHeight: 1.5
},
caption: {
fontSize: '0.75rem', // 12px
lineHeight: 1.5
},
code: {
fontFamily: '"JetBrains Mono", monospace',
fontSize: '0.875rem'
}
},
shape: {
borderRadius: 8
},
spacing: 8, // 基础间距单位为8px
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
scrollbarWidth: 'thin',
scrollbarColor: mode === 'dark' ? '#4B5563 transparent' : '#9CA3AF transparent',
'&::-webkit-scrollbar': {
width: '8px',
height: '8px'
},
'&::-webkit-scrollbar-track': {
background: 'transparent'
},
'&::-webkit-scrollbar-thumb': {
background: mode === 'dark' ? '#4B5563' : '#9CA3AF',
borderRadius: '4px'
}
},
// 确保代码块使用 JetBrains Mono 字体
'code, pre': {
fontFamily: '"JetBrains Mono", monospace'
},
// 自定义渐变文本的通用样式
'.gradient-text': {
background: gradientPrimary,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
textFillColor: 'transparent'
}
}
},
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 500,
borderRadius: '8px',
padding: '6px 16px'
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)'
}
},
containedPrimary: {
background: mainBlue,
'&:hover': {
backgroundColor: '#1E4785'
}
},
containedSecondary: {
background: '#8B5CF6',
'&:hover': {
backgroundColor: '#7039F2'
}
},
outlined: {
borderWidth: '1.5px',
'&:hover': {
borderWidth: '1.5px'
}
}
}
},
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: 'none',
background: mode === 'dark' ? '#1A1A1A' : mainBlue
}
}
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '12px',
boxShadow: mode === 'dark' ? '0px 4px 8px rgba(0, 0, 0, 0.4)' : '0px 4px 8px rgba(0, 0, 0, 0.05)'
}
}
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: '12px'
}
}
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: '6px',
fontWeight: 500
}
}
},
MuiTableHead: {
styleOverrides: {
root: {
'& .MuiTableCell-head': {
fontWeight: 600,
backgroundColor: mode === 'dark' ? '#2A2A2A' : '#F3F4F6'
}
}
}
},
MuiTabs: {
styleOverrides: {
indicator: {
height: '3px',
borderRadius: '3px 3px 0 0'
}
}
},
MuiTab: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 500,
'&.Mui-selected': {
fontWeight: 600
}
}
}
},
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: '8px'
}
}
},
MuiModal: {
defaultProps: {
disableScrollLock: true
}
},
MuiDialog: {
defaultProps: {
disableScrollLock: true
}
},
MuiPopover: {
defaultProps: {
disableScrollLock: true
}
},
MuiMenu: {
defaultProps: {
disableScrollLock: true
}
},
MuiDialogTitle: {
styleOverrides: {
root: {
fontSize: '1.25rem',
fontWeight: 600
}
}
}
}
});
};
export default function ThemeRegistry({ children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<NextThemeProvider attribute="class" defaultTheme="system" enableSystem>
<InnerThemeRegistry>{children}</InnerThemeRegistry>
</NextThemeProvider>
);
}
function InnerThemeRegistry({ children }) {
const { resolvedTheme } = useTheme();
const theme = getTheme(resolvedTheme === 'dark' ? 'dark' : 'light');
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
}

View File

@@ -0,0 +1,235 @@
import React, { useState, useEffect } from 'react';
import { Box, Button, Snackbar, Alert, Typography, Link, CircularProgress, LinearProgress } from '@mui/material';
import UpdateIcon from '@mui/icons-material/Update';
import { useTranslation } from 'react-i18next';
const UpdateChecker = () => {
const { t } = useTranslation();
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState(null);
const [open, setOpen] = useState(false);
const [checking, setChecking] = useState(false);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [updateDownloaded, setUpdateDownloaded] = useState(false);
const [updateError, setUpdateError] = useState(null);
// 检查更新
const checkForUpdates = async () => {
if (!window.electron?.updater) {
console.warn('Update feature is not available, possibly running in browser environment');
return;
}
try {
setChecking(true);
setUpdateError(null);
const result = await window.electron.updater.checkForUpdates();
console.log('Update check result:', result);
// 返回当前版本信息
if (result) {
setUpdateInfo(prev => ({
...prev,
currentVersion: result.currentVersion
}));
}
} catch (error) {
console.error('Failed to check for updates:', error);
// setUpdateError(error.message || 'Failed to check for updates');
} finally {
setChecking(false);
}
};
// 下载更新
const downloadUpdate = async () => {
if (!window.electron?.updater) return;
try {
setDownloading(true);
setUpdateError(null);
await window.electron.updater.downloadUpdate();
} catch (error) {
console.error('下载更新失败:', error);
setUpdateError(error.message || '下载更新失败');
setDownloading(false);
}
};
// 安装更新
const installUpdate = async () => {
if (!window.electron?.updater) return;
try {
await window.electron.updater.installUpdate();
} catch (error) {
console.error('Failed to install update:', error);
// setUpdateError(error.message || 'Failed to install update');
}
};
// 设置更新事件监听
useEffect(() => {
if (!window.electron?.updater) return;
// 有可用更新
const removeUpdateAvailable = window.electron.updater.onUpdateAvailable(info => {
console.log('发现新版本:', info);
setUpdateAvailable(true);
setUpdateInfo(prev => ({
...prev,
...info,
releaseUrl: `https://github.com/ConardLi/easy-dataset/releases`
}));
setOpen(true);
});
// 没有可用更新
const removeUpdateNotAvailable = window.electron.updater.onUpdateNotAvailable(() => {
console.log('没有可用更新');
setUpdateAvailable(false);
});
// 更新错误
const removeUpdateError = window.electron.updater.onUpdateError(error => {
console.error('更新错误:', error);
// setUpdateError(error);
});
// 下载进度
const removeDownloadProgress = window.electron.updater.onDownloadProgress(progress => {
console.log('下载进度:', progress);
setDownloadProgress(progress.percent || 0);
});
// 更新下载完成
const removeUpdateDownloaded = window.electron.updater.onUpdateDownloaded(info => {
console.log('更新下载完成:', info);
setDownloading(false);
setUpdateDownloaded(true);
});
// 组件挂载时检查更新
const timer = setTimeout(() => {
checkForUpdates();
}, 5000);
// 清理函数
return () => {
clearTimeout(timer);
removeUpdateAvailable();
removeUpdateNotAvailable();
removeUpdateError();
removeDownloadProgress();
removeUpdateDownloaded();
};
}, []);
// 定期检查更新(每小时一次)
useEffect(() => {
if (!window.electron?.updater) return;
const interval = setInterval(
() => {
checkForUpdates();
},
60 * 60 * 1000
);
return () => clearInterval(interval);
}, []);
const handleClose = () => {
setOpen(false);
};
// 如果没有更新或者不在 Electron 环境中,不显示任何内容
if (!updateAvailable && !open) return null;
return (
<>
{updateAvailable && (
<Button color="primary" startIcon={<UpdateIcon />} onClick={() => setOpen(true)} sx={{ ml: 1 }}>
{t('update.newVersion')}
</Button>
)}
<Snackbar
open={open}
autoHideDuration={null}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert onClose={handleClose} severity="info" sx={{ width: '100%', maxWidth: 400 }}>
<Box sx={{ p: 1 }}>
<Typography variant="h6">{t('update.newVersionAvailable')}</Typography>
{updateInfo && (
<>
<Typography variant="body2" sx={{ mt: 1 }}>
{t('update.currentVersion')}: {updateInfo.currentVersion}
</Typography>
<Typography variant="body2">
{t('update.latestVersion')}: {updateInfo.version}
</Typography>
</>
)}
{checking && (
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<CircularProgress size={16} sx={{ mr: 1 }} />
<Typography variant="body2">{t('update.checking')}</Typography>
</Box>
)}
{updateError && (
<Typography variant="body2" color="error.main" sx={{ mt: 1 }}>
{updateError}
</Typography>
)}
{downloading && (
<Box sx={{ mt: 2, width: '100%' }}>
<Typography variant="body2" sx={{ mb: 0.5 }}>
{t('update.downloading')}: {Math.round(downloadProgress)}%
</Typography>
<LinearProgress variant="determinate" value={downloadProgress} />
</Box>
)}
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
{/* {!downloading && !updateDownloaded ? (
<Button
variant="contained"
color="primary"
disabled={checking || downloading}
onClick={downloadUpdate}
>
{t('update.downloadNow')}
</Button>
) : updateDownloaded ? (
<Button
variant="contained"
color="primary"
onClick={installUpdate}
>
{t('update.installNow')}
</Button>
) : null} */}
{updateInfo?.releaseUrl && (
<Link href={updateInfo.releaseUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outlined">{t('update.viewRelease')}</Button>
</Link>
)}
</Box>
</Box>
</Alert>
</Snackbar>
</>
);
};
export default UpdateChecker;

View File

@@ -0,0 +1,23 @@
'use client';
import { Snackbar, Alert } from '@mui/material';
export default function MessageAlert({ message, onClose }) {
if (!message) return null;
const severity = message.severity || 'error';
const text = typeof message === 'string' ? message : message.message;
return (
<Snackbar
open={Boolean(message)}
autoHideDuration={2000}
onClose={onClose}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert onClose={onClose} severity={severity} sx={{ width: '100%' }}>
{text}
</Alert>
</Snackbar>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { Box, Typography, Card, CardContent, Chip, TextField } from '@mui/material';
import { useTranslation } from 'react-i18next';
/**
* 多轮对话内容展示和编辑组件
*/
export default function ConversationContent({ messages, editMode, onMessageChange, conversation }) {
const { t } = useTranslation();
// 获取角色显示信息
const getRoleDisplay = role => {
switch (role) {
case 'system':
return { name: t('datasets.system'), color: 'default' };
case 'user':
return { name: conversation?.roleA || t('datasets.user'), color: 'primary' };
case 'assistant':
return { name: conversation?.roleB || t('datasets.assistant'), color: 'secondary' };
default:
return { name: role, color: 'default' };
}
};
return (
<Box>
<Typography variant="h6" gutterBottom>
{t('datasets.conversationContent')}
</Typography>
<Box sx={{ maxHeight: editMode ? 'none' : '70vh', overflowY: 'auto' }}>
{messages.map((message, index) => {
const roleInfo = getRoleDisplay(message.role);
return (
<Box key={index} sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Chip label={roleInfo.name} color={roleInfo.color} size="small" sx={{ fontSize: '0.75rem' }} />
{message.role !== 'system' && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
{t('datasets.round', { round: Math.floor((index + 1) / 2) + 1 })}
</Typography>
)}
</Box>
<Card variant="outlined" sx={{ mb: 1 }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
{editMode ? (
<TextField
fullWidth
multiline
minRows={3}
maxRows={10}
value={message.content}
onChange={e => onMessageChange && onMessageChange(index, e.target.value)}
variant="outlined"
size="small"
sx={{
'& .MuiInputBase-input': {
fontFamily: 'inherit',
fontSize: '0.875rem',
lineHeight: 1.5
}
}}
/>
) : (
<Typography
variant="body2"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'inherit',
lineHeight: 1.6,
margin: 0
}}
>
{message.content}
</Typography>
)}
</CardContent>
</Card>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,87 @@
'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 EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
/**
* 多轮对话详情页面的头部导航组件
*/
export default function ConversationHeader({
projectId,
conversationId,
conversation,
editMode,
saving,
onEdit,
onSave,
onCancel,
onDelete,
onNavigate
}) {
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}/multi-turn`)}>
{t('common.backToList')}
</Button>
<Divider orientation="vertical" flexItem />
<Typography variant="h6">{t('datasets.conversationDetail')}</Typography>
{conversation && (
<Typography variant="body2" color="text.secondary">
{conversation.scenario && (
<>
{conversation.scenario} {conversation.turnCount}/{conversation.maxTurns}
</>
)}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{/* 翻页按钮 */}
<IconButton onClick={() => onNavigate && onNavigate('prev')}>
<NavigateBeforeIcon />
</IconButton>
<IconButton onClick={() => onNavigate && onNavigate('next')}>
<NavigateNextIcon />
</IconButton>
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
{/* 编辑/保存按钮 */}
{editMode ? (
<>
<Button onClick={onCancel}>{t('common.cancel')}</Button>
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={16} /> : <SaveIcon />}
onClick={onSave}
disabled={saving}
>
{saving ? t('datasets.saving') : t('common.save')}
</Button>
</>
) : (
<>
<Button variant="outlined" startIcon={<EditIcon />} onClick={onEdit}>
{t('common.edit')}
</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, Paper } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@mui/material/styles';
/**
* 多轮对话元数据展示组件
*/
export default function ConversationMetadata({ conversation }) {
const { t } = useTranslation();
const theme = useTheme();
if (!conversation) return null;
return (
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 1 }}>
{t('datasets.metadata')}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`${t('datasets.modelUsed')}: ${conversation.model}`} variant="outlined" size="small" />
{conversation.scenario && (
<Chip
label={`${t('datasets.conversationScenario')}: ${conversation.scenario}`}
color="primary"
variant="outlined"
size="small"
/>
)}
<Chip
label={`${t('datasets.conversationRounds')}: ${conversation.turnCount}/${conversation.maxTurns}`}
variant="outlined"
size="small"
/>
{conversation.roleA && (
<Chip
label={`${t('settings.multiTurnRoleA')}: ${conversation.roleA}`}
variant="outlined"
color="info"
size="small"
/>
)}
{conversation.roleB && (
<Chip
label={`${t('settings.multiTurnRoleB')}: ${conversation.roleB}`}
variant="outlined"
color="secondary"
size="small"
/>
)}
<Chip
label={`${t('datasets.createdAt')}: ${new Date(conversation.createAt).toLocaleDateString()}`}
variant="outlined"
size="small"
/>
{conversation.confirmed && (
<Chip
label={t('datasets.confirmed')}
size="small"
sx={{
backgroundColor: alpha(theme.palette.success.main, 0.1),
color: theme.palette.success.dark,
fontWeight: 'medium'
}}
/>
)}
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,201 @@
'use client';
import { useState, useEffect } from 'react';
import { Box, Typography, Divider, Paper, TextField } from '@mui/material';
import { toast } from 'sonner';
import StarRating from '@/components/datasets/StarRating';
import TagSelector from '@/components/datasets/TagSelector';
import NoteInput from '@/components/datasets/NoteInput';
import { useTranslation } from 'react-i18next';
/**
* 多轮对话评分、标签、备注综合组件
*/
export default function ConversationRatingSection({ conversation, projectId, onUpdate }) {
const { t } = useTranslation();
const [availableTags, setAvailableTags] = useState([]);
const [loading, setLoading] = useState(false);
// 解析对话中的标签
const parseConversationTags = tagsString => {
try {
if (typeof tagsString === 'string' && tagsString.trim()) {
return tagsString.split(/\s+/).filter(tag => tag.length > 0);
}
return [];
} catch (e) {
return [];
}
};
// 本地状态管理
const [localScore, setLocalScore] = useState(conversation.score || 0);
const [localTags, setLocalTags] = useState(() => parseConversationTags(conversation.tags));
const [localNote, setLocalNote] = useState(conversation.note || '');
// 获取项目中已使用的标签
useEffect(() => {
const fetchAvailableTags = async () => {
try {
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/tags`);
if (response.ok) {
const data = await response.json();
setAvailableTags(data.tags || []);
}
} catch (error) {
console.error('获取可用标签失败:', error);
}
};
if (projectId) {
fetchAvailableTags();
}
}, [projectId]);
// 同步props中的conversation到本地状态
useEffect(() => {
setLocalScore(conversation.score || 0);
setLocalTags(parseConversationTags(conversation.tags));
setLocalNote(conversation.note || '');
}, [conversation]);
// 更新对话元数据
const updateMetadata = async updates => {
if (loading) return;
// 立即更新本地状态
if (updates.score !== undefined) {
setLocalScore(updates.score);
}
if (updates.tagsArray !== undefined) {
setLocalTags(updates.tagsArray);
}
if (updates.note !== undefined) {
setLocalNote(updates.note);
}
setLoading(true);
try {
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversation.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
score: updates.score,
tags: updates.tags,
note: updates.note
})
});
if (!response.ok) {
throw new Error(t('datasets.saveFailed'));
}
const result = await response.json();
toast.success(t('datasets.saveSuccess'));
// 如果有父组件的更新回调,调用它
if (onUpdate) {
onUpdate(result.data);
}
} catch (error) {
console.error('更新对话元数据失败:', error);
toast.error(error.message || t('datasets.saveFailed'));
// 出错时恢复本地状态
if (updates.score !== undefined) {
setLocalScore(conversation.score || 0);
}
if (updates.tagsArray !== undefined) {
setLocalTags(parseConversationTags(conversation.tags));
}
if (updates.note !== undefined) {
setLocalNote(conversation.note || '');
}
} finally {
setLoading(false);
}
};
// 处理评分变更
const handleScoreChange = newScore => {
updateMetadata({ score: newScore });
};
// 处理标签变更
const handleTagsChange = newTags => {
const tagsString = Array.isArray(newTags) ? newTags.join(' ') : '';
updateMetadata({ tags: tagsString, tagsArray: newTags });
};
// 处理备注变更
const handleNoteChange = newNote => {
updateMetadata({ note: newNote });
};
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 }} />
{/* 确认状态 */}
{/* <Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
{t('datasets.confirmationStatus')}
</Typography>
<Typography variant="body2">
{conversation.confirmed ? t('datasets.confirmed') : t('datasets.unconfirmed')}
</Typography>
</Box> */}
{/* AI评估 */}
{conversation.aiEvaluation && (
<>
<Divider sx={{ my: 2 }} />
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
{t('datasets.aiEvaluation')}
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{conversation.aiEvaluation}
</Typography>
</Box>
</>
)}
</Paper>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import {
Box,
TextField,
InputAdornment,
List,
ListItem,
ListItemButton,
ListItemText,
Paper,
Typography,
ClickAwayListener,
Fade,
Avatar,
useTheme,
alpha
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import LaunchIcon from '@mui/icons-material/Launch';
import TravelExploreIcon from '@mui/icons-material/TravelExplore';
import sites from '@/constant/sites.json';
import { useTranslation } from 'react-i18next';
export function DatasetSearchBar() {
const [searchQuery, setSearchQuery] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [recentSearches, setRecentSearches] = useState([]);
const searchRef = useRef(null);
const suggestionsRef = useRef(null);
const theme = useTheme();
const { t } = useTranslation();
// 从 localStorage 加载最近搜索
useEffect(() => {
const savedSearches = localStorage.getItem('recentDatasetSearches');
if (savedSearches) {
try {
const searches = JSON.parse(savedSearches);
setRecentSearches(searches);
} catch (e) {
console.error('解析最近搜索失败', e);
}
}
}, []);
// 处理搜索输入变化
const handleSearchChange = event => {
setSearchQuery(event.target.value);
if (event.target.value) {
setShowSuggestions(true);
} else {
setShowSuggestions(false);
}
};
// 处理回车搜索
const handleSearchSubmit = event => {
if (event.key === 'Enter' && searchQuery.trim()) {
// 默认使用第一个搜索引擎
if (sites.length > 0) {
handleSuggestionClick(sites[0]);
}
}
};
// 保存最近搜索
const saveRecentSearch = query => {
if (!query.trim()) return;
// 添加到最近搜索并去重
const updatedSearches = [query, ...recentSearches.filter(s => s !== query)].slice(0, 5);
setRecentSearches(updatedSearches);
// 保存到 localStorage
try {
localStorage.setItem('recentDatasetSearches', JSON.stringify(updatedSearches));
} catch (e) {
console.error('保存最近搜索失败', e);
}
};
// 处理点击搜索建议
const handleSuggestionClick = site => {
if (searchQuery.trim()) {
// 根据不同网站处理搜索参数
let searchUrl = site.link;
// 如果链接中不包含问号,则添加搜索参数
if (site.link.includes('huggingface.co')) {
searchUrl = `${site.link}?sort=trending&search=${encodeURIComponent(searchQuery)}`;
} else if (site.link.includes('kaggle.com')) {
searchUrl = `${site.link}?search=${encodeURIComponent(searchQuery)}`;
} else if (site.link.includes('datasetsearch.research.google.com')) {
searchUrl = `${site.link}/search?query=${encodeURIComponent(searchQuery)}&src=0`;
} else if (site.link.includes('paperswithcode.com')) {
searchUrl = `${site.link}?q=${encodeURIComponent(searchQuery)}`;
} else if (site.link.includes('modelscope.cn')) {
searchUrl = `${site.link}?query=${encodeURIComponent(searchQuery)}`;
} else if (site.link.includes('opendatalab.com')) {
searchUrl = `${site.link}?keywords=${encodeURIComponent(searchQuery)}`;
} else if (site.link.includes('tianchi.aliyun.com')) {
searchUrl = `${site.link}?q=${encodeURIComponent(searchQuery)}`;
} else {
// 默认处理方式在URL后添加搜索参数
searchUrl = `${site.link}${site.link.includes('?') ? '&' : '?'}search=${encodeURIComponent(searchQuery)}`;
}
// 保存最近搜索
saveRecentSearch(searchQuery);
window.open(searchUrl, '_blank');
}
setShowSuggestions(false);
};
// 处理点击外部关闭建议
const handleClickAway = event => {
// 确保点击的不是建议框本身
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target)) {
setShowSuggestions(false);
}
};
return (
<ClickAwayListener onClickAway={handleClickAway}>
<Box sx={{ position: 'relative', width: '100%', zIndex: 1300 }} ref={searchRef}>
<TextField
fullWidth
placeholder={t('datasetSquare.searchPlaceholder')}
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleSearchSubmit}
onClick={() => searchQuery && setShowSuggestions(true)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="primary" />
</InputAdornment>
),
sx: {
height: 56,
borderRadius: 3,
backgroundColor:
theme.palette.mode === 'dark'
? alpha(theme.palette.background.default, 0.6)
: alpha(theme.palette.background.default, 0.8),
backdropFilter: 'blur(8px)',
px: 2,
transition: 'all 0.3s ease',
boxShadow: `0 0 0 1px ${alpha(theme.palette.primary.main, 0.15)}`,
'&.MuiOutlinedInput-root': {
'& fieldset': {
borderColor: 'transparent'
},
'&:hover fieldset': {
borderColor: 'transparent'
},
'&.Mui-focused': {
boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.3)}`,
backgroundColor:
theme.palette.mode === 'dark'
? alpha(theme.palette.background.paper, 0.8)
: alpha(theme.palette.common.white, 0.95)
},
'&.Mui-focused fieldset': {
borderColor: 'transparent'
}
}
}
}}
sx={{
mb: 1,
'& .MuiInputBase-input': {
fontSize: '1rem',
fontWeight: 500,
color: theme.palette.text.primary
},
'& .MuiInputBase-input::placeholder': {
color: alpha(theme.palette.text.primary, 0.6),
opacity: 0.7
}
}}
/>
{/* 搜索建议下拉框 - 使用绝对定位确保不被裁剪 */}
{showSuggestions && searchQuery && (
<Box
ref={suggestionsRef}
sx={{
position: 'absolute',
width: '100%',
zIndex: 9999,
top: 'calc(100% + 8px)',
left: 0,
boxShadow: '0 4px 20px rgba(0,0,0,0.1)',
pointerEvents: 'auto' // 确保可以点击
}}
>
<Fade in={showSuggestions}>
<Paper
elevation={6}
sx={{
width: '100%',
maxHeight: 350,
overflow: 'auto',
borderRadius: 2,
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
position: 'relative'
}}
>
<List>
{sites.slice(0, 5).map((site, index) => (
<ListItem key={index} disablePadding>
<ListItemButton
onClick={() => handleSuggestionClick(site)}
sx={{
py: 1.5,
'&:hover': {
bgcolor: alpha(theme.palette.primary.main, 0.05)
}
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar
sx={{
width: 28,
height: 28,
mr: 1.5,
bgcolor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main
}}
>
<TravelExploreIcon fontSize="small" />
</Avatar>
<Typography>
{t('datasetSquare.searchVia')} <strong>{site.name}</strong> Search
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>
"{searchQuery}"
</Typography>
<LaunchIcon fontSize="small" color="action" />
</Box>
</Box>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Paper>
</Fade>
</Box>
)}
</Box>
</ClickAwayListener>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { Card, CardActionArea, CardContent, CardMedia, Typography, Box, Chip, useTheme, alpha } from '@mui/material';
import LaunchIcon from '@mui/icons-material/Launch';
import StorageIcon from '@mui/icons-material/Storage';
import { useTranslation } from 'react-i18next';
export function DatasetSiteCard({ site }) {
const { name, link, description, image, labels } = site;
const theme = useTheme();
// 处理图片路径,如果没有图片则使用默认图片
const imageUrl = image || `/imgs/default-dataset.png`;
const { t } = useTranslation();
// 处理卡片点击
const handleCardClick = () => {
window.open(link, '_blank');
};
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'all 0.3s ease',
borderRadius: 2,
overflow: 'hidden',
boxShadow: theme.palette.mode === 'dark' ? '0 4px 20px rgba(0,0,0,0.3)' : '0 4px 20px rgba(0,0,0,0.1)',
'&:hover': {
transform: 'translateY(-6px)',
boxShadow: theme.palette.mode === 'dark' ? '0 8px 30px rgba(0,0,0,0.4)' : '0 8px 30px rgba(0,0,0,0.15)',
'& .MuiCardMedia-root': {
transform: 'scale(1.05)'
}
}
}}
>
<CardActionArea
onClick={handleCardClick}
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
height: '100%',
'&:hover': {
'& .card-content': {
background:
theme.palette.mode === 'dark'
? alpha(theme.palette.primary.dark, 0.1)
: alpha(theme.palette.primary.light, 0.1)
}
}
}}
>
{/* 网站截图 */}
<Box sx={{ position: 'relative', width: '100%', height: 160, overflow: 'hidden' }}>
<CardMedia
component="img"
height="160"
image={imageUrl}
alt={name}
sx={{
objectFit: 'cover',
bgcolor: theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100',
transition: 'transform 0.5s ease'
}}
/>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: `linear-gradient(to bottom, transparent 70%, ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.5)'})`,
zIndex: 1
}}
/>
<Chip
icon={<StorageIcon fontSize="small" />}
label={t('datasetSquare.dataset')}
size="small"
sx={{
position: 'absolute',
top: 10,
right: 10,
zIndex: 2,
backgroundColor: alpha(theme.palette.background.paper, 0.8),
backdropFilter: 'blur(4px)',
border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
'& .MuiChip-icon': {
color: theme.palette.primary.main
}
}}
/>
</Box>
{/* 网站信息 */}
<CardContent
className="card-content"
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
transition: 'background 0.3s ease',
p: 2.5
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Typography
variant="h6"
component="div"
sx={{
fontWeight: 600,
lineHeight: 1.3,
mb: 0.5,
pr: 2, // 留出空间给图标
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.dark
}}
>
{name}
</Typography>
<LaunchIcon
fontSize="small"
sx={{
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main,
opacity: 0.8,
mt: 0.5
}}
/>
</Box>
<Typography
variant="body2"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
color: theme.palette.text.secondary,
lineHeight: 1.6,
mb: 1
}}
>
{description}
</Typography>
<Box sx={{ mt: 'auto', pt: 1.5 }}>
{/* 标签显示 */}
{labels && labels.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1.5 }}>
{labels.map((label, index) => (
<Chip
key={index}
label={label}
size="small"
sx={{
borderRadius: 1,
height: 20,
fontSize: '0.65rem',
backgroundColor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
'&:hover': {
backgroundColor: alpha(theme.palette.primary.main, 0.2)
}
}}
/>
))}
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Chip
label={t('datasetSquare.viewDataset')}
size="small"
color="primary"
variant="outlined"
sx={{
borderRadius: 1,
height: 24,
fontSize: '0.75rem',
'&:hover': {
backgroundColor: alpha(theme.palette.primary.main, 0.1)
}
}}
/>
</Box>
</Box>
</CardContent>
</CardActionArea>
</Card>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect } from 'react';
import { Grid, Box, Typography, Skeleton, Divider, Tabs, Tab, Fade, Chip, useTheme, alpha, Paper } from '@mui/material';
import StorageIcon from '@mui/icons-material/Storage';
import CategoryIcon from '@mui/icons-material/Category';
import StarIcon from '@mui/icons-material/Star';
import { DatasetSiteCard } from './DatasetSiteCard';
import sites from '@/constant/sites.json';
import { useTranslation } from 'react-i18next';
export function DatasetSiteList() {
const [loading, setLoading] = useState(true);
const theme = useTheme();
const { t } = useTranslation();
// 定义类别
const CATEGORIES = {
ALL: t('datasetSquare.categories.all'),
POPULAR: t('datasetSquare.categories.popular'),
CHINESE: t('datasetSquare.categories.chinese'),
ENGLISH: t('datasetSquare.categories.english'),
RESEARCH: t('datasetSquare.categories.research'),
MULTIMODAL: t('datasetSquare.categories.multimodal')
};
const [activeCategory, setActiveCategory] = useState(CATEGORIES.ALL);
// 模拟加载效果
useEffect(() => {
const timer = setTimeout(() => {
setLoading(false);
}, 800);
return () => clearTimeout(timer);
}, []);
// 处理类别切换
const handleCategoryChange = (event, newValue) => {
setActiveCategory(newValue);
};
// 根据当前选中的类别过滤网站
const getFilteredSites = () => {
if (activeCategory === CATEGORIES.ALL) {
return sites;
} else if (activeCategory === CATEGORIES.POPULAR) {
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.popular')));
} else if (activeCategory === CATEGORIES.CHINESE) {
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.chinese')));
} else if (activeCategory === CATEGORIES.ENGLISH) {
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.english')));
} else if (activeCategory === CATEGORIES.RESEARCH) {
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.research')));
} else if (activeCategory === CATEGORIES.MULTIMODAL) {
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.multimodal')));
}
return sites;
};
const filteredSites = getFilteredSites();
return (
<Box sx={{ mt: 4 }}>
{/* 类别选择器 */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CategoryIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h5" component="h2" fontWeight={600}>
{t('datasetSquare.categoryTitle')}
</Typography>
</Box>
<Paper
elevation={0}
sx={{
borderRadius: 2,
p: 0.5,
bgcolor:
theme.palette.mode === 'dark'
? alpha(theme.palette.primary.dark, 0.1)
: alpha(theme.palette.primary.light, 0.1),
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
overflow: 'auto'
}}
>
<Tabs
value={activeCategory}
onChange={handleCategoryChange}
variant="scrollable"
scrollButtons="auto"
sx={{
minHeight: 48,
'& .MuiTabs-indicator': {
display: 'none'
},
'& .MuiTab-root': {
minHeight: 40,
borderRadius: 2,
mx: 0.5,
transition: 'all 0.2s',
'&.Mui-selected': {
bgcolor:
theme.palette.mode === 'dark' ? alpha(theme.palette.primary.main, 0.2) : theme.palette.primary.main,
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : 'white',
fontWeight: 600
}
}
}}
>
<Tab
value={CATEGORIES.ALL}
label={CATEGORIES.ALL}
icon={<StorageIcon fontSize="small" />}
iconPosition="start"
/>
<Tab
value={CATEGORIES.POPULAR}
label={CATEGORIES.POPULAR}
icon={<StarIcon fontSize="small" />}
iconPosition="start"
/>
<Tab value={CATEGORIES.CHINESE} label={CATEGORIES.CHINESE} />
<Tab value={CATEGORIES.ENGLISH} label={CATEGORIES.ENGLISH} />
<Tab value={CATEGORIES.RESEARCH} label={CATEGORIES.RESEARCH} />
<Tab value={CATEGORIES.MULTIMODAL} label={CATEGORIES.MULTIMODAL} />
</Tabs>
</Paper>
</Box>
{/* 数据集网站列表 */}
<Box sx={{ position: 'relative', minHeight: 300 }}>
{loading ? (
// 加载骨架屏
<Grid container spacing={3}>
{Array.from(new Array(8)).map((_, index) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
<Box sx={{ width: '100%', height: '100%' }}>
<Skeleton variant="rectangular" height={160} sx={{ borderRadius: 2 }} />
<Box sx={{ pt: 1.5, px: 0.5 }}>
<Skeleton width="80%" height={28} />
<Skeleton width="100%" />
<Skeleton width="100%" />
<Skeleton width="60%" />
</Box>
</Box>
</Grid>
))}
</Grid>
) : (
<Fade in={!loading} timeout={500}>
<Box>
{/* 结果数量提示 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" color="text.secondary">
{t('datasetSquare.foundResources', { count: filteredSites.length })}{' '}
<Chip
label={filteredSites.length}
size="small"
color="primary"
sx={{ mx: 0.5, height: 20, fontSize: '0.75rem' }}
/>
</Typography>
{activeCategory !== CATEGORIES.ALL && (
<Chip
label={t('datasetSquare.currentFilter', { category: activeCategory })}
size="small"
onDelete={() => setActiveCategory(CATEGORIES.ALL)}
sx={{ borderRadius: 1.5 }}
/>
)}
</Box>
{filteredSites.length > 0 ? (
<Grid container spacing={3}>
{filteredSites.map((site, index) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
<DatasetSiteCard site={site} />
</Grid>
))}
</Grid>
) : (
<Box
sx={{
py: 10,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
bgcolor:
theme.palette.mode === 'dark'
? alpha(theme.palette.background.paper, 0.2)
: alpha(theme.palette.grey[100], 0.5),
borderRadius: 2
}}
>
<StorageIcon sx={{ fontSize: 60, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
{t('datasetSquare.noDatasets')}
</Typography>
<Typography variant="body2" color="text.disabled">
{t('datasetSquare.tryOtherCategories')}
</Typography>
</Box>
)}
</Box>
</Fade>
)}
</Box>
</Box>
);
}

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

View File

@@ -0,0 +1,325 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Typography,
Box,
Alert,
Paper,
Divider,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio
} from '@mui/material';
/**
* 全自动蒸馏数据集配置弹框
* @param {Object} props
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调
* @param {Function} props.onStart - 开始蒸馏任务的回调
* @param {Function} props.onStartBackground - 开始后台蒸馏任务的回调
* @param {string} props.projectId - 项目ID
* @param {Object} props.project - 项目信息
* @param {Object} props.stats - 当前统计信息
*/
export default function AutoDistillDialog({
open,
onClose,
onStart,
onStartBackground,
projectId,
project,
stats = {}
}) {
const { t } = useTranslation();
// 表单状态
const [topic, setTopic] = useState('');
const [levels, setLevels] = useState(2);
const [tagsPerLevel, setTagsPerLevel] = useState(10);
const [questionsPerTag, setQuestionsPerTag] = useState(10);
const [datasetType, setDatasetType] = useState('single-turn'); // 'single-turn' | 'multi-turn' | 'both'
// 计算信息
const [estimatedTags, setEstimatedTags] = useState(0); // 所有标签总数(包括根节点和中间节点)
const [leafTags, setLeafTags] = useState(0); // 叶子节点数量(即最后一层标签数)
const [estimatedQuestions, setEstimatedQuestions] = useState(0);
const [newTags, setNewTags] = useState(0);
const [newQuestions, setNewQuestions] = useState(0);
const [error, setError] = useState('');
// 初始化默认主题
useEffect(() => {
if (project && project.name) {
setTopic(project.name);
}
}, [project]);
// 计算预估标签和问题数量
useEffect(() => {
/*
* 根据公式:总问题数 = \left( \prod_{i=1}^{n} L_i \right) \times Q
* 当每层标签数量相同(L)时:总问题数 = L^n \times Q
*/
const leafTags = Math.pow(tagsPerLevel, levels);
// 总问题数 = 叶子节点数 * 每个节点的问题数
const totalQuestions = leafTags * questionsPerTag;
let totalTags;
if (tagsPerLevel === 1) {
// 如果每层只有1个标签总数就是 levels+1
totalTags = levels + 1;
} else {
// 使用等比数列求和公式
totalTags = (1 - Math.pow(tagsPerLevel, levels + 1)) / (1 - tagsPerLevel);
}
setLeafTags(leafTags);
setEstimatedTags(leafTags); // 改为只显示叶子节点数量,而非所有节点数量
setEstimatedQuestions(totalQuestions);
// 计算新增标签和问题数量
const currentTags = stats.tagsCount || 0;
const currentQuestions = stats.questionsCount || 0;
// 只考虑最后一层的标签数量
setNewTags(Math.max(0, leafTags - currentTags));
setNewQuestions(Math.max(0, totalQuestions - currentQuestions));
// 验证是否可以执行任务
if (leafTags <= currentTags && totalQuestions <= currentQuestions) {
setError(t('distill.autoDistillInsufficientError'));
} else {
setError('');
}
}, [levels, tagsPerLevel, questionsPerTag, stats, t]);
// 处理开始任务
const handleStart = () => {
if (error) return;
onStart({
topic,
levels,
tagsPerLevel,
questionsPerTag,
estimatedTags,
estimatedQuestions,
datasetType
});
};
// 处理开始后台任务
const handleStartBackground = () => {
if (error) return;
onStartBackground({
topic,
levels,
tagsPerLevel,
questionsPerTag,
estimatedTags,
estimatedQuestions,
datasetType
});
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{t('distill.autoDistillTitle')}</DialogTitle>
<DialogContent>
<Box sx={{ py: 2, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, gap: 3 }}>
{/* 左侧:输入区域 */}
<Box sx={{ flex: 1 }}>
<TextField
label={t('distill.distillTopic')}
value={topic}
onChange={e => setTopic(e.target.value)}
fullWidth
margin="normal"
required
disabled
helperText={t('distill.rootTopicHelperText')}
/>
<Box sx={{ mt: 3, mb: 2 }}>
<Typography gutterBottom>{t('distill.tagLevels')}</Typography>
<TextField
type="number"
fullWidth
InputProps={{
inputProps: { min: 1, max: 5 }
}}
value={levels}
onChange={e => {
const value = Math.min(5, Math.max(1, Number(e.target.value)));
setLevels(value);
}}
helperText={t('distill.tagLevelsHelper', { max: 5 })}
/>
</Box>
<Box sx={{ mt: 3, mb: 2 }}>
<Typography gutterBottom>{t('distill.tagsPerLevel')}</Typography>
<TextField
type="number"
fullWidth
InputProps={{
inputProps: { min: 1, max: 50 }
}}
value={tagsPerLevel}
onChange={e => {
const value = Math.min(50, Math.max(1, Number(e.target.value)));
setTagsPerLevel(value);
}}
helperText={t('distill.tagsPerLevelHelper', { max: 50 })}
/>
</Box>
<Box sx={{ mt: 3, mb: 2 }}>
<Typography gutterBottom>{t('distill.questionsPerTag')}</Typography>
<TextField
type="number"
fullWidth
InputProps={{
inputProps: { min: 1, max: 50 }
}}
value={questionsPerTag}
onChange={e => {
const value = Math.min(50, Math.max(1, Number(e.target.value)));
setQuestionsPerTag(value);
}}
helperText={t('distill.questionsPerTagHelper', { max: 50 })}
/>
</Box>
<Box sx={{ mt: 3, mb: 2 }}>
<FormControl component="fieldset">
<FormLabel component="legend" sx={{ mb: 2, fontWeight: 'medium' }}>
{t('distill.datasetType', { defaultValue: '数据集类型' })}
</FormLabel>
<RadioGroup value={datasetType} onChange={e => setDatasetType(e.target.value)}>
<FormControlLabel
value="single-turn"
control={<Radio />}
label={t('distill.singleTurnDataset', { defaultValue: '单轮对话数据集' })}
/>
<FormControlLabel
value="multi-turn"
control={<Radio />}
label={t('distill.multiTurnDataset', { defaultValue: '多轮对话数据集' })}
/>
<FormControlLabel
value="both"
control={<Radio />}
label={t('distill.bothDatasetTypes', { defaultValue: '两种数据集都生成' })}
/>
</RadioGroup>
</FormControl>
</Box>
</Box>
{/* 右侧:预估信息区域 */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Paper
variant="outlined"
sx={{
p: 3,
mt: 1,
borderRadius: 2,
flex: 1,
display: 'flex',
flexDirection: 'column'
}}
>
<Typography variant="h6" fontWeight="bold" gutterBottom>
{t('distill.estimationInfo')}
</Typography>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, mt: 2 }}>
<Typography variant="subtitle2">{t('distill.estimatedTags')}:</Typography>
<Typography variant="subtitle1" fontWeight="medium">
{estimatedTags}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle2">{t('distill.estimatedQuestions')}:</Typography>
<Typography variant="subtitle1" fontWeight="medium">
{estimatedQuestions}
</Typography>
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle2">{t('distill.currentTags')}:</Typography>
<Typography variant="subtitle1" fontWeight="medium">
{stats.tagsCount || 0}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle2">{t('distill.currentQuestions')}:</Typography>
<Typography variant="subtitle1" fontWeight="medium">
{stats.questionsCount || 0}
</Typography>
</Box>
</Box>
<Box sx={{ pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle1" color="primary">
{t('distill.newTags')}:
</Typography>
<Typography variant="h6" fontWeight="bold" color="primary.main">
{newTags}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle1" color="primary">
{t('distill.newQuestions')}:
</Typography>
<Typography variant="h6" fontWeight="bold" color="primary.main">
{newQuestions}
</Typography>
</Box>
</Box>
</Box>
</Paper>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleStartBackground} color="secondary" variant="outlined" disabled={!!error || !topic}>
{t('distill.startAutoDistillBackground', { defaultValue: '开始自动蒸馏(后台运行)' })}
</Button>
<Button onClick={handleStart} color="primary" variant="contained" disabled={!!error || !topic}>
{t('distill.startAutoDistill')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogTitle,
DialogContent,
Box,
Typography,
LinearProgress,
Paper,
Divider,
IconButton,
Button
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
/**
* 全自动蒸馏进度组件
* @param {Object} props
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调
* @param {Object} props.progress - 进度信息
*/
export default function AutoDistillProgress({ open, onClose, progress = {} }) {
const { t } = useTranslation();
const logContainerRef = useRef(null);
// 自动滚动到底部
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [progress.logs]);
const getStageText = () => {
const { stage } = progress;
switch (stage) {
case 'level1':
return t('distill.stageBuildingLevel1');
case 'level2':
return t('distill.stageBuildingLevel2');
case 'level3':
return t('distill.stageBuildingLevel3');
case 'level4':
return t('distill.stageBuildingLevel4');
case 'level5':
return t('distill.stageBuildingLevel5');
case 'questions':
return t('distill.stageBuildingQuestions');
case 'datasets':
return t('distill.stageBuildingDatasets');
case 'multi-turn-datasets':
return t('distill.stageBuildingMultiTurnDatasets', { defaultValue: '生成多轮对话数据集中...' });
case 'completed':
return t('distill.stageCompleted');
default:
return t('distill.stageInitializing');
}
};
const getOverallProgress = () => {
const { tagsBuilt, tagsTotal, questionsBuilt, questionsTotal, datasetsBuilt, datasetsTotal } = progress;
// 整体进度按比例计算标签构建占30%问题生成占35%数据集生成占35%
let tagProgress = tagsTotal ? (tagsBuilt / tagsTotal) * 30 : 0;
let questionProgress = questionsTotal ? (questionsBuilt / questionsTotal) * 35 : 0;
let datasetProgress = datasetsTotal ? (datasetsBuilt / datasetsTotal) * 35 : 0;
return Math.min(100, Math.round(tagProgress + questionProgress + datasetProgress));
};
return (
<Dialog
open={open}
onClose={progress.stage === 'completed' || !progress.stage ? onClose : null}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{t('distill.autoDistillProgress')}
{(progress.stage === 'completed' || !progress.stage) && (
<IconButton onClick={onClose} aria-label="close">
<CloseIcon />
</IconButton>
)}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ py: 1 }}>
{/* 整体进度 */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
{t('distill.overallProgress')}
</Typography>
<Box sx={{ mb: 2 }}>
<LinearProgress variant="determinate" value={getOverallProgress()} sx={{ height: 10, borderRadius: 5 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
<Typography variant="body2" color="text.secondary">
{getOverallProgress()}%
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: progress.multiTurnDatasetsTotal > 0 ? 'repeat(4, 1fr)' : 'repeat(3, 1fr)',
gap: 2
}}
>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('distill.tagsProgress')}
</Typography>
<Typography variant="h6">
{progress.tagsBuilt || 0} / {progress.tagsTotal || 0}
</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('distill.questionsProgress')}
</Typography>
<Typography variant="h6">
{progress.questionsBuilt || 0} / {progress.questionsTotal || 0}
</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('distill.datasetsProgress')}
</Typography>
<Typography variant="h6">
{progress.datasetsBuilt || 0} / {progress.datasetsTotal || 0}
</Typography>
</Paper>
{progress.multiTurnDatasetsTotal > 0 && (
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('distill.multiTurnDatasetsProgress', { defaultValue: '多轮对话进度' })}
</Typography>
<Typography variant="h6">
{progress.multiTurnDatasetsBuilt || 0} / {progress.multiTurnDatasetsTotal || 0}
</Typography>
</Paper>
)}
</Box>
</Box>
{/* 当前阶段 */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
{t('distill.currentStage')}
</Typography>
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'primary.light', color: 'primary.contrastText' }}>
<Typography variant="h6">{getStageText()}</Typography>
</Paper>
</Box>
{/* 实时日志 */}
<Box sx={{ mb: 2 }}>
<Typography variant="h6" gutterBottom>
{t('distill.realTimeLogs')}
</Typography>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 250,
overflow: 'auto',
bgcolor: 'grey.900',
color: 'grey.100',
fontFamily: 'monospace',
fontSize: '0.875rem'
}}
ref={logContainerRef}
>
{progress.logs?.length > 0 ? (
progress.logs.map((log, index) => {
// 检测成功日志,显示为绿色 Successfully
let color = 'inherit';
if (log.includes('成功') || log.includes('完成') || log.includes('Successfully')) {
color = '#4caf50';
}
if (log.includes('失败') || log.toLowerCase().includes('error')) {
color = '#f44336';
}
return (
<Box key={index} sx={{ mb: 0.5, color: color }}>
{log}
</Box>
);
})
) : (
<Typography variant="body2" color="grey.500">
{t('distill.waitingForLogs')}
</Typography>
)}
</Paper>
</Box>
</Box>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { Dialog, DialogActions, DialogTitle, Button } from '@mui/material';
/**
* 通用确认对话框组件
* @param {Object} props
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调
* @param {Function} props.onConfirm - 确认操作的回调
* @param {string} props.title - 对话框标题
* @param {string} props.cancelText - 取消按钮文本
* @param {string} props.confirmText - 确认按钮文本
*/
export default function ConfirmDialog({
open,
onClose,
onConfirm,
title,
cancelText = '取消',
confirmText = '确认',
confirmColor = 'error'
}) {
return (
<Dialog open={open} onClose={onClose} aria-labelledby="confirm-dialog-title">
<DialogTitle id="confirm-dialog-title">{title}</DialogTitle>
<DialogActions>
<Button onClick={onClose} color="primary">
{cancelText}
</Button>
<Button onClick={onConfirm} color={confirmColor} autoFocus>
{confirmText}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,515 @@
'use client';
import { useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Typography, List } from '@mui/material';
import axios from 'axios';
import { useAtomValue } from 'jotai';
import { selectedModelInfoAtom } from '@/lib/store';
import { useGenerateDataset } from '@/hooks/useGenerateDataset';
import { toast } from 'sonner';
// 导入子组件
import TagTreeItem from './TagTreeItem';
import TagMenu from './TagMenu';
import TagEditDialog from './TagEditDialog';
import ConfirmDialog from './ConfirmDialog';
import { sortTagsByNumber } from './utils';
/**
* 蒸馏树形视图组件
* @param {Object} props
* @param {string} props.projectId - 项目ID
* @param {Array} props.tags - 标签列表
* @param {Function} props.onGenerateSubTags - 生成子标签的回调函数
* @param {Function} props.onGenerateQuestions - 生成问题的回调函数
* @param {Function} props.onTagsUpdate - 标签更新的回调函数
*/
const DistillTreeView = forwardRef(function DistillTreeView(
{ projectId, tags = [], onGenerateSubTags, onGenerateQuestions, onTagsUpdate },
ref
) {
const { t } = useTranslation();
const selectedModel = useAtomValue(selectedModelInfoAtom);
const [expandedTags, setExpandedTags] = useState({});
const [tagQuestions, setTagQuestions] = useState({});
const [loadingTags, setLoadingTags] = useState({});
const [loadingQuestions, setLoadingQuestions] = useState({});
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const [selectedTagForMenu, setSelectedTagForMenu] = useState(null);
const [allQuestions, setAllQuestions] = useState([]);
const [loading, setLoading] = useState(false);
const [processingQuestions, setProcessingQuestions] = useState({});
const [processingMultiTurnQuestions, setProcessingMultiTurnQuestions] = useState({});
const [deleteQuestionConfirmOpen, setDeleteQuestionConfirmOpen] = useState(false);
const [questionToDelete, setQuestionToDelete] = useState(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [tagToDelete, setTagToDelete] = useState(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [tagToEdit, setTagToEdit] = useState(null);
const [project, setProject] = useState(null);
const [projectName, setProjectName] = useState('');
// 使用生成数据集的hook
const { generateSingleDataset } = useGenerateDataset();
// 获取问题统计信息
const fetchQuestionsStats = useCallback(async () => {
try {
setLoading(true);
const response = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`);
setAllQuestions(response.data);
console.log('获取问题统计信息成功:', { totalQuestions: response.data.length });
} catch (error) {
console.error('获取问题统计信息失败:', error);
} finally {
setLoading(false);
}
}, [projectId]);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
fetchQuestionsStats
}));
// 获取标签下的问题
const fetchQuestionsByTag = useCallback(
async tagId => {
try {
setLoadingQuestions(prev => ({ ...prev, [tagId]: true }));
const response = await axios.get(`/api/projects/${projectId}/distill/questions/by-tag?tagId=${tagId}`);
setTagQuestions(prev => ({
...prev,
[tagId]: response.data
}));
} catch (error) {
console.error('获取标签问题失败:', error);
} finally {
setLoadingQuestions(prev => ({ ...prev, [tagId]: false }));
}
},
[projectId]
);
// 获取项目信息,获取项目名称
useEffect(() => {
if (projectId) {
axios
.get(`/api/projects/${projectId}`)
.then(response => {
setProject(response.data);
setProjectName(response.data.name || '');
})
.catch(error => {
console.error('获取项目信息失败:', error);
});
}
}, [projectId]);
// 初始化时获取问题统计信息
useEffect(() => {
fetchQuestionsStats();
}, [fetchQuestionsStats]);
// 构建标签树
const tagTree = useMemo(() => {
const rootTags = [];
const tagMap = {};
// 创建标签映射
tags.forEach(tag => {
tagMap[tag.id] = { ...tag, children: [] };
});
// 构建树结构
tags.forEach(tag => {
if (tag.parentId && tagMap[tag.parentId]) {
tagMap[tag.parentId].children.push(tagMap[tag.id]);
} else {
rootTags.push(tagMap[tag.id]);
}
});
return rootTags;
}, [tags]);
// 切换标签展开/折叠状态
const toggleTag = useCallback(
tagId => {
setExpandedTags(prev => ({
...prev,
[tagId]: !prev[tagId]
}));
// 如果展开且还没有加载过问题,则加载问题
if (!expandedTags[tagId] && !tagQuestions[tagId]) {
fetchQuestionsByTag(tagId);
}
},
[expandedTags, tagQuestions, fetchQuestionsByTag]
);
// 处理菜单打开
const handleMenuOpen = (event, tag) => {
event.stopPropagation();
setMenuAnchorEl(event.currentTarget);
setSelectedTagForMenu(tag);
};
// 处理菜单关闭
const handleMenuClose = () => {
setMenuAnchorEl(null);
setSelectedTagForMenu(null);
};
// 打开编辑标签对话框
const openEditDialog = () => {
setTagToEdit(selectedTagForMenu);
setEditDialogOpen(true);
handleMenuClose();
};
// 关闭编辑标签对话框
const closeEditDialog = () => {
setEditDialogOpen(false);
setTagToEdit(null);
};
// 处理编辑标签成功
const handleEditTagSuccess = updatedTag => {
// 更新标签数据,不刷新页面
const updateTagInTree = tagList => {
return tagList.map(tag => {
if (tag.id === updatedTag.id) {
return { ...tag, label: updatedTag.label };
}
if (tag.children && tag.children.length > 0) {
return { ...tag, children: updateTagInTree(tag.children) };
}
return tag;
});
};
// 调用父组件的回调更新标签列表
const updatedTags = updateTagInTree(tags);
onTagsUpdate?.(updatedTags);
};
// 打开删除确认对话框
const openDeleteConfirm = () => {
console.log('打开删除确认对话框', selectedTagForMenu);
// 保存要删除的标签
setTagToDelete(selectedTagForMenu);
setDeleteConfirmOpen(true);
handleMenuClose();
};
// 关闭删除确认对话框
const closeDeleteConfirm = () => {
setDeleteConfirmOpen(false);
};
// 处理删除标签
const handleDeleteTag = () => {
if (!tagToDelete) {
console.log('没有要删除的标签信息');
return;
}
console.log('开始删除标签:', tagToDelete.id, tagToDelete.label);
// 先关闭确认对话框
closeDeleteConfirm();
// 执行删除操作
const deleteTagAction = async () => {
try {
console.log('发送删除请求:', `/api/projects/${projectId}/tags?id=${tagToDelete.id}`);
// 发送删除请求
const response = await axios.delete(`/api/projects/${projectId}/tags?id=${tagToDelete.id}`);
console.log('删除标签成功:', response.data);
// 刷新页面
window.location.reload();
} catch (error) {
console.error('删除标签失败:', error);
console.error('错误详情:', error.response ? error.response.data : '无响应数据');
alert(`删除标签失败: ${error.message}`);
}
};
// 立即执行删除操作
deleteTagAction();
};
// 打开删除问题确认对话框
const openDeleteQuestionConfirm = (questionId, event) => {
event.stopPropagation();
setQuestionToDelete(questionId);
setDeleteQuestionConfirmOpen(true);
};
// 关闭删除问题确认对话框
const closeDeleteQuestionConfirm = () => {
setDeleteQuestionConfirmOpen(false);
setQuestionToDelete(null);
};
// 处理删除问题
const handleDeleteQuestion = async () => {
if (!questionToDelete) return;
try {
await axios.delete(`/api/projects/${projectId}/questions/${questionToDelete}`);
// 更新问题列表
setTagQuestions(prev => {
const newQuestions = { ...prev };
Object.keys(newQuestions).forEach(tagId => {
newQuestions[tagId] = newQuestions[tagId].filter(q => q.id !== questionToDelete);
});
return newQuestions;
});
// 关闭确认对话框
closeDeleteQuestionConfirm();
} catch (error) {
console.error('删除问题失败:', error);
}
};
// 处理生成数据集
const handleGenerateDataset = async (questionId, questionInfo, event) => {
event.stopPropagation();
// 设置处理状态
setProcessingQuestions(prev => ({
...prev,
[questionId]: true
}));
await generateSingleDataset({ projectId, questionId, questionInfo });
// 重置处理状态
setProcessingQuestions(prev => ({
...prev,
[questionId]: false
}));
};
// 处理生成多轮对话数据集
const handleGenerateMultiTurnDataset = async (questionId, questionInfo, event) => {
event.stopPropagation();
try {
// 设置处理状态
setProcessingMultiTurnQuestions(prev => ({
...prev,
[questionId]: true
}));
// 首先检查项目是否配置了多轮对话设置
const configResponse = await axios.get(`/api/projects/${projectId}/tasks`);
if (configResponse.status !== 200) {
throw new Error('获取项目配置失败');
}
const config = configResponse.data;
const multiTurnConfig = {
systemPrompt: config.multiTurnSystemPrompt,
scenario: config.multiTurnScenario,
rounds: config.multiTurnRounds,
roleA: config.multiTurnRoleA,
roleB: config.multiTurnRoleB
};
// 检查是否已配置必要的多轮对话设置
if (
!multiTurnConfig.scenario ||
!multiTurnConfig.roleA ||
!multiTurnConfig.roleB ||
!multiTurnConfig.rounds ||
multiTurnConfig.rounds < 1
) {
throw new Error('请先在项目设置中配置多轮对话相关参数');
}
// 检查是否选择了模型
if (!selectedModel || Object.keys(selectedModel).length === 0) {
throw new Error('请先选择一个模型');
}
// 调用多轮对话生成API
const response = await axios.post(`/api/projects/${projectId}/dataset-conversations`, {
questionId,
...multiTurnConfig,
model: selectedModel,
language: 'zh-CN'
});
if (response.status === 200) {
// 成功后刷新问题统计
fetchQuestionsStats();
toast.success(t('datasets.multiTurnGenerateSuccess', { defaultValue: '多轮对话数据集生成成功!' }));
// 通知父组件刷新统计信息
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('refreshDistillStats'));
}
}
} catch (error) {
console.error('生成多轮对话数据集失败:', error);
toast.error(error.message || t('datasets.multiTurnGenerateError', { defaultValue: '生成多轮对话数据集失败' }));
} finally {
// 重置处理状态
setProcessingMultiTurnQuestions(prev => ({
...prev,
[questionId]: false
}));
}
};
// 获取标签路径
const getTagPath = useCallback(
tag => {
if (!tag) return '';
const findPath = (currentTag, path = []) => {
const newPath = [currentTag.label, ...path];
if (!currentTag.parentId) {
// 如果是顶级标签,确保路径以项目名称开始
if (projectName && !newPath.includes(projectName)) {
return [projectName, ...newPath];
}
return newPath;
}
const parentTag = tags.find(t => t.id === currentTag.parentId);
if (!parentTag) {
// 如果没有找到父标签,确保路径以项目名称开始
if (projectName && !newPath.includes(projectName)) {
return [projectName, ...newPath];
}
return newPath;
}
return findPath(parentTag, newPath);
};
const path = findPath(tag);
// 最终检查,确保路径以项目名称开始
if (projectName && path.length > 0 && path[0] !== projectName) {
path.unshift(projectName);
}
return path.join(' > ');
},
[tags, projectName]
);
// 渲染标签树
const renderTagTree = (tagList, level = 0) => {
// 对同级标签进行排序
const sortedTagList = sortTagsByNumber(tagList);
return (
<List disablePadding sx={{ px: 2 }}>
{sortedTagList.map(tag => (
<TagTreeItem
key={tag.id}
tag={tag}
level={level}
expanded={expandedTags[tag.id]}
onToggle={toggleTag}
onMenuOpen={handleMenuOpen}
onGenerateQuestions={tag => {
// 包装函数,处理问题生成后的刷新
const handleGenerateQuestionsWithRefresh = async () => {
// 调用父组件传入的函数生成问题
await onGenerateQuestions(tag, getTagPath(tag));
// 生成问题后刷新数据
await fetchQuestionsStats();
// 如果标签已展开,刷新该标签的问题详情
if (expandedTags[tag.id]) {
await fetchQuestionsByTag(tag.id);
}
};
handleGenerateQuestionsWithRefresh();
}}
onGenerateSubTags={tag => onGenerateSubTags(tag, getTagPath(tag))}
questions={tagQuestions[tag.id] || []}
loadingQuestions={loadingQuestions[tag.id]}
processingQuestions={processingQuestions}
processingMultiTurnQuestions={processingMultiTurnQuestions}
onDeleteQuestion={openDeleteQuestionConfirm}
onGenerateDataset={handleGenerateDataset}
onGenerateMultiTurnDataset={handleGenerateMultiTurnDataset}
allQuestions={allQuestions}
tagQuestions={tagQuestions}
>
{/* 递归渲染子标签 */}
{tag.children && tag.children.length > 0 && expandedTags[tag.id] && renderTagTree(tag.children, level + 1)}
</TagTreeItem>
))}
</List>
);
};
return (
<Box>
{tagTree.length > 0 ? (
renderTagTree(tagTree)
) : (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
{t('distill.noTags')}
</Typography>
</Box>
)}
{/* 标签操作菜单 */}
<TagMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
onEdit={openEditDialog}
onDelete={openDeleteConfirm}
/>
{/* 编辑标签对话框 */}
<TagEditDialog
open={editDialogOpen}
tag={tagToEdit}
projectId={projectId}
onClose={closeEditDialog}
onSuccess={handleEditTagSuccess}
/>
{/* 删除标签确认对话框 */}
<ConfirmDialog
open={deleteConfirmOpen}
onClose={closeDeleteConfirm}
onConfirm={handleDeleteTag}
title={t('distill.deleteTagConfirmTitle')}
cancelText={t('common.cancel')}
confirmText={t('common.delete')}
confirmColor="error"
/>
{/* 删除问题确认对话框 */}
<ConfirmDialog
open={deleteQuestionConfirmOpen}
onClose={closeDeleteQuestionConfirm}
onConfirm={handleDeleteQuestion}
title={t('questions.deleteConfirm')}
cancelText={t('common.cancel')}
confirmText={t('common.delete')}
confirmColor="error"
/>
</Box>
);
});
export default DistillTreeView;

View File

@@ -0,0 +1,194 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Typography,
Box,
CircularProgress,
Alert,
List,
ListItem,
ListItemText,
Paper,
IconButton,
Divider
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import axios from 'axios';
import i18n from '@/lib/i18n';
/**
* 问题生成对话框组件
* @param {Object} props
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调函数
* @param {Function} props.onGenerated - 问题生成完成的回调函数
* @param {string} props.projectId - 项目ID
* @param {Object} props.tag - 标签对象
* @param {string} props.tagPath - 标签路径
* @param {Object} props.model - 选择的模型配置
*/
export default function QuestionGenerationDialog({ open, onClose, onGenerated, projectId, tag, tagPath, model }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [count, setCount] = useState(5);
const [generatedQuestions, setGeneratedQuestions] = useState([]);
// 处理生成问题
const handleGenerateQuestions = async () => {
try {
setLoading(true);
setError('');
const response = await axios.post(`/api/projects/${projectId}/distill/questions`, {
tagPath,
currentTag: tag.label,
tagId: tag.id,
count,
model,
language: i18n.language
});
setGeneratedQuestions(response.data);
} catch (error) {
console.error('生成问题失败:', error);
setError(error.response?.data?.error || t('distill.generateQuestionsError'));
} finally {
setLoading(false);
}
};
// 处理生成完成
const handleGenerateComplete = async () => {
if (onGenerated) {
onGenerated(generatedQuestions);
}
handleClose();
};
// 处理关闭对话框
const handleClose = () => {
setGeneratedQuestions([]);
setError('');
setCount(5);
if (onClose) {
onClose();
}
};
// 处理数量变化
const handleCountChange = event => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 1 && value <= 100) {
setCount(value);
}
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: { borderRadius: 2 }
}}
>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="div">
{t('distill.generateQuestionsTitle', { tag: tag?.label || t('distill.unknownTag') })}
</Typography>
<IconButton edge="end" color="inherit" onClick={handleClose} aria-label="close">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>
{t('distill.tagPath')}:
</Typography>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 1, backgroundColor: 'background.paper' }}>
<Typography variant="body1">{tagPath || tag?.label || t('distill.unknownTag')}</Typography>
</Paper>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>
{t('distill.questionCount')}:
</Typography>
<TextField
fullWidth
variant="outlined"
type="number"
value={count}
onChange={handleCountChange}
inputProps={{ min: 1, max: 100 }}
disabled={loading}
helperText={t('distill.questionCountHelp')}
/>
</Box>
{generatedQuestions.length > 0 && (
<Box>
<Typography variant="subtitle1" gutterBottom>
{t('distill.generatedQuestions')}:
</Typography>
<Paper variant="outlined" sx={{ p: 0, borderRadius: 1, backgroundColor: 'background.paper' }}>
<List disablePadding>
{generatedQuestions.map((question, index) => (
<React.Fragment key={index}>
{index > 0 && <Divider />}
<ListItem>
<ListItemText
primary={question.question}
primaryTypographyProps={{
style: { whiteSpace: 'normal', wordBreak: 'break-word' }
}}
/>
</ListItem>
</React.Fragment>
))}
</List>
</Paper>
</Box>
)}
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={handleClose} color="inherit">
{t('common.cancel')}
</Button>
{generatedQuestions.length > 0 ? (
<Button onClick={handleGenerateComplete} color="primary" variant="contained">
{t('common.complete')}
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={handleGenerateQuestions}
disabled={loading}
startIcon={loading && <CircularProgress size={20} color="inherit" />}
>
{loading ? t('common.generating') : t('distill.generateQuestions')}
</Button>
)}
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import {
ListItem,
ListItemIcon,
ListItemText,
Box,
Typography,
Chip,
IconButton,
Tooltip,
CircularProgress
} from '@mui/material';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import DeleteIcon from '@mui/icons-material/Delete';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import ChatIcon from '@mui/icons-material/Chat';
import { useTranslation } from 'react-i18next';
/**
* 问题列表项组件
* @param {Object} props
* @param {Object} props.question - 问题对象
* @param {number} props.level - 缩进级别
* @param {Function} props.onDelete - 删除问题的回调
* @param {Function} props.onGenerateDataset - 生成数据集的回调
* @param {Function} props.onGenerateMultiTurnDataset - 生成多轮对话数据集的回调
* @param {boolean} props.processing - 是否正在处理
* @param {boolean} props.processingMultiTurn - 是否正在生成多轮对话
*/
export default function QuestionListItem({
question,
level,
onDelete,
onGenerateDataset,
onGenerateMultiTurnDataset,
processing = false,
processingMultiTurn = false
}) {
const { t } = useTranslation();
return (
<ListItem
sx={{
pl: (level + 1) * 2,
py: 0.75,
borderLeft: '1px dashed rgba(0, 0, 0, 0.1)',
ml: 2,
borderBottom: '1px solid',
borderColor: 'divider',
'&:hover': {
bgcolor: 'action.hover'
}
}}
secondaryAction={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Tooltip title={t('datasets.generateDataset')}>
<IconButton
size="small"
color="primary"
onClick={e => onGenerateDataset(e)}
disabled={processing || processingMultiTurn}
>
{processing ? <CircularProgress size={16} /> : <AutoFixHighIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title={t('questions.generateMultiTurnDataset', { defaultValue: '生成多轮对话数据集' })}>
<IconButton
size="small"
color="secondary"
onClick={e => onGenerateMultiTurnDataset && onGenerateMultiTurnDataset(e)}
disabled={processing || processingMultiTurn || !onGenerateMultiTurnDataset}
>
{processingMultiTurn ? <CircularProgress size={16} /> : <ChatIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
color="error"
onClick={e => onDelete(e)}
disabled={processing || processingMultiTurn}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
}
>
<ListItemIcon sx={{ minWidth: 32, color: 'secondary.main' }}>
<HelpOutlineIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="body2"
sx={{
whiteSpace: 'normal',
wordBreak: 'break-word',
paddingRight: '28px' // 留出删除按钮的空间
}}
>
{question.question}
</Typography>
{question.answered && (
<Chip
size="small"
label={t('datasets.answered')}
color="success"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
</Box>
}
/>
</ListItem>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
CircularProgress,
Alert
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import { toast } from 'sonner';
/**
* 标签编辑对话框组件
* @param {Object} props
* @param {boolean} props.open - 对话框是否打开
* @param {Object} props.tag - 要编辑的标签对象
* @param {string} props.projectId - 项目ID
* @param {Function} props.onClose - 关闭对话框的回调
* @param {Function} props.onSuccess - 编辑成功的回调
*/
export default function TagEditDialog({ open, tag, projectId, onClose, onSuccess }) {
const { t } = useTranslation();
const [newLabel, setNewLabel] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open && tag) {
setNewLabel(tag.label);
setError('');
}
}, [open, tag]);
const handleConfirm = async () => {
if (!newLabel.trim()) {
setError(t('distill.labelRequired'));
return;
}
if (newLabel === tag.label) {
onClose();
return;
}
try {
setLoading(true);
setError('');
const response = await axios.put(`/api/projects/${projectId}/distill/tags/${tag.id}`, { label: newLabel.trim() });
if (response.status === 200) {
toast.success(t('distill.tagUpdateSuccess'));
onSuccess?.(response.data);
onClose();
}
} catch (err) {
console.error('更新标签失败:', err);
setError(err.response?.data?.error || t('distill.tagUpdateFailed'));
toast.error(err.response?.data?.error || t('distill.tagUpdateFailed'));
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
onClose();
}
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('distill.editTagTitle')}</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label={t('distill.tagName')}
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
disabled={loading}
autoFocus
onKeyPress={e => {
if (e.key === 'Enter' && !loading) {
handleConfirm();
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
{t('common.cancel')}
</Button>
<Button
onClick={handleConfirm}
variant="contained"
disabled={loading || !newLabel.trim()}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Typography,
Box,
CircularProgress,
Alert,
Chip,
Paper,
IconButton
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import axios from 'axios';
import i18n from '@/lib/i18n';
/**
* 标签生成对话框组件
* @param {Object} props
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调函数
* @param {Function} props.onGenerated - 标签生成完成的回调函数
* @param {string} props.projectId - 项目ID
* @param {Object} props.parentTag - 父标签对象为null时表示生成根标签
* @param {string} props.tagPath - 标签链路
* @param {Object} props.model - 选择的模型配置
*/
export default function TagGenerationDialog({ open, onClose, onGenerated, projectId, parentTag, tagPath, model }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [count, setCount] = useState(5);
const [generatedTags, setGeneratedTags] = useState([]);
const [parentTagName, setParentTagName] = useState('');
const [project, setProject] = useState(null);
// 获取项目信息,如果是顶级标签,默认填写项目名称
useEffect(() => {
if (projectId && !parentTag) {
axios
.get(`/api/projects/${projectId}`)
.then(response => {
setProject(response.data);
setParentTagName(response.data.name || '');
})
.catch(error => {
console.error('获取项目信息失败:', error);
});
} else if (parentTag) {
setParentTagName(parentTag.label || '');
}
}, [projectId, parentTag]);
// 处理生成标签
const handleGenerateTags = async () => {
try {
setLoading(true);
setError('');
const response = await axios.post(`/api/projects/${projectId}/distill/tags`, {
parentTag: parentTagName,
parentTagId: parentTag ? parentTag.id : null,
tagPath: tagPath || parentTagName,
count,
model,
language: i18n.language
});
setGeneratedTags(response.data);
} catch (error) {
console.error('生成标签失败:', error);
setError(error.response?.data?.error || t('distill.generateTagsError'));
} finally {
setLoading(false);
}
};
// 处理生成完成
const handleGenerateComplete = async () => {
if (onGenerated) {
onGenerated(generatedTags);
}
handleClose();
};
// 处理关闭对话框
const handleClose = () => {
setGeneratedTags([]);
setError('');
setCount(5);
if (onClose) {
onClose();
}
};
// 处理数量变化
const handleCountChange = event => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 1 && value <= 100) {
setCount(value);
}
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: { borderRadius: 2 }
}}
>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="div">
{parentTag
? t('distill.generateSubTagsTitle', { parentTag: parentTag.label })
: t('distill.generateRootTagsTitle')}
</Typography>
<IconButton edge="end" color="inherit" onClick={handleClose} aria-label="close">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* 标签路径显示 */}
{parentTag && tagPath && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>
{t('distill.tagPath')}:
</Typography>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 1, backgroundColor: 'background.paper' }}>
<Typography variant="body1">{tagPath || parentTag.label}</Typography>
</Paper>
</Box>
)}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>
{t('distill.parentTag')}:
</Typography>
<TextField
fullWidth
variant="outlined"
value={parentTagName}
onChange={e => setParentTagName(e.target.value)}
placeholder={t('distill.parentTagPlaceholder')}
disabled={loading || !parentTag}
// 如果是顶级标签,设置为只读
InputProps={{
readOnly: !parentTag
}}
// 显示适当的帮助文本
helperText={
!parentTag
? t('distill.rootTopicHelperText', { defaultValue: '使用项目名称作为顶级主题' })
: t('distill.parentTagHelp')
}
/>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>
{t('distill.tagCount')}:
</Typography>
<TextField
fullWidth
variant="outlined"
type="number"
value={count}
onChange={handleCountChange}
inputProps={{ min: 1, max: 100 }}
disabled={loading}
helperText={t('distill.tagCountHelp')}
/>
</Box>
{generatedTags.length > 0 && (
<Box>
<Typography variant="subtitle1" gutterBottom>
{t('distill.generatedTags')}:
</Typography>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 1, backgroundColor: 'background.paper' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{generatedTags.map((tag, index) => (
<Chip key={index} label={tag.label} color="primary" variant="outlined" />
))}
</Box>
</Paper>
</Box>
)}
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={handleClose} color="inherit">
{t('common.cancel')}
</Button>
{generatedTags.length > 0 ? (
<Button onClick={handleGenerateComplete} color="primary" variant="contained">
{t('common.complete')}
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={handleGenerateTags}
disabled={loading || !parentTagName}
startIcon={loading && <CircularProgress size={20} color="inherit" />}
>
{loading ? t('common.generating') : t('distill.generateTags')}
</Button>
)}
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import { useTranslation } from 'react-i18next';
/**
* 标签操作菜单组件
* @param {Object} props
* @param {HTMLElement} props.anchorEl - 菜单锚点元素
* @param {boolean} props.open - 菜单是否打开
* @param {Function} props.onClose - 关闭菜单的回调
* @param {Function} props.onEdit - 编辑操作的回调
* @param {Function} props.onDelete - 删除操作的回调
*/
export default function TagMenu({ anchorEl, open, onClose, onEdit, onDelete }) {
const { t } = useTranslation();
const handleEdit = () => {
onEdit?.();
onClose();
};
const handleDelete = () => {
onDelete?.();
onClose();
};
return (
<Menu anchorEl={anchorEl} open={open} onClose={onClose}>
<MenuItem onClick={handleEdit}>
<ListItemIcon>
<EditIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('common.edit')}</ListItemText>
</MenuItem>
<MenuItem onClick={handleDelete}>
<ListItemIcon>
<DeleteIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('common.delete')}</ListItemText>
</MenuItem>
</Menu>
);
}

View File

@@ -0,0 +1,240 @@
'use client';
import { useState } from 'react';
import {
Box,
Typography,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
IconButton,
Collapse,
Chip,
Tooltip,
List,
CircularProgress
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import AddIcon from '@mui/icons-material/Add';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useTranslation } from 'react-i18next';
import QuestionListItem from './QuestionListItem';
/**
* 标签树项组件
* @param {Object} props
* @param {Object} props.tag - 标签对象
* @param {number} props.level - 缩进级别
* @param {boolean} props.expanded - 是否展开
* @param {Function} props.onToggle - 切换展开/折叠的回调
* @param {Function} props.onMenuOpen - 打开菜单的回调
* @param {Function} props.onGenerateQuestions - 生成问题的回调
* @param {Function} props.onGenerateSubTags - 生成子标签的回调
* @param {Array} props.questions - 标签下的问题列表
* @param {boolean} props.loadingQuestions - 是否正在加载问题
* @param {Object} props.processingQuestions - 正在处理的问题ID映射
* @param {Function} props.onDeleteQuestion - 删除问题的回调
* @param {Function} props.onGenerateDataset - 生成数据集的回调
* @param {Function} props.onGenerateMultiTurnDataset - 生成多轮对话数据集的回调
* @param {Object} props.processingMultiTurnQuestions - 正在生成多轮对话的问题ID映射
* @param {Array} props.allQuestions - 所有问题列表(用于计算问题数量)
* @param {Object} props.tagQuestions - 标签问题映射
* @param {React.ReactNode} props.children - 子标签内容
*/
export default function TagTreeItem({
tag,
level = 0,
expanded = false,
onToggle,
onMenuOpen,
onGenerateQuestions,
onGenerateSubTags,
questions = [],
loadingQuestions = false,
processingQuestions = {},
onDeleteQuestion,
onGenerateDataset,
onGenerateMultiTurnDataset,
processingMultiTurnQuestions = {},
allQuestions = [],
tagQuestions = {},
children
}) {
const { t } = useTranslation();
// 递归计算所有层级的子标签数量
const getTotalSubTagsCount = childrenTags => {
let count = childrenTags.length;
childrenTags.forEach(childTag => {
if (childTag.children && childTag.children.length > 0) {
count += getTotalSubTagsCount(childTag.children);
}
});
return count;
};
// 递归获取所有子标签的问题数量
const getChildrenQuestionsCount = childrenTags => {
let count = 0;
childrenTags.forEach(childTag => {
// 子标签的问题
if (tagQuestions[childTag.id] && tagQuestions[childTag.id].length > 0) {
count += tagQuestions[childTag.id].length;
} else {
count += allQuestions.filter(q => q.label === childTag.label).length;
}
// 子标签的子标签的问题
if (childTag.children && childTag.children.length > 0) {
count += getChildrenQuestionsCount(childTag.children);
}
});
return count;
};
// 计算当前标签的问题数量
const getCurrentTagQuestionsCount = () => {
let currentTagQuestions = 0;
if (tagQuestions[tag.id] && tagQuestions[tag.id].length > 0) {
currentTagQuestions = tagQuestions[tag.id].length;
} else {
currentTagQuestions = allQuestions.filter(q => q.label === tag.label).length;
}
return currentTagQuestions;
};
// 总问题数量 = 当前标签的问题 + 所有子标签的问题
const totalQuestions =
getCurrentTagQuestionsCount() + (tag.children ? getChildrenQuestionsCount(tag.children || []) : 0);
return (
<Box key={tag.id} sx={{ my: 0.5 }}>
<ListItem
disablePadding
sx={{
pl: level * 2,
borderLeft: level > 0 ? '1px dashed rgba(0, 0, 0, 0.1)' : 'none',
ml: level > 0 ? 2 : 0
}}
>
<ListItemButton onClick={() => onToggle(tag.id)} sx={{ borderRadius: 1, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<FolderIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 'medium' }}>{tag.label}</Typography>
{tag.children && tag.children.length > 0 && (
<Chip
size="small"
label={`${getTotalSubTagsCount(tag.children)} ${t('distill.subTags')}`}
color="primary"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
{totalQuestions > 0 && (
<Chip
size="small"
label={`${totalQuestions} ${t('distill.questions')}`}
color="secondary"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
</Box>
}
primaryTypographyProps={{ component: 'div' }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Tooltip title={t('distill.generateQuestions')}>
<IconButton
size="small"
onClick={e => {
e.stopPropagation();
onGenerateQuestions(tag);
}}
>
<QuestionMarkIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('distill.addChildTag')}>
<IconButton
size="small"
onClick={e => {
e.stopPropagation();
onGenerateSubTags(tag);
}}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={e => onMenuOpen(e, tag)}>
<MoreVertIcon fontSize="small" />
</IconButton>
{tag.children && tag.children.length > 0 ? (
expanded ? (
<ExpandLessIcon fontSize="small" />
) : (
<ExpandMoreIcon fontSize="small" />
)
) : null}
</Box>
</ListItemButton>
</ListItem>
{/* 子标签 */}
{tag.children && tag.children.length > 0 && (
<Collapse in={expanded} timeout="auto" unmountOnExit>
{children}
</Collapse>
)}
{/* 标签下的问题 */}
{expanded && (
<Collapse in={expanded} timeout="auto" unmountOnExit>
<List disablePadding sx={{ mt: 0.5, mb: 1 }}>
{loadingQuestions ? (
<ListItem sx={{ pl: (level + 1) * 2, py: 0.75 }}>
<CircularProgress size={20} />
<Typography variant="body2" sx={{ ml: 2 }}>
{t('common.loading')}
</Typography>
</ListItem>
) : questions && questions.length > 0 ? (
questions.map(question => (
<QuestionListItem
key={question.id}
question={question}
level={level}
processing={processingQuestions[question.id]}
processingMultiTurn={processingMultiTurnQuestions[question.id]}
onDelete={e => onDeleteQuestion(question.id, e)}
onGenerateDataset={e => onGenerateDataset(question.id, question.question, e)}
onGenerateMultiTurnDataset={
onGenerateMultiTurnDataset ? e => onGenerateMultiTurnDataset(question.id, question, e) : undefined
}
/>
))
) : (
<ListItem sx={{ pl: (level + 1) * 2, py: 1 }}>
<Typography variant="body2" color="text.secondary">
{t('distill.noQuestions')}
</Typography>
</ListItem>
)}
</List>
</Collapse>
)}
</Box>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
/**
* 按照标签前面的序号对标签进行排序
* @param {Array} tags - 标签数组
* @returns {Array} 排序后的标签数组
*/
export const sortTagsByNumber = tags => {
return [...tags].sort((a, b) => {
// 提取标签前面的序号
const getNumberPrefix = label => {
// 匹配形如 1, 1.1, 1.1.2 的序号
const match = label.match(/^([\d.]+)\s/);
if (match) {
return match[1]; // 返回完整的序号字符串,如 "1.10"
}
return null; // 没有序号
};
const aPrefix = getNumberPrefix(a.label);
const bPrefix = getNumberPrefix(b.label);
// 如果两个标签都有序号,按序号比较
if (aPrefix && bPrefix) {
// 将序号分解为数组,然后按数值比较
const aParts = aPrefix.split('.').map(num => parseInt(num, 10));
const bParts = bPrefix.split('.').map(num => parseInt(num, 10));
// 比较序号数组
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
if (aParts[i] !== bParts[i]) {
return aParts[i] - bParts[i]; // 数值比较,确保 1.2 排在 1.10 前面
}
}
// 如果前面的数字都相同,则较短的序号在前
return aParts.length - bParts.length;
}
// 如果只有一个标签有序号,则有序号的在前
else if (aPrefix) {
return -1;
} else if (bPrefix) {
return 1;
}
// 如果都没有序号,则按原来的字母序排序
else {
return a.label.localeCompare(b.label, 'zh-CN');
}
});
};
/**
* 获取标签的完整路径
* @param {Object} tag - 标签对象
* @param {Array} allTags - 所有标签数组
* @returns {string} 标签路径,如 "标签1 > 标签2 > 标签3"
*/
export const getTagPath = (tag, allTags) => {
if (!tag) return '';
const findPath = (currentTag, path = []) => {
const newPath = [currentTag.label, ...path];
if (!currentTag.parentId) return newPath;
const parentTag = allTags.find(t => t.id === currentTag.parentId);
if (!parentTag) return newPath;
return findPath(parentTag, newPath);
};
return findPath(tag).join(' > ');
};

View File

@@ -0,0 +1,245 @@
// HuggingFaceTab.js 组件
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Typography,
Box,
TextField,
Button,
FormControlLabel,
Checkbox,
Alert,
CircularProgress,
Divider,
Paper,
Grid,
Tooltip,
IconButton,
Link
} from '@mui/material';
import InfoIcon from '@mui/icons-material/Info';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
const HuggingFaceTab = ({
projectId,
systemPrompt,
reasoningLanguage,
confirmedOnly,
includeCOT,
formatType,
fileFormat,
customFields,
handleSystemPromptChange,
handleReasoningLanguageChange,
handleConfirmedOnlyChange,
handleIncludeCOTChange
}) => {
const { t } = useTranslation();
const [token, setToken] = useState('');
const [datasetName, setDatasetName] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [datasetUrl, setDatasetUrl] = useState('');
const [hasToken, setHasToken] = useState(false);
const [loading, setLoading] = useState(true);
// 从配置中获取 huggingfaceToken
useEffect(() => {
if (projectId) {
setLoading(true);
fetch(`/api/projects/${projectId}/config`)
.then(res => res.json())
.then(data => {
if (data.huggingfaceToken) {
setToken(data.huggingfaceToken);
setHasToken(true);
}
setLoading(false);
})
.catch(err => {
console.error('获取 HuggingFace Token 失败:', err);
setLoading(false);
});
}
}, [projectId]);
// 处理上传数据集到 HuggingFace
const handleUpload = async () => {
if (!hasToken) {
return;
}
if (!datasetName) {
setError('请输入数据集名称');
return;
}
try {
setUploading(true);
setError('');
setSuccess(false);
const response = await fetch(`/api/projects/${projectId}/huggingface/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
token,
datasetName,
isPrivate,
formatType,
systemPrompt,
reasoningLanguage,
confirmedOnly,
includeCOT,
fileFormat,
customFields: formatType === 'custom' ? customFields : undefined
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '上传失败');
}
setSuccess(true);
setDatasetUrl(data.url);
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<Box sx={{ mt: 2 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} icon={<CheckCircleOutlineIcon fontSize="inherit" />}>
{t('export.uploadSuccess')}
{datasetUrl && (
<Box mt={1}>
<Link href={datasetUrl} target="_blank" rel="noopener noreferrer">
{t('export.viewOnHuggingFace')}
</Link>
</Box>
)}
</Alert>
)}
{!hasToken ? (
<Alert severity="warning" sx={{ mb: 3 }}>
{t('export.noTokenWarning')}
<Box mt={1}>
<Button
variant="outlined"
size="small"
onClick={() => (window.location.href = `/projects/${projectId}/settings`)}
>
{t('export.goToSettings')}
</Button>
</Box>
</Alert>
) : null}
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.datasetSettings')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth
label={t('export.datasetName')}
placeholder="username/dataset-name"
value={datasetName}
onChange={e => setDatasetName(e.target.value)}
helperText={t('export.datasetNameHelp')}
sx={{ mb: 2 }}
/>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={<Checkbox checked={isPrivate} onChange={e => setIsPrivate(e.target.checked)} />}
label={t('export.privateDataset')}
/>
</Grid>
</Grid>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.exportOptions')}
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{t('export.systemPrompt')}
</Typography>
<TextField
fullWidth
multiline
rows={3}
value={systemPrompt}
onChange={handleSystemPromptChange}
variant="outlined"
/>
</Box>
{/* Reasoning language only for multilingualthinking */}
{formatType === 'multilingualthinking' && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.reasoningLanguage')}
</Typography>
<TextField
fullWidth
rows={3}
multiline
variant="outlined"
placeholder={t('export.reasoningLanguage')}
value={reasoningLanguage}
onChange={handleReasoningLanguageChange}
/>
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 4 }}>
<FormControlLabel
control={<Checkbox checked={confirmedOnly} onChange={handleConfirmedOnlyChange} />}
label={t('export.onlyConfirmed')}
/>
<FormControlLabel
control={<Checkbox checked={includeCOT} onChange={handleIncludeCOTChange} />}
label={t('export.includeCOT')}
/>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
<Button
variant="contained"
onClick={handleUpload}
disabled={uploading || !hasToken || !datasetName}
sx={{ borderRadius: 2 }}
>
{uploading ? <CircularProgress size={24} /> : t('export.uploadToHuggingFace')}
</Button>
</Box>
</Box>
);
};
export default HuggingFaceTab;

View File

@@ -0,0 +1,184 @@
// LlamaFactoryTab.js 组件
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
FormControlLabel,
Checkbox,
Typography,
Box,
TextField,
Alert,
CircularProgress,
IconButton,
Tooltip
} from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
const LlamaFactoryTab = ({
projectId,
systemPrompt,
reasoningLanguage,
confirmedOnly,
includeCOT,
formatType,
handleSystemPromptChange,
handleReasoningLanguageChange,
handleConfirmedOnlyChange,
handleIncludeCOTChange
}) => {
const { t } = useTranslation();
const [configExists, setConfigExists] = useState(false);
const [configPath, setConfigPath] = useState('');
const [generating, setGenerating] = useState(false);
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
// 检查配置文件是否存在
useEffect(() => {
if (projectId) {
fetch(`/api/projects/${projectId}/llamaFactory/checkConfig`)
.then(res => res.json())
.then(data => {
setConfigExists(data.exists);
if (data.exists) {
setConfigPath(data.configPath);
}
})
.catch(err => {
setError(err.message);
});
}
}, [projectId, configExists]);
// 复制路径到剪贴板
const handleCopyPath = () => {
const path = configPath.replace('dataset_info.json', '');
navigator.clipboard.writeText(path).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
// 处理生成 Llama Factory 配置
const handleGenerateConfig = async () => {
try {
setGenerating(true);
setError('');
const response = await fetch(`/api/projects/${projectId}/llamaFactory/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
formatType,
systemPrompt,
reasoningLanguage,
confirmedOnly,
includeCOT
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error);
}
setConfigExists(true);
} catch (err) {
setError(err.message);
} finally {
setGenerating(false);
}
};
return (
<Box sx={{ mt: 2 }}>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>
{t('export.systemPrompt')}
</Typography>
<TextField
fullWidth
multiline
rows={3}
value={systemPrompt}
onChange={handleSystemPromptChange}
variant="outlined"
/>
</Box>
{/* Reasoning language only for multilingualthinking */}
{formatType === 'multilingualthinking' && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.reasoningLanguage')}
</Typography>
<TextField
fullWidth
rows={3}
multiline
variant="outlined"
placeholder={t('export.reasoningLanguage')}
value={reasoningLanguage}
onChange={handleReasoningLanguageChange}
/>
</Box>
)}
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'row', gap: 4 }}>
<FormControlLabel
control={<Checkbox checked={confirmedOnly} onChange={handleConfirmedOnlyChange} />}
label={t('export.onlyConfirmed')}
/>
<FormControlLabel
control={<Checkbox checked={includeCOT} onChange={handleIncludeCOTChange} />}
label={t('export.includeCOT')}
/>
</Box>
{configExists ? (
<>
<Alert severity="success" sx={{ mb: 2 }}>
{t('export.configExists')}
</Alert>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('export.configPath')}: {configPath.replace('dataset_info.json', '')}
</Typography>
<Tooltip title={copied ? t('common.copied') : t('common.copy')}>
<IconButton size="small" onClick={handleCopyPath} sx={{ ml: 1 }}>
{copied ? <CheckIcon fontSize="small" color="success" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Tooltip>
</Box>
</>
) : (
<Typography variant="body2" color="text.secondary" gutterBottom>
{t('export.noConfig')}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button onClick={handleGenerateConfig} variant="contained" disabled={generating} sx={{ borderRadius: 2 }}>
{generating ? (
<CircularProgress size={24} />
) : configExists ? (
t('export.updateConfig')
) : (
t('export.generateConfig')
)}
</Button>
</Box>
</Box>
);
};
export default LlamaFactoryTab;

View File

@@ -0,0 +1,777 @@
// LocalExportTab.js 组件
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
FormControl,
FormControlLabel,
RadioGroup,
Radio,
TextField,
Checkbox,
Typography,
Box,
Paper,
useTheme,
Grid,
Table,
TableRow,
TableHead,
TableBody,
TableCell,
TableContainer,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Chip,
Alert,
CircularProgress
} from '@mui/material';
const LocalExportTab = ({
fileFormat,
formatType,
systemPrompt,
confirmedOnly,
includeCOT,
customFields,
alpacaFieldType,
customInstruction,
reasoningLanguage,
handleFileFormatChange,
handleFormatChange,
handleSystemPromptChange,
handleReasoningLanguageChange,
handleConfirmedOnlyChange,
handleIncludeCOTChange,
handleCustomFieldChange,
handleIncludeLabelsChange,
handleIncludeChunkChange,
handleQuestionOnlyChange,
handleAlpacaFieldTypeChange,
handleCustomInstructionChange,
handleExport,
projectId
}) => {
const theme = useTheme();
const { t } = useTranslation();
// Balance export related state
const [balanceDialogOpen, setBalanceDialogOpen] = useState(false);
const [tagStats, setTagStats] = useState([]);
const [balanceConfig, setBalanceConfig] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [totalCount, setTotalCount] = useState(0);
// Get label statistics (changed to GET + query parameters)
const fetchTagStats = async () => {
try {
setLoading(true);
const url = `/api/projects/${projectId}/datasets/export?confirmed=${confirmedOnly ? 'true' : 'false'}`;
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
throw new Error(t('errors.getTagStatsFailed'));
}
const stats = await response.json();
setTagStats(stats);
// 初始化平衡配置
const initialConfig = stats.map(stat => ({
tagLabel: stat.tagLabel,
maxCount: Math.min(stat.datasetCount, 100), // 默认最多100条
availableCount: stat.datasetCount
}));
setBalanceConfig(initialConfig);
// 计算总数
const total = initialConfig.reduce((sum, config) => sum + config.maxCount, 0);
setTotalCount(total);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// 打开平衡导出对话框
const handleOpenBalanceDialog = () => {
setBalanceDialogOpen(true);
fetchTagStats();
};
// 更新单个标签的数量配置
const updateBalanceConfig = (tagLabel, newCount) => {
const newConfig = balanceConfig.map(config => {
if (config.tagLabel === tagLabel) {
const count = Math.min(Math.max(0, parseInt(newCount) || 0), config.availableCount);
return { ...config, maxCount: count };
}
return config;
});
setBalanceConfig(newConfig);
// 重新计算总数
const total = newConfig.reduce((sum, config) => sum + config.maxCount, 0);
setTotalCount(total);
};
// 一键设置所有标签为相同数量
const setAllToSameCount = count => {
const newConfig = balanceConfig.map(config => ({
...config,
maxCount: Math.min(Math.max(0, parseInt(count) || 0), config.availableCount)
}));
setBalanceConfig(newConfig);
const total = newConfig.reduce((sum, config) => sum + config.maxCount, 0);
setTotalCount(total);
};
// 处理平衡导出
const handleBalancedExport = () => {
// 过滤出数量大于0的配置
const validConfig = balanceConfig.filter(config => config.maxCount > 0);
if (validConfig.length === 0) {
setError(t('export.balancedExport.atLeastOneTag', '请至少为一个标签设置大于0的数量'));
return;
}
// 调用原有的导出函数,但传递平衡配置
handleExport({
balanceMode: true,
balanceConfig: validConfig,
formatType,
systemPrompt,
reasoningLanguage,
confirmedOnly,
fileFormat,
includeCOT,
alpacaFieldType,
customInstruction,
customFields: formatType === 'custom' ? customFields : undefined
});
setBalanceDialogOpen(false);
};
// 自定义格式的示例
const getCustomFormatExample = () => {
const { questionField, answerField, cotField, includeLabels, includeChunk } = customFields;
const example = {
[questionField]: t('sampleData.questionContent'),
[answerField]: t('sampleData.answerContent')
};
// 如果包含思维链字段,添加到示例中
if (includeCOT) {
example[cotField] = t('sampleData.cotContent');
}
if (includeLabels) {
example.labels = [t('sampleData.domainLabel')];
}
if (includeChunk) {
example.chunk = t('sampleData.textChunk');
}
return fileFormat === 'json' ? JSON.stringify([example], null, 2) : JSON.stringify(example);
};
// CSV 自定义格式化示例
const getPreviewData = () => {
if (formatType === 'alpaca') {
// 根据选择的字段类型生成不同的示例
if (alpacaFieldType === 'instruction') {
return {
headers: ['instruction', 'input', 'output', 'system'],
rows: [
{
instruction: t('export.sampleInstruction', '人类指令(必填)'),
input: '',
output: t('export.sampleOutput', '模型回答(必填)'),
system: t('export.sampleSystem', '系统提示词(选填)')
},
{
instruction: t('export.sampleInstruction2', '第二个指令'),
input: '',
output: t('export.sampleOutput2', '第二个回答'),
system: t('export.sampleSystemShort', '系统提示词')
}
]
};
} else {
// input
return {
headers: ['instruction', 'input', 'output', 'system'],
rows: [
{
instruction: customInstruction || t('export.fixedInstruction', '固定的指令内容'),
input: t('export.sampleInput', '人类问题(必填)'),
output: t('export.sampleOutput', '模型回答(必填)'),
system: t('export.sampleSystem', '系统提示词(选填)')
},
{
instruction: customInstruction || t('export.fixedInstruction', '固定的指令内容'),
input: t('export.sampleInput2', '第二个问题'),
output: t('export.sampleOutput2', '第二个回答'),
system: t('export.sampleSystemShort', '系统提示词')
}
]
};
}
} else if (formatType === 'sharegpt') {
return {
headers: ['messages'],
rows: [
{
messages: JSON.stringify(
[
{
messages: [
{
role: 'system',
content: t('export.sampleSystem', '系统提示词(选填)')
},
{
role: 'user',
content: t('export.sampleUserMessage', '人类指令') // 映射到 question 字段
},
{
role: 'assistant',
content: t('export.sampleAssistantMessage', '模型回答') // 映射到 cot+answer 字段
}
]
}
],
null,
2
)
}
]
};
} else if (formatType === 'multilingualthinking') {
return {
headers: 'messages',
rows: {
messages: JSON.stringify(
{
reasoning_language: 'English',
developer: t('export.sampleSystem', '系统提示词(选填)'),
user: t('export.sampleUserMessage', '人类指令'), // 映射到 question 字段
analysis: t('export.sampleAnalysis', '模型的思维链内容'), // 映射到 cot 字段
final: t('export.sampleFinal', '模型回答'), // 映射到 answer 字段
messages: [
{
role: 'system',
content: '系统提示词(选填)',
thinking: 'null'
},
{
role: 'user',
content: '人类指令', // 映射到 question 字段
thinking: 'null'
},
{
role: 'assistant',
content: '模型回答', // 映射到 answer 字段
thinking: '模型的思维链内容' // 映射到 cot 字段
}
]
},
null,
2
)
}
};
} else if (formatType === 'custom') {
// 如果选择仅导出问题,只包含问题字段
if (customFields.questionOnly) {
const headers = [customFields.questionField];
if (customFields.includeLabels) headers.push('labels');
if (customFields.includeChunk) headers.push('chunk');
const row = {
[customFields.questionField]: t('sampleData.questionContent')
};
if (customFields.includeLabels) row.labels = t('sampleData.domainLabel');
if (customFields.includeChunk) row.chunk = t('sampleData.textChunk');
return {
headers,
rows: [row]
};
} else {
// 正常的自定义格式
const headers = [customFields.questionField, customFields.answerField];
if (includeCOT) headers.push(customFields.cotField);
if (customFields.includeLabels) headers.push('labels');
if (customFields.includeChunk) headers.push('chunk');
const row = {
[customFields.questionField]: t('sampleData.questionContent'),
[customFields.answerField]: t('sampleData.answerContent')
};
if (includeCOT) row[customFields.cotField] = t('sampleData.cotContent');
if (customFields.includeLabels) row.labels = t('sampleData.domainLabel');
if (customFields.includeChunk) row.chunk = t('sampleData.textChunk');
return {
headers,
rows: [row]
};
}
}
};
return (
<>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.fileFormat')}
</Typography>
<FormControl component="fieldset">
<RadioGroup
aria-label="fileFormat"
name="fileFormat"
value={fileFormat}
onChange={handleFileFormatChange}
row
>
<FormControlLabel value="json" control={<Radio />} label="JSON" />
<FormControlLabel value="jsonl" control={<Radio />} label="JSONL" />
{/* <FormControlLabel value="csv" control={<Radio />} label="CSV" /> */}
<FormControlLabel
value="csv"
control={<Radio disabled={formatType === 'multilingualthinking'} />}
label="CSV"
/>
</RadioGroup>
</FormControl>
</Box>
{/* 数据集风格 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.format')}
</Typography>
<FormControl component="fieldset">
<RadioGroup aria-label="format" name="format" value={formatType} onChange={handleFormatChange} row>
<FormControlLabel value="alpaca" control={<Radio />} label="Alpaca" />
<FormControlLabel value="sharegpt" control={<Radio />} label="ShareGPT" />
{/* NEW: MultilingualThinking format */}
<FormControlLabel
value="multilingualthinking"
control={<Radio disabled={fileFormat === 'csv'} />}
label={t('export.multilingualThinkingFormat') || 'MultilingualThinking'}
/>
<FormControlLabel value="custom" control={<Radio />} label={t('export.customFormat')} />
</RadioGroup>
</FormControl>
</Box>
{/* Alpaca 格式特有的设置 */}
{formatType === 'alpaca' && (
<Box sx={{ mb: 3, pl: 2, borderLeft: `1px solid ${theme.palette.divider}` }}>
<Typography variant="subtitle2" gutterBottom>
{t('export.alpacaSettings', 'Alpaca 格式设置')}
</Typography>
<FormControl component="fieldset">
<Typography variant="body2" color="text.secondary" gutterBottom>
{t('export.questionFieldType', '问题字段类型')}
</Typography>
<RadioGroup
aria-label="alpacaFieldType"
name="alpacaFieldType"
value={alpacaFieldType}
onChange={handleAlpacaFieldTypeChange}
row
>
<FormControlLabel
value="instruction"
control={<Radio />}
label={t('export.useInstruction', '使用 instruction 字段')}
/>
<FormControlLabel value="input" control={<Radio />} label={t('export.useInput', '使用 input 字段')} />
</RadioGroup>
{alpacaFieldType === 'input' && (
<TextField
fullWidth
size="small"
label={t('export.customInstruction', '自定义 instruction 字段内容')}
value={customInstruction}
onChange={handleCustomInstructionChange}
margin="normal"
placeholder={t('export.instructionPlaceholder', '请输入固定的指令内容')}
helperText={t(
'export.instructionHelperText',
'当使用 input 字段时,可以在这里指定固定的 instruction 内容'
)}
/>
)}
</FormControl>
</Box>
)}
{/* 自定义格式选项 */}
{formatType === 'custom' && (
<Box sx={{ mb: 3, pl: 2, borderLeft: `1px solid ${theme.palette.divider}` }}>
<Typography variant="subtitle2" gutterBottom>
{t('export.customFormatSettings')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
size="small"
label={t('export.questionFieldName')}
value={customFields.questionField}
onChange={handleCustomFieldChange('questionField')}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
size="small"
label={t('export.answerFieldName')}
value={customFields.answerField}
onChange={handleCustomFieldChange('answerField')}
margin="normal"
/>
</Grid>
{/* 添加思维链字段名输入框 */}
<Grid item xs={12} sm={6}>
<TextField
fullWidth
size="small"
label={t('export.cotFieldName')}
value={customFields.cotField}
onChange={handleCustomFieldChange('cotField')}
margin="normal"
/>
</Grid>
</Grid>
<FormControlLabel
control={
<Checkbox checked={customFields.includeLabels} onChange={handleIncludeLabelsChange} size="small" />
}
label={t('export.includeLabels')}
/>
<FormControlLabel
control={<Checkbox checked={customFields.includeChunk} onChange={handleIncludeChunkChange} size="small" />}
label={t('export.includeChunk')}
/>
<FormControlLabel
control={<Checkbox checked={customFields.questionOnly} onChange={handleQuestionOnlyChange} size="small" />}
label={t('export.questionOnly')}
/>
</Box>
)}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.example')}
</Typography>
{fileFormat === 'csv' ? (
<TableContainer component={Paper} sx={{ mb: 2 }}>
{(() => {
const { headers, rows } = getPreviewData();
const tableKey = `${formatType}-${fileFormat}-${JSON.stringify(customFields)}`;
return (
<Table size="small" key={tableKey}>
<TableHead>
<TableRow>
{headers.map(header => (
<TableCell key={header}>{header}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableRow key={index}>
{headers.map(header => (
<TableCell key={header}>
{Array.isArray(row[header]) ? row[header].join(', ') : row[header] || ''}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
})()}
</TableContainer>
) : (
<Paper
variant="outlined"
sx={{
p: 2,
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[900] : theme.palette.grey[100],
overflowX: 'auto'
}}
>
<pre style={{ margin: 0 }}>
{formatType === 'custom'
? getCustomFormatExample()
: formatType === 'multilingualthinking'
? fileFormat === 'json'
? JSON.stringify(
{
reasoning_language: 'English',
developer: '系统提示词(选填)',
user: '人类指令', // 映射到 question 字段
analysis: '模型的思维链内容', // 映射到 cot 字段
final: '模型回答', // 映射到 answer 字段
messages: [
{
content: t('export.sampleSystem', '系统提示词(选填)'),
role: 'system',
thinking: null
},
{
content: t('export.sampleUserMessage', '人类指令'),
role: 'user',
thinking: null
},
{
content: t('export.sampleAssistantMessage', '模型回答'),
role: 'assistant',
thinking: t('export.sampleThinking', '模型的思维链内容')
}
]
},
null,
2
)
: '{"reasoning_language": "English","developer": "系统提示词(选填)", "user": "人类指令", "analysis": "模型的思维链内容", "final": "模型回答", "messages": [{"role": "user", "content": "人类指令", "thinking": "null"}, {"role": "assistant", "content": "模型回答", "thinking": "模型的思维链内容"}]}'
: formatType === 'alpaca'
? fileFormat === 'json'
? JSON.stringify(
[
{
instruction: t('export.sampleInstruction', '人类指令(必填)'), // 映射到 question 字段
input: t('export.sampleInputOptional', '人类输入(选填)'),
output: t('export.sampleOutput', '模型回答(必填)'), // 映射到 cot+answer 字段
system: t('export.sampleSystem', '系统提示词(选填)')
}
],
null,
2
)
: '{"instruction": "人类指令(必填)", "input": "人类输入(选填)", "output": "模型回答(必填)", "system": "系统提示词(选填)"}\n{"instruction": "第二个指令", "input": "", "output": "第二个回答", "system": "系统提示词"}'
: fileFormat === 'json'
? JSON.stringify(
[
{
messages: [
{
role: 'system',
content: t('export.sampleSystem', '系统提示词(选填)')
},
{
role: 'user',
content: t('export.sampleUserMessage', '人类指令') // 映射到 question 字段
},
{
role: 'assistant',
content: t('export.sampleAssistantMessage', '模型回答') // 映射到 cot+answer 字段
}
]
}
],
null,
2
)
: '{"messages": [{"role": "system", "content": "系统提示词(选填)"}, {"role": "user", "content": "人类指令"}, {"role": "assistant", "content": "模型回答"}]}\n{"messages": [{"role": "user", "content": "第二个问题"}, {"role": "assistant", "content": "第二个回答"}]}'}
</pre>
</Paper>
)}
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.systemPrompt')}
</Typography>
<TextField
fullWidth
multiline
rows={3}
variant="outlined"
placeholder={t('export.systemPromptPlaceholder')}
value={systemPrompt}
onChange={handleSystemPromptChange}
/>
</Box>
{/* Reasoning language only for multilingualthinking */}
{formatType === 'multilingualthinking' && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{t('export.Reasoninglanguage')}
</Typography>
<TextField
fullWidth
rows={3}
multiline
variant="outlined"
placeholder={t('export.ReasoninglanguagePlaceholder')}
value={reasoningLanguage}
onChange={handleReasoningLanguageChange}
/>
</Box>
)}
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'row', gap: 4 }}>
<FormControlLabel
control={<Checkbox checked={confirmedOnly} onChange={handleConfirmedOnlyChange} />}
label={t('export.onlyConfirmed')}
/>
<FormControlLabel
control={<Checkbox checked={includeCOT} onChange={handleIncludeCOTChange} />}
label={t('export.includeCOT')}
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 2 }}>
<Button onClick={handleOpenBalanceDialog} variant="outlined" sx={{ borderRadius: 2 }}>
{t('exportDialog.balancedExport')}
</Button>
<Button onClick={handleExport} variant="contained" sx={{ borderRadius: 2 }}>
{t('export.confirmExport')}
</Button>
</Box>
{/* 平衡导出对话框 */}
<Dialog
open={balanceDialogOpen}
onClose={() => setBalanceDialogOpen(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 2
}
}}
>
<DialogTitle>{t('exportDialog.balancedExportTitle')}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mb: 3 }}>
{t('exportDialog.balancedExportDescription')}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
) : (
<>
{/* 批量设置 */}
<Box sx={{ mb: 3, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
{t('exportDialog.quickSettings')}
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button size="small" onClick={() => setAllToSameCount(50)}>
{t('exportDialog.setAllTo50')}
</Button>
<Button size="small" onClick={() => setAllToSameCount(100)}>
{t('exportDialog.setAllTo100')}
</Button>
<Button size="small" onClick={() => setAllToSameCount(200)}>
{t('exportDialog.setAllTo200')}
</Button>
<TextField
size="small"
type="number"
placeholder={t('exportDialog.customAmount')}
sx={{ width: 120 }}
onKeyPress={e => {
if (e.key === 'Enter') {
setAllToSameCount(e.target.value);
e.target.value = '';
}
}}
/>
</Box>
</Box>
{/* 标签配置表格 */}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t('exportDialog.tagName')}</TableCell>
<TableCell align="right">{t('exportDialog.availableCount')}</TableCell>
<TableCell align="right">{t('exportDialog.exportCount')}</TableCell>
<TableCell align="right">{t('exportDialog.settings')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{balanceConfig.map(config => (
<TableRow key={config.tagLabel}>
<TableCell>
<Chip label={config.tagLabel} size="small" variant="outlined" />
</TableCell>
<TableCell align="right">{config.availableCount}</TableCell>
<TableCell align="right">
<strong>{config.maxCount}</strong>
</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
value={config.maxCount}
onChange={e => updateBalanceConfig(config.tagLabel, e.target.value)}
inputProps={{
min: 0,
max: config.availableCount,
style: { textAlign: 'right' }
}}
sx={{ width: 80 }}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* 统计信息 */}
<Box sx={{ mt: 2, p: 2, bgcolor: 'primary.50', borderRadius: 1 }}>
<Typography variant="body2">
<strong>
{t('exportDialog.totalExportCount')}: {totalCount}
</strong>{' '}
| {t('exportDialog.tagCount')}: {balanceConfig.filter(c => c.maxCount > 0).length} /{' '}
{balanceConfig.length}
</Typography>
</Box>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setBalanceDialogOpen(false)}>{t('common.cancel', '取消')}</Button>
<Button variant="contained" onClick={handleBalancedExport} disabled={loading || totalCount === 0}>
{t('exportDialog.export')} ({totalCount})
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default LocalExportTab;

View File

@@ -0,0 +1,173 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
useTheme,
CircularProgress,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
export default function CreateProjectDialog({ open, onClose }) {
const { t } = useTranslation();
const theme = useTheme();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState([]);
const [formData, setFormData] = useState({
name: '',
description: '',
reuseConfigFrom: ''
});
const [error, setError] = useState(null);
// 获取项目列表
useEffect(() => {
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects');
if (response.ok) {
const data = await response.json();
setProjects(data);
}
} catch (error) {
console.error('获取项目列表失败:', error);
}
};
fetchProjects();
}, []);
const handleChange = e => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async e => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (!response.ok) {
throw new Error(t('projects.createFailed'));
}
const data = await response.json();
router.push(`/projects/${data.id}/settings?tab=model`);
} catch (err) {
console.error(t('projects.createError'), err);
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: '16px',
background: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.9)' : 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(8px)'
}
}}
>
<DialogTitle>
<Typography variant="h5" fontWeight="bold">
{t('projects.createNew')}
</Typography>
</DialogTitle>
<form onSubmit={handleSubmit}>
<DialogContent>
<Box sx={{ mb: 3 }}>
<TextField
name="name"
label={t('projects.name')}
fullWidth
required
value={formData.name}
onChange={handleChange}
sx={{ mb: 2 }}
/>
<TextField
name="description"
label={t('projects.description')}
fullWidth
multiline
rows={4}
value={formData.description}
onChange={handleChange}
/>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel id="reuse-config-label">{t('projects.reuseConfig')}</InputLabel>
<Select
labelId="reuse-config-label"
name="reuseConfigFrom"
value={formData.reuseConfigFrom}
onChange={handleChange}
label={t('projects.reuseConfig')}
>
<MenuItem value="">{t('projects.noReuse')}</MenuItem>
{projects.map(project => (
<MenuItem key={project.id} value={project.id}>
{project.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{error && (
<Typography color="error" variant="body2" sx={{ mb: 2 }}>
{error}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
type="submit"
variant="contained"
disabled={loading || !formData.name}
sx={{
background: theme.palette.gradient.primary,
'&:hover': {
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)'
}
}}
>
{loading ? <CircularProgress size={24} /> : t('home.createProject')}
</Button>
</DialogActions>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { Box, Container, Typography, Button, useMediaQuery } from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import SearchIcon from '@mui/icons-material/Search';
import { styles } from '@/styles/home';
import { useTheme } from '@mui/material';
import { motion } from 'framer-motion';
import ParticleBackground from './ParticleBackground';
import { useTranslation } from 'react-i18next';
export default function HeroSection({ onCreateProject }) {
const { t } = useTranslation();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
return (
<Box sx={{ ...styles.heroSection, ...styles.heroBackground(theme) }}>
{/* 添加粒子背景 */}
<ParticleBackground />
<Box sx={styles.decorativeCircle} />
<Box sx={styles.decorativeCircleSecond} />
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 1 }}>
<Box
sx={{
textAlign: 'center',
maxWidth: '800px',
mx: 'auto',
py: { xs: 5, md: 8 }
}}
component={motion.div}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<Typography
variant={isMobile ? 'h3' : 'h1'}
component="h1"
fontWeight="bold"
sx={{
...styles.gradientTitle(theme),
letterSpacing: '-1px',
mb: 3,
textShadow: theme.palette.mode === 'dark' ? '0 0 30px rgba(139, 92, 246, 0.3)' : 'none'
}}
>
{t('home.title')}
</Typography>
<Typography
variant={isMobile ? 'body1' : 'h5'}
component={motion.p}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.8 }}
color="text.secondary"
paragraph
sx={{
maxWidth: '650px',
mx: 'auto',
lineHeight: 1.8,
opacity: 0.9,
fontSize: { xs: '1rem', md: '1.2rem' },
fontWeight: 400,
mb: 4
}}
>
{t('home.subtitle')}
</Typography>
<Box
component={motion.div}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
sx={{
mt: 6,
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'center',
gap: { xs: 2, sm: 3 }
}}
>
<Button
variant="contained"
size="large"
onClick={onCreateProject}
startIcon={<AddCircleOutlineIcon />}
sx={{
...styles.createButton(theme),
fontWeight: 600,
transition: 'all 0.3s ease',
transform: 'translateY(0)',
px: 4,
py: 1.5,
borderRadius: '12px',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)'
}
}}
>
{t('home.createProject')}
</Button>
<Button
variant="contained"
size="large"
onClick={() => {
window.location.href = '/dataset-square';
}}
startIcon={<SearchIcon />}
sx={{
...styles.createButton(theme),
fontWeight: 600,
transition: 'all 0.3s ease',
transform: 'translateY(0)',
px: 4,
py: 1.5,
borderRadius: '12px',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)'
}
}}
>
{t('home.searchDataset')}
</Button>
</Box>
</Box>
</Container>
</Box>
);
}

View File

@@ -0,0 +1,300 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
CircularProgress,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Alert,
Paper,
useTheme,
Tooltip
} from '@mui/material';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import DeleteIcon from '@mui/icons-material/Delete';
import { useTranslation } from 'react-i18next';
/**
* 项目迁移对话框组件
* @param {Object} props - 组件属性
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调函数
* @param {Array<string>} props.projectIds - 需要迁移的项目ID列表
*/
export default function MigrationDialog({ open, onClose, projectIds = [] }) {
const { t } = useTranslation();
const theme = useTheme();
const [migrating, setMigrating] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(null);
const [migratedCount, setMigratedCount] = useState(0);
const [taskId, setTaskId] = useState(null);
const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState('');
const [processingIds, setProcessingIds] = useState([]);
// 打开项目目录
const handleOpenDirectory = async projectId => {
try {
setProcessingIds(prev => [...prev, projectId]);
const response = await fetch('/api/projects/open-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ projectId })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || t('migration.openDirectoryFailed'));
}
// 成功打开目录,不需要特别处理
} catch (err) {
console.error('打开目录错误:', err);
setError(err.message);
} finally {
setProcessingIds(prev => prev.filter(id => id !== projectId));
}
};
// 删除项目目录
const handleDeleteDirectory = async projectId => {
try {
if (!window.confirm(t('migration.confirmDelete'))) {
return;
}
setProcessingIds(prev => [...prev, projectId]);
const response = await fetch('/api/projects/delete-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ projectId })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || t('migration.deleteDirectoryFailed'));
}
// 从列表中移除已删除的项目
const updatedProjectIds = projectIds.filter(id => id !== projectId);
// 这里我们不能直接修改 projectIds因为它是从父组件传入的
// 但我们可以通知用户界面刷新
window.location.reload();
} catch (err) {
console.error('删除目录错误:', err);
setError(err.message);
} finally {
setProcessingIds(prev => prev.filter(id => id !== projectId));
}
};
// 处理迁移操作
const handleMigration = async () => {
try {
setMigrating(true);
setError(null);
setSuccess(false);
setProgress(0);
setStatusText(t('migration.starting'));
// 调用异步迁移接口启动迁移任务
const response = await fetch('/api/projects/migrate', {
method: 'POST'
});
if (!response.ok) {
throw new Error(t('migration.failed'));
}
const { success, taskId: newTaskId } = await response.json();
if (!success || !newTaskId) {
throw new Error(t('migration.startFailed'));
}
// 保存任务ID
setTaskId(newTaskId);
setStatusText(t('migration.processing'));
// 开始轮询任务状态
await pollMigrationStatus(newTaskId);
} catch (err) {
console.error('迁移错误:', err);
setError(err.message);
setMigrating(false);
}
};
// 轮询迁移任务状态
const pollMigrationStatus = async id => {
try {
// 定义轮询间隔(毫秒)
const pollInterval = 1000;
// 发送请求获取任务状态
const response = await fetch(`/api/projects/migrate?taskId=${id}`);
if (!response.ok) {
throw new Error(t('migration.statusFailed'));
}
const { success, task } = await response.json();
if (!success || !task) {
throw new Error(t('migration.taskNotFound'));
}
// 更新进度
setProgress(task.progress || 0);
// 根据任务状态更新UI
if (task.status === 'completed') {
// 任务完成
setMigratedCount(task.completed);
setSuccess(true);
setMigrating(false);
setStatusText(t('migration.completed'));
// 迁移成功后,延迟关闭对话框并刷新页面
setTimeout(() => {
onClose();
window.location.reload();
}, 2000);
} else if (task.status === 'failed') {
// 任务失败
throw new Error(task.error || t('migration.failed'));
} else {
// 任务仍在进行中,继续轮询
setTimeout(() => pollMigrationStatus(id), pollInterval);
// 更新状态文本
if (task.total > 0) {
setStatusText(
t('migration.progressStatus', {
completed: task.completed || 0,
total: task.total
})
);
}
}
} catch (err) {
console.error('获取迁移状态错误:', err);
setError(err.message);
setMigrating(false);
}
};
return (
<Dialog open={open} onClose={migrating ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5
}}
>
<WarningAmberIcon color="warning" />
<Typography variant="h6">{t('migration.title')}</Typography>
</DialogTitle>
<DialogContent>
{success ? (
<Alert severity="success" sx={{ mb: 2 }}>
{t('migration.success', { count: migratedCount })}
</Alert>
) : error ? (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
) : null}
<Typography variant="body1" sx={{ mb: 2 }}>
{t('migration.description')}
</Typography>
{projectIds.length > 0 && (
<Box sx={{ mt: 2, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
{t('migration.projectsList')}:
</Typography>
<Paper variant="outlined" sx={{ maxHeight: 180, overflow: 'auto' }}>
<List dense>
{projectIds.map(id => (
<ListItem key={id}>
<ListItemText primary={id} />
<ListItemSecondaryAction>
<Tooltip title={t('migration.openDirectory')}>
<IconButton
edge="end"
aria-label="open"
onClick={() => handleOpenDirectory(id)}
disabled={processingIds.includes(id)}
size="small"
>
<FolderOpenIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('migration.deleteDirectory')}>
<IconButton
edge="end"
aria-label="delete"
onClick={() => handleDeleteDirectory(id)}
disabled={processingIds.includes(id)}
size="small"
sx={{ ml: 1, color: 'error.main' }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</Paper>
</Box>
)}
{migrating && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 3, gap: 1.5 }}>
<CircularProgress variant={progress > 0 ? 'determinate' : 'indeterminate'} value={progress} />
<Typography variant="body2" color="text.secondary">
{statusText || t('migration.migrating')}
</Typography>
{progress > 0 && (
<Typography variant="body2" color="text.secondary">
{progress}%
</Typography>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={migrating}>
{t('common.cancel')}
</Button>
<Button onClick={handleMigration} variant="contained" color="primary" disabled={migrating || success}>
{migrating ? t('migration.migrating') : t('migration.migrate')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,251 @@
'use client';
import { useEffect, useRef } from 'react';
import { useTheme } from '@mui/material';
export default function ParticleBackground() {
const canvasRef = useRef(null);
const theme = useTheme();
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
let animationFrameId;
let particles = [];
let mousePosition = { x: 0, y: 0 };
let hoverRadius = 150; // 增加鼠标影响范围
let mouseSpeed = { x: 0, y: 0 }; // 跟踪鼠标速度
let lastMousePosition = { x: 0, y: 0 }; // 上一帧鼠标位置
// 设置画布大小为窗口大小
const handleResize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
initParticles();
};
// 跟踪鼠标位置和速度
const handleMouseMove = event => {
// 计算鼠标速度
mouseSpeed.x = event.clientX - mousePosition.x;
mouseSpeed.y = event.clientY - mousePosition.y;
// 更新鼠标位置
lastMousePosition.x = mousePosition.x;
lastMousePosition.y = mousePosition.y;
mousePosition.x = event.clientX;
mousePosition.y = event.clientY;
};
// 触摸设备支持
const handleTouchMove = event => {
if (event.touches.length > 0) {
// 计算触摸速度
mouseSpeed.x = event.touches[0].clientX - mousePosition.x;
mouseSpeed.y = event.touches[0].clientY - mousePosition.y;
// 更新触摸位置
lastMousePosition.x = mousePosition.x;
lastMousePosition.y = mousePosition.y;
mousePosition.x = event.touches[0].clientX;
mousePosition.y = event.touches[0].clientY;
}
};
// 生成随机颜色
const getRandomColor = () => {
// 主题色调
const colors =
theme.palette.mode === 'dark'
? [
'rgba(255, 255, 255, 0.5)', // 白色
'rgba(100, 181, 246, 0.5)', // 蓝色
'rgba(156, 39, 176, 0.4)', // 紫色
'rgba(121, 134, 203, 0.5)' // 靛蓝色
]
: [
'rgba(42, 92, 170, 0.5)', // 主蓝色
'rgba(66, 165, 245, 0.4)', // 浅蓝色
'rgba(94, 53, 177, 0.3)', // 深紫色
'rgba(3, 169, 244, 0.4)' // 天蓝色
];
return colors[Math.floor(Math.random() * colors.length)];
};
// 初始化粒子
const initParticles = () => {
particles = [];
// 增加粒子数量,但保持性能平衡
const particleCount = Math.min(Math.floor(window.innerWidth / 8), 150);
for (let i = 0; i < particleCount; i++) {
// 创建不同大小和速度的粒子
const size = Math.random();
const speedFactor = Math.max(0.1, size); // 较大的粒子移动较慢
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
// 粒子大小更加多样化
radius: size * 3 + 0.5,
// 使用随机颜色
color: getRandomColor(),
// 添加发光效果
glow: Math.random() * 10 + 5,
// 调整速度范围,使运动更加自然
speedX: (Math.random() * 0.6 - 0.3) * speedFactor,
speedY: (Math.random() * 0.6 - 0.3) * speedFactor,
originalSpeedX: (Math.random() * 0.6 - 0.3) * speedFactor,
originalSpeedY: (Math.random() * 0.6 - 0.3) * speedFactor,
// 添加脉动效果
pulseSpeed: Math.random() * 0.02 + 0.01,
pulseDirection: Math.random() > 0.5 ? 1 : -1,
pulseAmount: 0,
// 粒子透明度
opacity: Math.random() * 0.5 + 0.5
});
}
};
// 绘制粒子
const drawParticles = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 计算鼠标速度衰减
mouseSpeed.x *= 0.95;
mouseSpeed.y *= 0.95;
// 绘制粒子之间的连线
drawLines();
particles.forEach(particle => {
// 计算粒子与鼠标的距离
const dx = mousePosition.x - particle.x;
const dy = mousePosition.y - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 脉动效果
particle.pulseAmount += particle.pulseSpeed * particle.pulseDirection;
if (Math.abs(particle.pulseAmount) > 0.5) {
particle.pulseDirection *= -1;
}
// 如果粒子在鼠标影响范围内,调整其速度
if (distance < hoverRadius) {
const angle = Math.atan2(dy, dx);
const force = (hoverRadius - distance) / hoverRadius;
const mouseFactor = 3; // 增强鼠标影响力度
// 粒子远离鼠标,并受鼠标速度影响
particle.speedX = -Math.cos(angle) * force * mouseFactor + particle.originalSpeedX + mouseSpeed.x * 0.05;
particle.speedY = -Math.sin(angle) * force * mouseFactor + particle.originalSpeedY + mouseSpeed.y * 0.05;
} else {
// 逐渐恢复原始速度
particle.speedX = particle.speedX * 0.95 + particle.originalSpeedX * 0.05;
particle.speedY = particle.speedY * 0.95 + particle.originalSpeedY * 0.05;
}
// 更新粒子位置
particle.x += particle.speedX;
particle.y += particle.speedY;
// 边界检查
if (particle.x < 0) particle.x = canvas.width;
if (particle.x > canvas.width) particle.x = 0;
if (particle.y < 0) particle.y = canvas.height;
if (particle.y > canvas.height) particle.y = 0;
// 应用脉动效果到粒子大小
const currentRadius = particle.radius * (1 + particle.pulseAmount * 0.2);
// 绘制发光效果
const gradient = ctx.createRadialGradient(particle.x, particle.y, 0, particle.x, particle.y, particle.glow);
gradient.addColorStop(0, particle.color);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
// 绘制粒子
ctx.beginPath();
ctx.arc(particle.x, particle.y, currentRadius, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.fill();
// 添加发光效果
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.glow, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.globalAlpha = 0.3 * particle.opacity;
ctx.fill();
ctx.globalAlpha = 1.0;
});
animationFrameId = requestAnimationFrame(drawParticles);
};
// 绘制粒子之间的连线
const drawLines = () => {
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 增加连线的最大距离
const maxDistance = 120;
if (distance < maxDistance) {
// 只在粒子距离小于maxDistance时绘制连线
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
// 根据距离设置线条透明度
const opacity = 1 - distance / maxDistance;
// 根据主题设置线条颜色
const lineColor =
theme.palette.mode === 'dark'
? `rgba(255, 255, 255, ${opacity * 0.2})`
: `rgba(42, 92, 170, ${opacity * 0.2})`;
ctx.strokeStyle = lineColor;
ctx.lineWidth = opacity * 1.5; // 根据距离调整线宽
ctx.stroke();
}
}
}
};
// 初始化
handleResize();
window.addEventListener('resize', handleResize);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchmove', handleTouchMove);
// 开始动画
drawParticles();
// 清理函数
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
cancelAnimationFrame(animationFrameId);
};
}, [theme.palette.mode]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none', // 确保不会干扰下方元素的交互
zIndex: 0
}}
/>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import {
Card,
Box,
CardActionArea,
CardContent,
Typography,
Avatar,
Divider,
IconButton,
Menu,
MenuItem,
ListItemIcon
} from '@mui/material';
import Link from 'next/link';
import { styles } from '@/styles/home';
import { useTheme, alpha } from '@mui/material/styles';
import DataObjectIcon from '@mui/icons-material/DataObject';
import DeleteIcon from '@mui/icons-material/Delete';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import TokenIcon from '@mui/icons-material/Token';
import AssessmentIcon from '@mui/icons-material/Assessment';
import QuizIcon from '@mui/icons-material/Quiz';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
/**
* 统计项组件
*/
const StatItem = ({ icon: Icon, count, label, color, isToken }) => {
const theme = useTheme();
// 格式化数字
const displayCount = isToken ? (count || 0).toLocaleString() : count || 0;
return (
<Box sx={styles.statItem(theme)}>
<Box sx={styles.statIconBox(theme, color)}>
<Icon sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Typography variant="subtitle2" fontWeight="700" sx={{ lineHeight: 1 }}>
{displayCount}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ fontSize: '0.7rem' }}>
{label}
</Typography>
</Box>
</Box>
);
};
/**
* 项目卡片组件
* @param {Object} props - 组件属性
* @param {Object} props.project - 项目数据
* @param {Function} props.onDeleteClick - 删除按钮点击事件处理函数
*/
export default function ProjectCard({ project, onDeleteClick }) {
const { t } = useTranslation();
const theme = useTheme();
const [processingId, setProcessingId] = useState(false);
// 菜单状态
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
// 打开项目目录
const handleOpenDirectory = async event => {
event.stopPropagation();
event.preventDefault();
if (processingId) return;
try {
setProcessingId(true);
const response = await fetch('/api/projects/open-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ projectId: project.id })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || t('migration.openDirectoryFailed'));
}
// 成功打开目录,不需要特别处理
} catch (error) {
console.error('打开目录错误:', error);
alert(error.message);
} finally {
setProcessingId(false);
}
};
// 处理菜单打开
const handleMenuClick = event => {
event.stopPropagation();
event.preventDefault();
setAnchorEl(event.currentTarget);
};
// 处理菜单关闭
const handleMenuClose = event => {
if (event) {
event.stopPropagation();
event.preventDefault();
}
setAnchorEl(null);
};
// 处理打开目录点击
const handleOpenDirectoryClick = event => {
handleMenuClose(event);
handleOpenDirectory(event);
};
// 处理删除点击
const handleDeleteClick = event => {
handleMenuClose(event);
onDeleteClick(event, project);
};
return (
<Card sx={styles.projectCard(theme)}>
<Link
href={`/projects/${project.id}`}
passHref
style={{ textDecoration: 'none', color: 'inherit', height: '100%' }}
>
<CardActionArea component="div" sx={{ height: '100%' }}>
<CardContent sx={styles.projectCardContent}>
{/* 头部Avatar + Title + Menu */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', overflow: 'hidden', flex: 1 }}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
width: 40,
height: 40,
fontSize: '1.1rem',
fontWeight: 'bold',
borderRadius: '10px'
}}
>
{project.name.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ overflow: 'hidden', flex: 1 }}>
<Typography variant="h6" sx={styles.projectTitle}>
{project.name}
</Typography>
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.7rem' }}>
ID: {project.id}
</Typography>
</Box>
</Box>
<IconButton
size="small"
onClick={handleMenuClick}
sx={{
color: 'text.secondary',
padding: '4px',
'&:hover': { color: 'primary.main', bgcolor: alpha(theme.palette.primary.main, 0.1) }
}}
>
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
{/* 描述 */}
<Typography variant="body2" sx={styles.projectDescription}>
{project.description || t('projects.noDescription', { defaultValue: '暂无描述' })}
</Typography>
{/* 统计数据 */}
<Box sx={styles.statsContainer}>
<StatItem
icon={QuizIcon}
count={project._count.Questions}
label={t('projects.questions')}
color="primary"
/>
<StatItem
icon={DataObjectIcon}
count={(project._count.ImageDatasets || 0) + (project._count.Datasets || 0)}
label={t('projects.datasets')}
color="secondary"
/>
<StatItem
icon={AssessmentIcon}
count={project._count.EvalDatasets}
label={t('projects.evalDatasets')}
color="info"
/>
<StatItem
icon={TokenIcon}
count={project.totalTokens}
label={t('projects.tokens')}
color="success"
isToken
/>
</Box>
</CardContent>
</CardActionArea>
</Link>
{/* 操作菜单 */}
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleMenuClose}
onClick={e => {
e.preventDefault();
e.stopPropagation();
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{
elevation: 3,
sx: {
borderRadius: '12px',
minWidth: 160,
mt: 0.5
}
}}
>
<MenuItem onClick={handleOpenDirectoryClick}>
<ListItemIcon>
<FolderOpenIcon fontSize="small" />
</ListItemIcon>
<Typography variant="body2">{t('projects.openDirectory')}</Typography>
</MenuItem>
<Divider sx={{ my: 0.5, opacity: 0.5 }} />
<MenuItem onClick={handleDeleteClick} sx={{ color: 'error.main' }}>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<Typography variant="body2">{t('common.delete')}</Typography>
</MenuItem>
</Menu>
</Card>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import {
Grid,
Paper,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Typography
} from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import ProjectCard from './ProjectCard';
export default function ProjectList({ projects, onCreateProject }) {
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState(null);
const [loading, setLoading] = useState(false);
// 打开删除确认对话框
const handleOpenDeleteDialog = (event, project) => {
setProjectToDelete(project);
setDeleteDialogOpen(true);
};
// 关闭删除确认对话框
const handleCloseDeleteDialog = () => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
};
// 删除项目
const handleDeleteProject = async () => {
if (!projectToDelete) return;
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectToDelete.id}`, {
method: 'DELETE'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || t('projects.deleteFailed'));
}
// 刷新页面以更新项目列表
window.location.reload();
} catch (error) {
console.error('删除项目失败:', error);
alert(error.message || t('projects.deleteFailed'));
} finally {
setLoading(false);
handleCloseDeleteDialog();
}
};
return (
<>
<Grid container spacing={3}>
{projects.length === 0 ? (
<Grid item xs={12}>
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
{t('projects.noProjects')}
</Typography>
<Button variant="contained" onClick={onCreateProject} startIcon={<AddCircleOutlineIcon />} sx={{ mt: 2 }}>
{t('projects.createFirst')}
</Button>
</Paper>
</Grid>
) : (
projects.map(project => (
<Grid item xs={12} sm={6} md={4} key={project.id}>
<ProjectCard project={project} onDeleteClick={handleOpenDeleteDialog} />
</Grid>
))
)}
</Grid>
{/* 删除确认对话框 */}
<Dialog
open={deleteDialogOpen}
onClose={handleCloseDeleteDialog}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">{t('projects.deleteConfirmTitle')}</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
{projectToDelete && (
<>
{t('projects.deleteConfirm')}
<br />
<Typography component="span" fontWeight="bold" sx={{ mt: 1, display: 'inline-block' }}>
{projectToDelete.name}
</Typography>
</>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDeleteDialog} disabled={loading}>
{t('common.cancel')}
</Button>
<Button onClick={handleDeleteProject} color="error" variant="contained" disabled={loading}>
{loading ? t('common.deleting') : t('common.delete')}
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { Paper, Grid, Box, Typography, useMediaQuery, Avatar } from '@mui/material';
import { styles } from '@/styles/home';
import { useTheme } from '@mui/material';
import { motion } from 'framer-motion';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import StorageIcon from '@mui/icons-material/Storage';
import MemoryIcon from '@mui/icons-material/Memory';
// 默认模型列表
const mockModels = [
{ id: 'deepseek-r1', provider: 'Ollama', name: 'DeepSeek-R1' },
{ id: 'gpt-3.5-turbo-openai', provider: 'OpenAI', name: 'gpt-3.5-turbo' },
{ id: 'gpt-3.5-turbo-guiji', provider: 'Guiji', name: 'gpt-3.5-turbo' },
{ id: 'glm-4-flash', provider: 'Zhipu AI', name: 'GLM-4-Flash' }
];
export default function StatsCard({ projects }) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// 统计卡片数据
const statsItems = [
{
value: projects.length,
label: t('stats.ongoingProjects'),
color: 'primary',
icon: <FolderOpenIcon />
},
{
value: projects.reduce((sum, project) => sum + (project.questionsCount || 0), 0),
label: t('stats.questionCount'),
color: 'secondary',
icon: <QuestionAnswerIcon />
},
{
value: projects.reduce((sum, project) => sum + (project.datasetsCount || 0), 0),
label: t('stats.generatedDatasets'),
color: 'success',
icon: <StorageIcon />
},
{
value: mockModels.length,
label: t('stats.supportedModels'),
color: 'warning',
icon: <MemoryIcon />
}
];
return (
<Paper
elevation={0}
sx={styles.statsCard(theme)}
component={motion.div}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Grid container spacing={3}>
{statsItems.map((item, index) => (
<Grid item xs={12} sm={6} md={3} key={index}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 2,
borderRadius: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: `0 10px 20px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.05)'}`
}
}}
component={motion.div}
whileHover={{ scale: 1.03 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<Avatar
sx={{
width: 56,
height: 56,
mb: 2,
bgcolor: theme.palette[item.color].main,
color: '#fff',
boxShadow: `0 4px 12px ${theme.palette[item.color].main}40`
}}
>
{item.icon}
</Avatar>
<Typography
color={item.color + '.main'}
variant={isMobile ? 'h3' : 'h2'}
fontWeight="bold"
sx={{
mb: 0.5,
background: `linear-gradient(135deg, ${theme.palette[item.color].main} 0%, ${theme.palette[item.color].light} 100%)`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
textFillColor: 'transparent'
}}
>
{item.value}
</Typography>
<Typography variant="subtitle1" color="text.secondary" fontWeight="500" sx={{ opacity: 0.8 }}>
{item.label}
</Typography>
</Box>
</Grid>
))}
</Grid>
</Paper>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Chip,
Typography,
IconButton,
Tooltip,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button
} from '@mui/material';
import { Psychology as PsychologyIcon, AutoAwesome as AutoFixIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import GaPairsManager from './GaPairsManager';
/**
* GA Pairs Indicator Component - Shows GA pairs status for a file
* @param {Object} props
* @param {string} props.projectId - Project ID
* @param {string} props.fileId - File ID
* @param {string} props.fileName - File name for display
*/
export default function GaPairsIndicator({ projectId, fileId, fileName = '未命名文件' }) {
const { t } = useTranslation();
const [gaPairs, setGaPairs] = useState([]);
const [loading, setLoading] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
// 获取GA对状态的函数
const fetchGaPairsStatus = useCallback(async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`);
if (!response.ok) {
if (response.status === 404) {
setGaPairs([]);
return;
}
throw new Error(`HTTP ${response.status}: Failed to load GA pairs`);
}
const result = await response.json();
// 处理响应格式
let newGaPairs = [];
if (Array.isArray(result)) {
newGaPairs = result;
} else if (result?.data) {
newGaPairs = result.data;
}
setGaPairs(newGaPairs);
} catch (error) {
console.error('获取GA对状态失败:', error);
setGaPairs([]);
} finally {
setLoading(false);
}
}, [projectId, fileId]);
// 初始加载
useEffect(() => {
if (projectId && fileId) {
fetchGaPairsStatus();
}
}, [projectId, fileId, fetchGaPairsStatus]);
//监听外部事件
useEffect(() => {
const handleRefresh = event => {
const { projectId: eventProjectId, fileIds } = event.detail || {};
if (eventProjectId === projectId && fileIds?.includes(String(fileId))) {
fetchGaPairsStatus();
}
};
window.addEventListener('refreshGaPairsIndicators', handleRefresh);
return () => window.removeEventListener('refreshGaPairsIndicators', handleRefresh);
}, [projectId, fileId, fetchGaPairsStatus]);
// 计算激活的GA对数量
const activePairs = gaPairs.filter(pair => pair.isActive);
const hasGaPairs = gaPairs.length > 0;
//GA对变化回调处理
const handleGaPairsChange = useCallback(newGaPairs => {
setGaPairs(newGaPairs || []);
}, []);
const handleOpenDialog = useCallback(() => {
setDetailsOpen(true);
}, []);
const handleCloseDialog = useCallback(() => {
setDetailsOpen(false);
}, []);
//加载状态显示
if (loading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Typography variant="caption" color="textSecondary">
Loading...
</Typography>
</Box>
);
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{hasGaPairs ? (
<Chip
icon={<PsychologyIcon />}
label={`${activePairs.length}/${gaPairs.length} GA Pairs`}
size="small"
color={activePairs.length > 0 ? 'primary' : 'default'}
variant={activePairs.length > 0 ? 'filled' : 'outlined'}
onClick={handleOpenDialog}
/>
) : (
<Tooltip title="Generate GA Pairs">
<IconButton size="small" onClick={handleOpenDialog} color="primary">
<AutoFixIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* Details Dialog */}
<Dialog open={detailsOpen} onClose={handleCloseDialog} maxWidth="lg" fullWidth>
<DialogTitle>GA Pairs for {fileName}</DialogTitle>
<DialogContent>
{detailsOpen && (
<GaPairsManager projectId={projectId} fileId={fileId} onGaPairsChange={handleGaPairsChange} />
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Close</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,610 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Switch,
FormControlLabel,
TextField,
IconButton,
Tooltip,
Divider,
Alert,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
AutoFixHigh as AutoFixHighIcon,
Save as SaveIcon
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import i18n from '@/lib/i18n';
/**
* GA Pairs Manager Component
* @param {Object} props
* @param {string} props.projectId - Project ID
* @param {string} props.fileId - File ID
* @param {Function} props.onGaPairsChange - Callback when GA pairs change
*/
export default function GaPairsManager({ projectId, fileId, onGaPairsChange }) {
const { t } = useTranslation();
const [gaPairs, setGaPairs] = useState([]);
const [backupGaPairs, setBackupGaPairs] = useState([]); // 备份状态
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newGaPair, setNewGaPair] = useState({
genreTitle: '',
genreDesc: '',
audienceTitle: '',
audienceDesc: '',
isActive: true
});
useEffect(() => {
loadGaPairs();
}, [projectId, fileId]);
const loadGaPairs = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`);
// 检查响应状态
if (!response.ok) {
if (response.status === 404) {
console.warn('GA Pairs API not found, using empty data');
setGaPairs([]);
setBackupGaPairs([]);
return;
}
throw new Error(`HTTP ${response.status}: Failed to load GA pairs`);
}
const result = await response.json();
console.log('Load GA pairs result:', result);
if (result.success) {
const loadedData = result.data || [];
setGaPairs(loadedData);
setBackupGaPairs([...loadedData]); // 创建备份
onGaPairsChange?.(loadedData);
} else {
throw new Error(result.error || 'Failed to load GA pairs');
}
} catch (error) {
console.error('Load GA pairs error:', error);
setError(t('gaPairs.loadError', { error: error.message }));
} finally {
setLoading(false);
}
};
const generateGaPairs = async () => {
try {
setGenerating(true);
setError(null);
console.log('Starting GA pairs generation...');
// Get current language from i18n
const currentLanguage = i18n.language === 'en' ? 'en' : '中文';
const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
regenerate: false,
appendMode: true, // 新增:启用追加模式
language: currentLanguage
})
});
if (!response.ok) {
let errorMessage = t('gaPairs.generateError');
if (response.status === 404) {
errorMessage = t('gaPairs.serviceNotAvailable');
} else if (response.status === 400) {
try {
const errorResult = await response.json();
if (errorResult.error?.includes('No active AI model')) {
errorMessage = t('gaPairs.noActiveModel');
} else if (errorResult.error?.includes('content might be too short')) {
errorMessage = t('gaPairs.contentTooShort');
} else {
errorMessage = errorResult.error || errorMessage;
}
} catch (parseError) {
errorMessage = t('gaPairs.requestFailed', { status: response.status });
}
} else if (response.status === 500) {
try {
const errorResult = await response.json();
if (errorResult.error?.includes('model configuration') || errorResult.error?.includes('Module not found')) {
errorMessage = t('gaPairs.configError');
} else {
errorMessage = errorResult.error || 'Internal server error occurred.';
}
} catch (parseError) {
console.error('Failed to parse error response:', parseError);
errorMessage = errorResult.error || t('gaPairs.internalServerError');
}
}
throw new Error(errorMessage);
}
// 处理成功响应
const responseText = await response.text();
if (!responseText || responseText.trim() === '') {
throw new Error(t('gaPairs.emptyResponse'));
}
const result = JSON.parse(responseText);
console.log('Generate GA pairs result:', result);
if (result.success) {
// 在追加模式下后端只返回新生成的GA对
const newGaPairs = result.data || [];
// 将新生成的GA对追加到现有的GA对
const updatedGaPairs = [...gaPairs, ...newGaPairs];
setGaPairs(updatedGaPairs);
setBackupGaPairs([...updatedGaPairs]); // 更新备份
onGaPairsChange?.(updatedGaPairs);
setSuccess(
t('gaPairs.additionalPairsGenerated', {
count: newGaPairs.length,
total: updatedGaPairs.length
})
);
} else {
throw new Error(result.error || t('gaPairs.generationFailed'));
}
} catch (error) {
console.error('Generate GA pairs error:', error);
setError(error.message);
} finally {
setGenerating(false);
}
};
const saveGaPairs = async () => {
try {
setSaving(true);
setError(null);
// 验证GA对数据
const validatedGaPairs = gaPairs.map((pair, index) => {
// 处理不同的数据格式
let genreTitle, genreDesc, audienceTitle, audienceDesc;
if (pair.genre && typeof pair.genre === 'object') {
genreTitle = pair.genre.title;
genreDesc = pair.genre.description;
} else {
genreTitle = pair.genreTitle || pair.genre;
genreDesc = pair.genreDesc || '';
}
if (pair.audience && typeof pair.audience === 'object') {
audienceTitle = pair.audience.title;
audienceDesc = pair.audience.description;
} else {
audienceTitle = pair.audienceTitle || pair.audience;
audienceDesc = pair.audienceDesc || '';
}
// 验证必填字段
if (!genreTitle || !audienceTitle) {
throw new Error(t('gaPairs.validationError', { number: index + 1 }));
}
return {
id: pair.id,
genreTitle: genreTitle.trim(),
genreDesc: genreDesc.trim(),
audienceTitle: audienceTitle.trim(),
audienceDesc: audienceDesc.trim(),
isActive: pair.isActive !== undefined ? pair.isActive : true
};
});
console.log('Saving validated GA pairs:', validatedGaPairs);
const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
updates: validatedGaPairs
})
});
if (!response.ok) {
let errorMessage = t('gaPairs.saveError');
if (response.status === 404) {
errorMessage = 'GA Pairs save service is not available.';
} else {
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch (parseError) {
errorMessage = t('gaPairs.serverError', { status: response.status });
}
}
throw new Error(errorMessage);
}
const responseText = await response.text();
const result = responseText ? JSON.parse(responseText) : { success: true };
if (result.success) {
// 更新本地状态为服务器返回的数据
const savedData = result.data || validatedGaPairs;
setGaPairs(savedData);
// 根据保存的GA对数量显示不同的成功消息
if (savedData.length === 0) {
setSuccess(t('gaPairs.allPairsDeleted'));
} else {
setSuccess(t('gaPairs.pairsSaved', { count: savedData.length }));
}
onGaPairsChange?.(savedData);
} else {
throw new Error(result.error || t('gaPairs.saveOperationFailed'));
}
} catch (error) {
console.error('Save GA pairs error:', error);
setError(error.message);
} finally {
setSaving(false);
}
};
const handleGaPairChange = (index, field, value) => {
const updatedGaPairs = [...gaPairs];
// 确保对象存在
if (!updatedGaPairs[index]) {
console.error(`GA pair at index ${index} does not exist`);
return;
}
updatedGaPairs[index] = {
...updatedGaPairs[index],
[field]: value
};
setGaPairs(updatedGaPairs);
// 不立即调用 onGaPairsChange等用户点击保存时再调用
};
const handleDeleteGaPair = index => {
const updatedGaPairs = gaPairs.filter((_, i) => i !== index);
setGaPairs(updatedGaPairs);
onGaPairsChange?.(updatedGaPairs);
};
const handleAddGaPair = () => {
// 验证输入
if (!newGaPair.genreTitle?.trim() || !newGaPair.audienceTitle?.trim()) {
setError(t('gaPairs.requiredFields'));
return;
}
// 创建新的GA对对象
const newPair = {
id: `temp_${Date.now()}`, // 临时ID
genreTitle: newGaPair.genreTitle.trim(),
genreDesc: newGaPair.genreDesc?.trim() || '',
audienceTitle: newGaPair.audienceTitle.trim(),
audienceDesc: newGaPair.audienceDesc?.trim() || '',
isActive: true
};
const updatedGaPairs = [...gaPairs, newPair];
setGaPairs(updatedGaPairs);
onGaPairsChange?.(updatedGaPairs);
// 重置表单并关闭对话框
setNewGaPair({
genreTitle: '',
genreDesc: '',
audienceTitle: '',
audienceDesc: '',
isActive: true
});
setAddDialogOpen(false);
setError(null);
};
const resetMessages = () => {
setError(null);
setSuccess(null);
};
const recoverFromBackup = () => {
setGaPairs([...backupGaPairs]);
setError(null);
setSuccess(t('gaPairs.restoredFromBackup'));
};
useEffect(() => {
if (error || success) {
const timer = setTimeout(resetMessages, 5000);
return () => clearTimeout(timer);
}
}, [error, success]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>{t('gaPairs.loading')}</Typography>
</Box>
);
}
return (
<Box sx={{ p: 2 }}>
{/* Header with action buttons */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6">{t('gaPairs.title')}</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{/* 右上角按钮为手动添加GA对 */}
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => setAddDialogOpen(true)}
disabled={generating || saving}
>
{t('gaPairs.addPair')}
</Button>
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveGaPairs} disabled={generating || saving}>
{saving ? <CircularProgress size={20} /> : t('gaPairs.saveChanges')}
</Button>
</Box>
</Box>
{/* Error/Success Messages */}
{error && (
<Alert
severity="error"
sx={{ mb: 2 }}
action={
backupGaPairs.length > 0 && (
<Button color="inherit" size="small" onClick={recoverFromBackup}>
{t('gaPairs.restoreBackup')}
</Button>
)
}
onClose={resetMessages}
>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={resetMessages}>
{success}
</Alert>
)}
{/* Generate GA Pairs Section - 只在没有GA对时显示 */}
{gaPairs.length === 0 && (
<Card sx={{ mb: 3 }}>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
{t('gaPairs.noGaPairsTitle')}
</Typography>
<Typography color="textSecondary" sx={{ mb: 2 }}>
{t('gaPairs.noGaPairsDescription')}
</Typography>
<Button
variant="contained"
startIcon={generating ? <CircularProgress size={20} /> : <AutoFixHighIcon />}
onClick={generateGaPairs}
disabled={generating}
size="large"
>
{generating ? t('gaPairs.generating') : t('gaPairs.generateGaPairs')}
</Button>
</CardContent>
</Card>
)}
{/* GA Pairs List */}
{gaPairs.length > 0 && (
<Box>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
{t('gaPairs.activePairs', {
active: gaPairs.filter(pair => pair.isActive).length,
total: gaPairs.length
})}
</Typography>
<Grid container spacing={2}>
{gaPairs.map((pair, index) => (
<Grid item xs={12} key={pair.id || index}>
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Typography variant="subtitle2" color="primary">
{t('gaPairs.pairNumber', { number: index + 1 })}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={pair.isActive}
onChange={e => handleGaPairChange(index, 'isActive', e.target.checked)}
size="small"
/>
}
label={t('gaPairs.active')}
/>
{/* 添加删除按钮 */}
<Tooltip title={t('gaPairs.deleteTooltip')}>
<IconButton size="small" color="error" onClick={() => handleDeleteGaPair(index)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label={t('gaPairs.genre')}
value={pair.genreTitle || pair.genre || ''}
onChange={e => handleGaPairChange(index, 'genreTitle', e.target.value)}
multiline
rows={2}
fullWidth
disabled={!pair.isActive}
/>
<TextField
label={t('gaPairs.genreDescription')}
value={pair.genreDesc || ''}
onChange={e => handleGaPairChange(index, 'genreDesc', e.target.value)}
multiline
rows={2}
fullWidth
disabled={!pair.isActive}
/>
<TextField
label={t('gaPairs.audience')}
value={pair.audienceTitle || pair.audience || ''}
onChange={e => handleGaPairChange(index, 'audienceTitle', e.target.value)}
multiline
rows={2}
fullWidth
disabled={!pair.isActive}
/>
<TextField
label={t('gaPairs.audienceDescription')}
value={pair.audienceDesc || ''}
onChange={e => handleGaPairChange(index, 'audienceDesc', e.target.value)}
multiline
rows={2}
fullWidth
disabled={!pair.isActive}
/>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* 在GA对列表下方添加生成按钮 */}
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Button
variant="outlined"
startIcon={generating ? <CircularProgress size={20} /> : <AutoFixHighIcon />}
onClick={generateGaPairs}
disabled={generating}
>
{generating ? t('gaPairs.generating') : t('gaPairs.generateMore')}
</Button>
</Box>
</Box>
)}
{/* Add GA Pair Dialog */}
<Dialog open={addDialogOpen} onClose={() => setAddDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>{t('gaPairs.addDialogTitle')}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label={t('gaPairs.genreTitle')}
value={newGaPair.genreTitle || ''}
onChange={e => setNewGaPair({ ...newGaPair, genreTitle: e.target.value })}
fullWidth
required
placeholder={t('gaPairs.genreTitlePlaceholder')}
/>
<TextField
label={t('gaPairs.genreDescription')}
value={newGaPair.genreDesc || ''}
onChange={e => setNewGaPair({ ...newGaPair, genreDesc: e.target.value })}
multiline
rows={3}
fullWidth
placeholder={t('gaPairs.genreDescPlaceholder')}
/>
<TextField
label={t('gaPairs.audienceTitle')}
value={newGaPair.audienceTitle || ''}
onChange={e => setNewGaPair({ ...newGaPair, audienceTitle: e.target.value })}
fullWidth
required
placeholder={t('gaPairs.audienceTitlePlaceholder')}
/>
<TextField
label={t('gaPairs.audienceDescription')}
value={newGaPair.audienceDesc || ''}
onChange={e => setNewGaPair({ ...newGaPair, audienceDesc: e.target.value })}
multiline
rows={3}
fullWidth
placeholder={t('gaPairs.audienceDescPlaceholder')}
/>
<FormControlLabel
control={
<Switch
checked={newGaPair.isActive}
onChange={e => setNewGaPair({ ...newGaPair, isActive: e.target.checked })}
/>
}
label={t('gaPairs.active')}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setAddDialogOpen(false);
// 重置表单
setNewGaPair({
genreTitle: '',
genreDesc: '',
audienceTitle: '',
audienceDesc: '',
isActive: true
});
}}
>
{t('gaPairs.cancel')}
</Button>
<Button
onClick={handleAddGaPair}
variant="contained"
disabled={!newGaPair.genreTitle?.trim() || !newGaPair.audienceTitle?.trim()}
>
{t('gaPairs.addPairButton')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { Box, Typography, Paper, Grid, CircularProgress } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import ChatMessage from './ChatMessage';
import { playgroundStyles } from '@/styles/playground';
import { useTranslation } from 'react-i18next';
const ChatArea = ({ selectedModels, conversations, loading, getModelName }) => {
const theme = useTheme();
const styles = playgroundStyles(theme);
const { t } = useTranslation();
// 为每个模型创建独立的引用
const chatContainerRefs = {
model1: useRef(null),
model2: useRef(null),
model3: useRef(null)
};
// 为每个模型的聊天容器自动滚动到底部
useEffect(() => {
Object.values(chatContainerRefs).forEach(ref => {
if (ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
});
}, [conversations]);
if (selectedModels.length === 0) {
return (
<Box sx={styles.emptyStateBox}>
<Typography color="textSecondary">{t('playground.selectModelFirst')}</Typography>
</Box>
);
}
return (
<Grid container spacing={2} sx={styles.chatContainer}>
{selectedModels.map((modelId, index) => {
const modelConversation = conversations[modelId] || [];
const isLoading = loading[modelId];
const refKey = `model${index + 1}`;
return (
<Grid
item
xs={12}
md={selectedModels.length > 1 ? 12 / selectedModels.length : 12}
key={modelId}
style={{ maxHeight: 'calc(100vh - 300px)' }}
>
<Paper elevation={1} sx={styles.modelPaper}>
<Box sx={styles.modelHeader}>
<Typography variant="subtitle2">{getModelName(modelId)}</Typography>
{isLoading && <CircularProgress size={16} sx={{ ml: 1 }} color="inherit" />}
</Box>
<Box ref={chatContainerRefs[refKey]} sx={styles.modelChatBox}>
{modelConversation.length === 0 ? (
<Box sx={styles.emptyChatBox}>
<Typography color="textSecondary" variant="body2">
{t('playground.sendFirstMessage')}
</Typography>
</Box>
) : (
modelConversation.map((message, msgIndex) => (
<React.Fragment key={msgIndex}>
<ChatMessage message={message} modelName={null} />
</React.Fragment>
))
)}
</Box>
</Paper>
</Grid>
);
})}
</Grid>
);
};
export default ChatArea;

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import { Box, Paper, Typography, Alert, useTheme, IconButton, Collapse } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import PsychologyIcon from '@mui/icons-material/Psychology';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { useTranslation } from 'react-i18next';
/**
* 聊天消息组件
* @param {Object} props
* @param {Object} props.message - 消息对象
* @param {string} props.message.role - 消息角色:'user'、'assistant' 或 'error'
* @param {string} props.message.content - 消息内容
* @param {string} props.modelName - 模型名称(仅在 assistant 或 error 类型消息中显示)
*/
export default function ChatMessage({ message, modelName }) {
const theme = useTheme();
const { t } = useTranslation();
// 用户消息
if (message.role === 'user') {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
mb: 2
}}
>
<Paper
elevation={1}
sx={{
p: 2,
borderRadius: '16px 16px 0 16px',
maxWidth: '80%',
bgcolor: theme.palette.primary.main,
color: 'white'
}}
>
{typeof message.content === 'string' ? (
<Typography variant="body1">{message.content}</Typography>
) : (
// 如果是数组类型(用于视觉模型的用户输入)
<>
{Array.isArray(message.content) &&
message.content.map((item, i) => {
if (item.type === 'text') {
return (
<Typography key={i} variant="body1">
{item.text}
</Typography>
);
} else if (item.type === 'image_url') {
return (
<Box key={i} sx={{ mt: 1, mb: 1 }}>
<img
src={item.image_url.url}
alt="上传图片"
style={{ maxWidth: '100%', borderRadius: '4px' }}
/>
</Box>
);
}
return null;
})}
</>
)}
</Paper>
</Box>
);
}
// 助手消息
if (message.role === 'assistant') {
// 处理推理过程的展示状态
const [showThinking, setShowThinking] = useState(message.showThinking || false);
const hasThinking = message.thinking && message.thinking.trim().length > 0;
return (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-start',
mb: 2
}}
>
<Paper
elevation={1}
sx={{
p: 2,
borderRadius: '16px 16px 16px 0',
maxWidth: '80%',
width: hasThinking ? '80%' : 'auto',
bgcolor: theme.palette.mode === 'dark' ? theme.palette.grey[800] : theme.palette.grey[100]
}}
>
{modelName && (
<Typography variant="caption" color="textSecondary" sx={{ display: 'block', mb: 0.5 }}>
{modelName}
</Typography>
)}
{/* 推理过程显示区域 */}
{hasThinking && (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
borderBottom: `1px solid ${theme.palette.divider}`
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{message.isStreaming ? (
<AutoFixHighIcon
fontSize="small"
color="primary"
sx={{
animation: 'thinking-pulse 1.5s infinite',
'@keyframes thinking-pulse': {
'0%': { opacity: 0.4 },
'50%': { opacity: 1 },
'100%': { opacity: 0.4 }
}
}}
/>
) : (
<PsychologyIcon fontSize="small" color="primary" />
)}
<Typography variant="caption" color="primary" fontWeight="bold">
{t('playground.reasoningProcess', '推理过程')}
</Typography>
</Box>
<IconButton size="small" onClick={() => setShowThinking(!showThinking)} sx={{ p: 0 }}>
{showThinking ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</IconButton>
</Box>
<Collapse in={showThinking}>
<Box
sx={{
p: 1,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.05)',
borderRadius: 1,
fontFamily: 'monospace',
fontSize: '0.85rem'
}}
>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: theme.palette.text.secondary }}>
{message.thinking}
</Typography>
</Box>
</Collapse>
</Box>
)}
{/* 回答内容 */}
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap' }}>
{typeof message.content === 'string' ? (
<>
{message.content}
{message.isStreaming && <span className="blinking-cursor">|</span>}
</>
) : (
// 如果是数组类型(用于视觉模型的响应)
<>
{Array.isArray(message.content) &&
message.content.map((item, i) => {
if (item.type === 'text') {
return <span key={i}>{item.text}</span>;
} else if (item.type === 'image_url') {
return (
<Box key={i} sx={{ mt: 1, mb: 1 }}>
<img src={item.image_url.url} alt="图片" style={{ maxWidth: '100%', borderRadius: '4px' }} />
</Box>
);
}
return null;
})}
{message.isStreaming && <span className="blinking-cursor">|</span>}
</>
)}
</Typography>
</Paper>
</Box>
);
}
// 错误消息
if (message.role === 'error') {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-start',
mb: 2
}}
>
<Alert severity="error" sx={{ maxWidth: '80%' }}>
{modelName && (
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
{modelName}
</Typography>
)}
{message.content}
</Alert>
</Box>
);
}
return null;
}

View File

@@ -0,0 +1,104 @@
'use client';
import React, { useState } from 'react';
import { Box, TextField, Button, IconButton, Badge, Tooltip } from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import ImageIcon from '@mui/icons-material/Image';
import CancelIcon from '@mui/icons-material/Cancel';
import { useTheme } from '@mui/material/styles';
import { playgroundStyles } from '@/styles/playground';
import { useTranslation } from 'react-i18next';
const MessageInput = ({
userInput,
handleInputChange,
handleSendMessage,
loading,
selectedModels,
uploadedImage,
handleImageUpload,
handleRemoveImage,
availableModels
}) => {
const theme = useTheme();
const styles = playgroundStyles(theme);
const { t } = useTranslation();
const isDisabled = Object.values(loading).some(value => value) || selectedModels.length === 0;
const isSendDisabled = isDisabled || (!userInput.trim() && !uploadedImage);
// 检查是否有视觉模型被选中
const hasVisionModel = selectedModels.some(modelId => {
const model = availableModels.find(m => m.id === modelId);
return model && model.type === 'vision';
});
return (
<Box sx={styles.inputContainer}>
{uploadedImage && (
<Box sx={{ position: 'relative', mb: 1, display: 'inline-block', maxWidth: '100%' }}>
<Badge
badgeContent={
<IconButton
size="small"
onClick={handleRemoveImage}
sx={{ bgcolor: 'rgba(0, 0, 0, 0.4)', '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.6)' } }}
>
<CancelIcon fontSize="small" sx={{ color: '#fff' }} />
</IconButton>
}
sx={{ width: '100%' }}
overlap="rectangular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<img
src={uploadedImage}
alt="上传图片"
style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: '4px' }}
/>
</Badge>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'flex-start', width: '100%' }}>
<TextField
fullWidth
variant="outlined"
placeholder={t('playground.inputMessage')}
value={userInput}
onChange={handleInputChange}
disabled={isDisabled}
onKeyPress={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
multiline
maxRows={4}
/>
{hasVisionModel && (
<Tooltip title={t('playground.uploadImage')}>
<span>
<IconButton color="primary" component="label" disabled={isDisabled} sx={{ ml: 1, mr: 1 }}>
<input hidden accept="image/*" type="file" onChange={handleImageUpload} />
<ImageIcon />
</IconButton>
</span>
</Tooltip>
)}
<Button
variant="contained"
color="primary"
endIcon={<SendIcon />}
onClick={handleSendMessage}
disabled={isSendDisabled}
sx={styles.sendButton}
>
{t('playground.send')}
</Button>
</Box>
</Box>
);
};
export default MessageInput;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
OutlinedInput,
Box,
Chip,
Checkbox,
ListItemText
} from '@mui/material';
import { useTranslation } from 'react-i18next';
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250
}
}
};
/**
* 模型选择组件
* @param {Object} props
* @param {Array} props.models - 可用模型列表
* @param {Array} props.selectedModels - 已选择的模型ID列表
* @param {Function} props.onChange - 选择改变时的回调函数
*/
export default function ModelSelector({ models, selectedModels, onChange }) {
// 获取模型名称
const getModelName = modelId => {
const model = models.find(m => m.id === modelId);
return model ? `${model.providerName}: ${model.modelName}` : modelId;
};
const { t } = useTranslation();
return (
<FormControl fullWidth>
<InputLabel id="model-select-label">{t('playground.selectModelMax3')}</InputLabel>
<Select
labelId="model-select-label"
id="model-select"
multiple
value={selectedModels}
onChange={onChange}
input={<OutlinedInput label="选择模型最多3个" />}
renderValue={selected => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map(modelId => (
<Chip key={modelId} label={getModelName(modelId)} color="primary" variant="outlined" size="small" />
))}
</Box>
)}
MenuProps={MenuProps}
>
{models
.filter(m => {
if (m.providerId.toLowerCase() === 'ollama') {
return m.modelName && m.endpoint;
} else {
return m.modelName && m.endpoint && m.apiKey;
}
})
.map(model => (
<MenuItem
key={model.id}
value={model.id}
disabled={selectedModels.length >= 3 && !selectedModels.includes(model.id)}
>
<Checkbox checked={selectedModels.indexOf(model.id) > -1} />
<ListItemText primary={`${model.providerName}: ${model.modelName}`} />
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import React from 'react';
import { Grid, Button, Divider, FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { useTheme } from '@mui/material/styles';
import ModelSelector from './ModelSelector';
import { playgroundStyles } from '@/styles/playground';
import { useTranslation } from 'react-i18next';
const PlaygroundHeader = ({
availableModels,
selectedModels,
handleModelSelection,
handleClearConversations,
conversations,
outputMode,
handleOutputModeChange
}) => {
const theme = useTheme();
const styles = playgroundStyles(theme);
const { t } = useTranslation();
const isClearDisabled = selectedModels.length === 0 || Object.values(conversations).every(conv => conv.length === 0);
return (
<>
<Grid container spacing={2} sx={styles.controlsContainer}>
<Grid item xs={12} md={6}>
<ModelSelector models={availableModels} selectedModels={selectedModels} onChange={handleModelSelection} />
</Grid>
<Grid item xs={12} md={3}>
<FormControl fullWidth>
<InputLabel id="output-mode-label">{t('playground.outputMode')}</InputLabel>
<Select
labelId="output-mode-label"
id="output-mode-select"
value={outputMode}
label={t('playground.outputMode')}
onChange={handleOutputModeChange}
>
<MenuItem value="normal">{t('playground.normalOutput')}</MenuItem>
<MenuItem value="streaming">{t('playground.streamingOutput')}</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={3}>
<Button
variant="outlined"
color="error"
startIcon={<DeleteIcon />}
onClick={handleClearConversations}
disabled={isClearDisabled}
sx={styles.clearButton}
>
{t('playground.clearConversation')}
</Button>
</Grid>
</Grid>
<Divider sx={styles.divider} />
</>
);
};
export default PlaygroundHeader;

View File

@@ -0,0 +1,374 @@
'use client';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Typography,
Checkbox,
IconButton,
Chip,
Tooltip,
Pagination,
Divider,
Paper,
CircularProgress,
TextField
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import EditIcon from '@mui/icons-material/Edit';
import ChatIcon from '@mui/icons-material/Chat';
import { useGenerateDataset } from '@/hooks/useGenerateDataset';
import { toast } from 'sonner';
import { useAtomValue } from 'jotai';
import { selectedModelInfoAtom } from '@/lib/store';
export default function QuestionListView({
questions = [],
currentPage,
totalQuestions = 0,
handlePageChange,
selectedQuestions = [],
onSelectQuestion,
onDeleteQuestion,
projectId,
onEditQuestion,
refreshQuestions
}) {
const { t } = useTranslation();
// 处理状态
const [processingQuestions, setProcessingQuestions] = useState({});
const { generateSingleDataset } = useGenerateDataset();
// 获取当前选中的模型
const selectedModelInfo = useAtomValue(selectedModelInfoAtom);
// 获取文本块的标题
const getChunkTitle = content => {
const firstLine = content ? content.split('\n')[0].trim() : '';
if (firstLine.startsWith('# ')) {
return firstLine.substring(2);
} else if (firstLine.length > 0) {
return firstLine.length > 200 ? firstLine.substring(0, 200) + '...' : firstLine;
}
return '';
};
// 检查问题是否被选中
const isQuestionSelected = questionId => {
return selectedQuestions.includes(questionId);
};
// 处理生成数据集
const handleGenerateDataset = async (questionId, questionInfo, imageId, imageName) => {
// 设置处理状态
setProcessingQuestions(prev => ({
...prev,
[questionId]: true
}));
await generateSingleDataset({
projectId,
questionId,
questionInfo,
imageId,
imageName
});
// 重置处理状态
setProcessingQuestions(prev => ({
...prev,
[questionId]: false
}));
refreshQuestions();
};
// 处理生成多轮对话数据集
const handleGenerateMultiTurnDataset = async (questionId, questionInfo) => {
try {
// 设置处理状态
setProcessingQuestions(prev => ({
...prev,
[`${questionId}_multi`]: true
}));
// 首先检查项目是否配置了多轮对话设置
const configResponse = await fetch(`/api/projects/${projectId}/tasks`);
if (!configResponse.ok) {
throw new Error('获取项目配置失败');
}
const config = await configResponse.json();
const multiTurnConfig = {
systemPrompt: config.multiTurnSystemPrompt,
scenario: config.multiTurnScenario,
rounds: config.multiTurnRounds,
roleA: config.multiTurnRoleA,
roleB: config.multiTurnRoleB
};
console.log('multiTurnConfig:', multiTurnConfig);
// 检查是否已配置必要的多轮对话设置
// 系统提示词是可选的但场景、角色A、角色B和轮数是必需的
if (
!multiTurnConfig.scenario ||
!multiTurnConfig.roleA ||
!multiTurnConfig.roleB ||
!multiTurnConfig.rounds ||
multiTurnConfig.rounds < 1
) {
toast.error(t('questions.multiTurnNotConfigured', '请先在项目设置中配置多轮对话相关参数'));
return;
}
// 检查是否选中了模型
if (!selectedModelInfo) {
toast.error(t('datasets.selectModelFirst', '请先选择模型'));
return;
}
// 调用多轮对话生成API
const response = await fetch(`/api/projects/${projectId}/dataset-conversations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
questionId,
...multiTurnConfig,
model: selectedModelInfo
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '生成多轮对话数据集失败');
}
const result = await response.json();
toast.success(t('questions.multiTurnGenerated', '多轮对话数据集生成成功!'));
} catch (error) {
console.error('生成多轮对话数据集失败:', error);
toast.error(error.message || '生成多轮对话数据集失败');
} finally {
// 重置处理状态
setProcessingQuestions(prev => ({
...prev,
[`${questionId}_multi`]: false
}));
}
};
return (
<Box style={{ padding: '20px' }}>
{/* 问题列表 */}
<Paper
elevation={0}
sx={{
borderRadius: 2,
overflow: 'hidden',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
}}
>
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', bgcolor: 'background.paper' }}>
<Typography variant="body2" sx={{ fontWeight: 500, ml: 1 }}>
{t('datasets.question')}
</Typography>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontWeight: 500, mr: 2, display: { xs: 'none', sm: 'block' } }}>
{t('common.label')}
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 500, width: 150, mr: 2, display: { xs: 'none', md: 'block' } }}
>
{t('common.dataSource')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, width: 100, textAlign: 'center' }}>
{t('common.actions')}
</Typography>
</Box>
</Box>
<Divider />
{questions.map((question, index) => {
const isSelected = isQuestionSelected(question.id);
const questionKey = question.id;
return (
<Box key={questionKey}>
<Box
sx={{
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
bgcolor: isSelected ? 'action.selected' : 'background.paper',
'&:hover': {
bgcolor: 'action.hover'
}
}}
>
<Checkbox
checked={isSelected}
onChange={() => {
onSelectQuestion(questionKey);
}}
size="small"
/>
<Box sx={{ ml: 1, flex: 1, mr: 2 }}>
<Typography variant="body2">
{question.question}
{question.datasetCount > 0 ? (
<Chip
label={t('datasets.answerCount', { count: question.datasetCount })}
size="small"
color="primary"
variant="outlined"
sx={{ fontSize: '0.75rem', maxWidth: 150 }}
/>
) : null}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: { xs: 'block', sm: 'none' } }}>
{question.label || t('datasets.noTag')} ID: {(question.question || '').substring(0, 8)}
</Typography>
</Box>
<Box sx={{ display: { xs: 'none', sm: 'block' }, mr: 2 }}>
{question.label ? (
<Chip
label={question.label}
size="small"
color="primary"
variant="outlined"
sx={{ fontSize: '0.75rem', maxWidth: 150 }}
/>
) : (
<Typography variant="caption" color="text.disabled">
{t('datasets.noTag')}
</Typography>
)}
</Box>
<Box sx={{ width: 150, mr: 2, display: { xs: 'none', md: 'block' } }}>
<Tooltip title={getChunkTitle(question.chunk?.content)}>
<Chip
label={
question.imageId
? `Image: ${question.imageName}`
: `${t('chunks.title')}: ${question.chunk?.name}`
}
size="small"
variant="outlined"
color="info"
sx={{
fontSize: '0.75rem',
maxWidth: '100%',
textOverflow: 'ellipsis'
}}
/>
</Tooltip>
</Box>
<Box sx={{ width: 160, display: 'flex', justifyContent: 'center' }}>
<Tooltip title={t('common.edit')}>
<IconButton
size="small"
color="primary"
onClick={() => onEditQuestion(question)}
disabled={processingQuestions[questionKey]}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('datasets.generateDataset')}>
<IconButton
size="small"
color="primary"
onClick={() =>
handleGenerateDataset(question.id, question.question, question.imageId, question.imageName)
}
disabled={processingQuestions[questionKey]}
>
{processingQuestions[questionKey] ? (
<CircularProgress size={16} />
) : (
<AutoFixHighIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
{!question.imageId && (
<Tooltip title={t('questions.generateMultiTurn', '生成多轮对话')}>
<IconButton
size="small"
color="secondary"
onClick={() => handleGenerateMultiTurnDataset(question.id, question.question)}
disabled={processingQuestions[`${questionKey}_multi`]}
>
{processingQuestions[`${questionKey}_multi`] ? (
<CircularProgress size={16} />
) : (
<ChatIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
)}
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
color="error"
onClick={() => onDeleteQuestion(question.id)}
disabled={processingQuestions[questionKey]}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{index < questions.length - 1 && <Divider />}
</Box>
);
})}
</Paper>
{/* 分页 */}
{totalQuestions > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', mt: 3, mb: 2 }}>
<Pagination
count={totalQuestions}
page={currentPage}
onChange={handlePageChange}
color="primary"
showFirstButton
showLastButton
shape="rounded"
size="medium"
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">{t('common.jumpTo')}:</Typography>
<TextField
size="small"
type="number"
inputProps={{
min: 1,
max: totalQuestions,
style: { padding: '4px 8px', width: '50px' }
}}
onKeyPress={e => {
if (e.key === 'Enter') {
const pageNum = parseInt(e.target.value, 10);
if (pageNum >= 1 && pageNum <= totalQuestions) {
handlePageChange(null, pageNum);
e.target.value = '';
}
}
}}
/>
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,565 @@
'use client';
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemText,
Checkbox,
IconButton,
Collapse,
Chip,
Tooltip,
Divider,
CircularProgress
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import DeleteIcon from '@mui/icons-material/Delete';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import EditIcon from '@mui/icons-material/Edit';
import FolderIcon from '@mui/icons-material/Folder';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import { useGenerateDataset } from '@/hooks/useGenerateDataset';
import axios from 'axios';
/**
* 问题树视图组件
* @param {Object} props
* @param {Array} props.tags - 标签树
* @param {Array} props.selectedQuestions - 已选择的问题ID列表
* @param {Function} props.onSelectQuestion - 选择问题的回调函数
* @param {Function} props.onDeleteQuestion - 删除问题的回调函数
*/
export default function QuestionTreeView({
tags = [],
selectedQuestions = [],
onSelectQuestion,
onDeleteQuestion,
onEditQuestion,
projectId,
searchTerm
}) {
const { t } = useTranslation();
const [expandedTags, setExpandedTags] = useState({});
const [questionsByTag, setQuestionsByTag] = useState({});
const [processingQuestions, setProcessingQuestions] = useState({});
const { generateSingleDataset } = useGenerateDataset();
const [questions, setQuestions] = useState([]);
const [loadedTags, setLoadedTags] = useState({});
// 初始化时,将所有标签设置为收起状态(而不是展开状态)
useEffect(() => {
async function fetchTagsInfo() {
try {
// 获取标签信息,仅用于标签统计
const response = await axios.get(`/api/projects/${projectId}/questions/tree?tagsOnly=true&input=${searchTerm}`);
setQuestions(response.data); // 设置数据仅用于标签统计
// 当搜索条件变化时,重新加载已展开标签的问题数据
const expandedTagLabels = Object.entries(expandedTags)
.filter(([_, isExpanded]) => isExpanded)
.map(([label]) => label);
// 重新加载已展开标签的数据
for (const label of expandedTagLabels) {
fetchTagQuestions(label);
}
} catch (error) {
console.error('获取标签信息失败:', error);
}
}
if (projectId) {
fetchTagsInfo();
}
const initialExpandedState = {};
const processTag = tag => {
// 将默认状态改为 false收起而不是 true展开
initialExpandedState[tag.label] = false;
if (tag.child && tag.child.length > 0) {
tag.child.forEach(processTag);
}
};
tags.forEach(processTag);
// 未分类问题也默认收起
initialExpandedState['uncategorized'] = false;
setExpandedTags(initialExpandedState);
}, [tags]);
// 根据标签对问题进行分类
useEffect(() => {
const taggedQuestions = {};
// 初始化标签映射
const initTagMap = tag => {
taggedQuestions[tag.label] = [];
if (tag.child && tag.child.length > 0) {
tag.child.forEach(initTagMap);
}
};
tags.forEach(initTagMap);
// 将问题分配到对应的标签下
questions.forEach(question => {
// 如果问题没有标签,添加到"未分类"
if (!question.label) {
if (!taggedQuestions['uncategorized']) {
taggedQuestions['uncategorized'] = [];
}
taggedQuestions['uncategorized'].push(question);
return;
}
// 将问题添加到匹配的标签下
const questionLabel = question.label;
// 查找最精确匹配的标签
// 使用一个数组来存储所有匹配的标签路径,以便找到最精确的匹配
const findAllMatchingTags = (tag, path = []) => {
const currentPath = [...path, tag.label];
// 存储所有匹配结果
const matches = [];
// 精确匹配当前标签
if (tag.label === questionLabel) {
matches.push({ label: tag.label, depth: currentPath.length });
}
// 检查子标签
if (tag.child && tag.child.length > 0) {
for (const childTag of tag.child) {
const childMatches = findAllMatchingTags(childTag, currentPath);
matches.push(...childMatches);
}
}
return matches;
};
// 在所有根标签中查找所有匹配
let allMatches = [];
for (const rootTag of tags) {
const matches = findAllMatchingTags(rootTag);
allMatches.push(...matches);
}
// 找到深度最大的匹配(最精确的匹配)
let matchedTagLabel = null;
if (allMatches.length > 0) {
// 按深度排序,深度最大的是最精确的匹配
allMatches.sort((a, b) => b.depth - a.depth);
matchedTagLabel = allMatches[0].label;
}
if (matchedTagLabel) {
// 如果找到匹配的标签,将问题添加到该标签下
if (!taggedQuestions[matchedTagLabel]) {
taggedQuestions[matchedTagLabel] = [];
}
taggedQuestions[matchedTagLabel].push(question);
} else {
// 如果找不到匹配的标签,添加到"未分类"
if (!taggedQuestions['uncategorized']) {
taggedQuestions['uncategorized'] = [];
}
taggedQuestions['uncategorized'].push(question);
}
});
setQuestionsByTag(taggedQuestions);
}, [questions, tags]);
// 处理展开/折叠标签 - 使用 useCallback 优化
const handleToggleExpand = useCallback(
tagLabel => {
// 检查是否需要加载此标签的问题数据
const shouldExpand = !expandedTags[tagLabel];
if (shouldExpand && !loadedTags[tagLabel]) {
// 如果要展开且尚未加载数据,则加载数据
fetchTagQuestions(tagLabel);
}
setExpandedTags(prev => ({
...prev,
[tagLabel]: shouldExpand
}));
},
[expandedTags, loadedTags, projectId]
);
// 获取特定标签的问题数据
const fetchTagQuestions = useCallback(
async tagLabel => {
try {
const response = await axios.get(
`/api/projects/${projectId}/questions/tree?tag=${encodeURIComponent(tagLabel)}${searchTerm ? `&input=${searchTerm}` : ''}`
);
// 更新问题数据,合并新获取的数据
setQuestions(prev => {
// 创建一个新数组,包含现有数据
const updatedQuestions = [...prev];
// 添加新获取的问题数据
response.data.forEach(newQuestion => {
// 检查是否已存在相同 ID 的问题
const existingIndex = updatedQuestions.findIndex(q => q.id === newQuestion.id);
if (existingIndex === -1) {
// 如果不存在,添加到数组
updatedQuestions.push(newQuestion);
} else {
// 如果已存在,更新数据
updatedQuestions[existingIndex] = newQuestion;
}
});
return updatedQuestions;
});
// 标记该标签已加载数据
setLoadedTags(prev => ({
...prev,
[tagLabel]: true
}));
} catch (error) {
console.error(`获取标签 "${tagLabel}" 的问题失败:`, error);
}
},
[projectId, searchTerm, expandedTags]
);
// 检查问题是否被选中 - 使用 useCallback 优化
const isQuestionSelected = useCallback(
questionKey => {
return selectedQuestions.includes(questionKey);
},
[selectedQuestions]
);
// 处理生成数据集 - 使用 useCallback 优化
const handleGenerateDataset = async (questionId, questionInfo) => {
// 设置处理状态
setProcessingQuestions(prev => ({
...prev,
[questionId]: true
}));
await generateSingleDataset({ projectId, questionId, questionInfo });
// 重置处理状态
setProcessingQuestions(prev => ({
...prev,
[questionId]: false
}));
};
// 渲染单个问题项 - 使用 useCallback 优化
const renderQuestionItem = useCallback(
(question, index, total) => {
const questionKey = question.id;
return (
<QuestionItem
key={questionKey}
question={question}
index={index}
total={total}
isSelected={isQuestionSelected(questionKey)}
onSelect={onSelectQuestion}
onDelete={onDeleteQuestion}
onGenerate={handleGenerateDataset}
onEdit={onEditQuestion}
isProcessing={processingQuestions[questionKey]}
t={t}
/>
);
},
[isQuestionSelected, onSelectQuestion, onDeleteQuestion, handleGenerateDataset, processingQuestions, t]
);
// 计算标签及其子标签下的所有问题数量 - 使用 useMemo 缓存计算结果
const tagQuestionCounts = useMemo(() => {
const counts = {};
const countQuestions = tag => {
const directQuestions = questionsByTag[tag.label] || [];
let total = directQuestions.length;
if (tag.child && tag.child.length > 0) {
for (const childTag of tag.child) {
total += countQuestions(childTag);
}
}
counts[tag.label] = total;
return total;
};
tags.forEach(countQuestions);
return counts;
}, [questionsByTag, tags]);
// 递归渲染标签树 - 使用 useCallback 优化
const renderTagTree = useCallback(
(tag, level = 0) => {
const questions = questionsByTag[tag.label] || [];
const hasQuestions = questions.length > 0;
const hasChildren = tag.child && tag.child.length > 0;
const isExpanded = expandedTags[tag.label];
const totalQuestions = tagQuestionCounts[tag.label] || 0;
return (
<Box key={tag.label}>
<TagItem
tag={tag}
level={level}
isExpanded={isExpanded}
totalQuestions={totalQuestions}
onToggle={handleToggleExpand}
t={t}
/>
{/* 只有当标签展开时才渲染子内容,减少不必要的渲染 */}
{isExpanded && (
<Collapse in={true}>
{hasChildren && (
<List disablePadding>{tag.child.map(childTag => renderTagTree(childTag, level + 1))}</List>
)}
{hasQuestions && (
<List disablePadding sx={{ mt: hasChildren ? 1 : 0 }}>
{questions.map((question, index) => renderQuestionItem(question, index, questions.length))}
</List>
)}
</Collapse>
)}
</Box>
);
},
[questionsByTag, expandedTags, tagQuestionCounts, handleToggleExpand, renderQuestionItem, t]
);
// 渲染未分类问题
const renderUncategorizedQuestions = () => {
const uncategorizedQuestions = questionsByTag['uncategorized'] || [];
if (uncategorizedQuestions.length === 0) return null;
return (
<Box>
<ListItem
button
onClick={() => handleToggleExpand('uncategorized')}
sx={{
py: 1,
bgcolor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
bgcolor: 'primary.main'
},
borderRadius: '4px',
mb: 0.5,
pr: 1
}}
>
<FolderIcon fontSize="small" sx={{ mr: 1, color: 'inherit' }} />
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: '1rem' }}>
{t('datasets.uncategorized')}
</Typography>
<Chip
label={t('datasets.questionCount', { count: uncategorizedQuestions.length })}
size="small"
sx={{ ml: 1, height: 20, fontSize: '0.7rem', color: '#fff', backgroundColor: '#333' }}
/>
</Box>
}
/>
<IconButton size="small" edge="end" sx={{ color: 'inherit' }}>
{expandedTags['uncategorized'] ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</ListItem>
<Collapse in={expandedTags['uncategorized']}>
<List disablePadding>
{uncategorizedQuestions.map((question, index) =>
renderQuestionItem(question, index, uncategorizedQuestions.length)
)}
</List>
</Collapse>
</Box>
);
};
// 如果没有标签和问题,显示空状态
if (tags.length === 0 && Object.keys(questionsByTag).length === 0) {
return (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
{t('datasets.noTagsAndQuestions')}
</Typography>
</Box>
);
}
return (
<Paper
elevation={0}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
overflow: 'auto',
p: 2,
maxHeight: '75vh'
}}
>
<List disablePadding>
{renderUncategorizedQuestions()}
{tags.map(tag => renderTagTree(tag))}
</List>
</Paper>
);
}
// 使用 memo 优化问题项渲染
const QuestionItem = memo(
({ question, index, total, isSelected, onSelect, onDelete, onGenerate, onEdit, isProcessing, t }) => {
const questionKey = question.id;
return (
<Box key={question.id}>
<ListItem
sx={{
pl: 4,
py: 1,
borderRadius: '4px',
ml: 2,
mr: 1,
mb: 0.5,
bgcolor: isSelected ? 'action.selected' : 'transparent',
'&:hover': {
bgcolor: 'action.hover'
}
}}
>
<Checkbox checked={isSelected} onChange={() => onSelect(questionKey)} size="small" />
<QuestionMarkIcon fontSize="small" sx={{ mr: 1, color: 'primary.main' }} />
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 400 }}>
{question.question}
{question.dataSites && question.dataSites.length > 0 && (
<Chip
label={t('datasets.answerCount', { count: question.dataSites.length })}
size="small"
color="primary"
variant="outlined"
sx={{ ml: 1, fontSize: '0.75rem', maxWidth: 150 }}
/>
)}
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
{t('datasets.source')}: {question.chunk?.name || question.chunkId || t('common.unknown')}
</Typography>
}
/>
<Box>
<Tooltip title={t('common.edit')}>
<IconButton
size="small"
sx={{ mr: 1 }}
onClick={() =>
onEdit({
question: question.question,
chunkId: question.chunkId,
label: question.label || 'other'
})
}
disabled={isProcessing}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('datasets.generateDataset')}>
<IconButton
size="small"
sx={{ mr: 1 }}
onClick={() => onGenerate(question.id, question.question)}
disabled={isProcessing}
>
{isProcessing ? <CircularProgress size={16} /> : <AutoFixHighIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton size="small" onClick={() => onDelete(question.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
{index < total - 1 && <Divider component="li" variant="inset" sx={{ ml: 6 }} />}
</Box>
);
}
);
// 使用 memo 优化标签项渲染
const TagItem = memo(({ tag, level, isExpanded, totalQuestions, onToggle, t }) => {
return (
<ListItem
button
onClick={() => onToggle(tag.label)}
sx={{
pl: level * 2 + 1,
py: 1,
bgcolor: level === 0 ? 'primary.light' : 'background.paper',
color: level === 0 ? 'primary.contrastText' : 'inherit',
'&:hover': {
bgcolor: level === 0 ? 'primary.main' : 'action.hover'
},
borderRadius: '4px',
mb: 0.5,
pr: 1
}}
>
{/* 内部内容保持不变 */}
<FolderIcon fontSize="small" sx={{ mr: 1, color: level === 0 ? 'inherit' : 'primary.main' }} />
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography
variant="body1"
sx={{
fontWeight: level === 0 ? 600 : 400,
fontSize: level === 0 ? '1rem' : '0.9rem'
}}
>
{tag.label}
</Typography>
{totalQuestions > 0 && (
<Chip
label={t('datasets.questionCount', { count: totalQuestions })}
size="small"
color={level === 0 ? 'default' : 'primary'}
variant={level === 0 ? 'default' : 'outlined'}
sx={{ ml: 1, height: 20, fontSize: '0.7rem', color: '#fff', backgroundColor: '#333' }}
/>
)}
</Box>
}
/>
<IconButton size="small" edge="end" sx={{ color: level === 0 ? 'inherit' : 'action.active' }}>
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</ListItem>
);
});

View File

@@ -0,0 +1,153 @@
'use client';
import { useState, useEffect } from 'react';
import { Typography, Box, Button, TextField, Grid, Card, CardContent, Alert, Snackbar } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { useTranslation } from 'react-i18next';
export default function BasicSettings({ projectId }) {
const { t } = useTranslation();
const [projectInfo, setProjectInfo] = useState({
id: '',
name: '',
description: ''
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
async function fetchProjectInfo() {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}`);
if (!response.ok) {
throw new Error(t('projects.fetchFailed'));
}
const data = await response.json();
setProjectInfo(data);
} catch (error) {
console.error('获取项目信息出错:', error);
setError(error.message);
} finally {
setLoading(false);
}
}
fetchProjectInfo();
}, [projectId, t]);
// 处理项目信息变更
const handleProjectInfoChange = e => {
const { name, value } = e.target;
setProjectInfo(prev => ({
...prev,
[name]: value
}));
};
// 保存项目信息
const handleSaveProjectInfo = async () => {
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: projectInfo.name,
description: projectInfo.description
})
});
if (!response.ok) {
throw new Error(t('projects.saveFailed'));
}
setSuccess(true);
} catch (error) {
console.error('保存项目信息出错:', error);
setError(error.message);
}
};
const handleCloseSnackbar = () => {
setSuccess(false);
setError(null);
};
if (loading) {
return <Typography>{t('common.loading')}</Typography>;
}
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('settings.basicInfo')}
</Typography>
<Grid container spacing={3}>
<Grid item xs={12}>
<TextField
fullWidth
label={t('projects.id')}
value={projectInfo.id}
disabled
helperText={t('settings.idNotEditable')}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label={t('projects.name')}
name="name"
value={projectInfo.name}
onChange={handleProjectInfoChange}
required
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label={t('projects.description')}
name="description"
value={projectInfo.description}
onChange={handleProjectInfoChange}
multiline
rows={3}
/>
</Grid>
<Grid item xs={12}>
<Button variant="contained" startIcon={<SaveIcon />} onClick={handleSaveProjectInfo}>
{t('settings.saveBasicInfo')}
</Button>
</Grid>
</Grid>
</CardContent>
<Snackbar
open={success}
autoHideDuration={2000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
{t('settings.saveSuccess')}
</Alert>
</Snackbar>
<Snackbar
open={!!error}
autoHideDuration={2000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={handleCloseSnackbar} severity="error" sx={{ width: '100%' }}>
{error}
</Alert>
</Snackbar>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,709 @@
'use client';
import { useState, useEffect } from 'react';
import {
Typography,
Box,
Button,
TextField,
Grid,
Card,
CardContent,
Slider,
InputAdornment,
Alert,
Snackbar,
FormControl,
Select,
InputLabel,
MenuItem,
Chip,
FormHelperText
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import SaveIcon from '@mui/icons-material/Save';
import useTaskSettings from '@/hooks/useTaskSettings';
export default function TaskSettings({ projectId }) {
const { t } = useTranslation();
const { taskSettings, setTaskSettings, loading, error, success, setSuccess } = useTaskSettings(projectId);
// 确保 multiTurnRounds 有正确的初始值
useEffect(() => {
if (
!loading &&
taskSettings &&
(taskSettings.multiTurnRounds === undefined || taskSettings.multiTurnRounds === null)
) {
setTaskSettings(prev => ({
...prev,
multiTurnRounds: 3 // 默认值
}));
}
}, [loading, taskSettings, setTaskSettings]);
// 处理设置变更
const handleSettingChange = e => {
const { name, value } = e.target;
setTaskSettings(prev => ({
...prev,
[name]: value
}));
};
// 处理滑块变更
const handleSliderChange = name => (event, newValue) => {
setTaskSettings(prev => ({
...prev,
[name]: newValue
}));
};
// 保存任务配置
const handleSaveTaskSettings = async () => {
try {
// 确保数组类型的数据被正确处理
const settingsToSave = { ...taskSettings };
// 确保递归分块的分隔符数组存在
if (settingsToSave.splitType === 'recursive' && settingsToSave.separatorsInput) {
if (!settingsToSave.separators || !Array.isArray(settingsToSave.separators)) {
settingsToSave.separators = settingsToSave.separatorsInput.split(',').map(item => item.trim());
}
}
console.log('Saving settings:', settingsToSave);
const response = await fetch(`/api/projects/${projectId}/tasks`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settingsToSave)
});
if (!response.ok) {
throw new Error(t('settings.saveTasksFailed'));
}
setSuccess(true);
} catch (error) {
console.error('保存任务配置出错:', error);
//setError(error.message);
}
};
const handleCloseSnackbar = () => {
setSuccess(false);
//setError(null);
};
if (loading) {
return <Typography>{t('common.loading')}</Typography>;
}
return (
<Box sx={{ position: 'relative', pb: 8 }}>
{' '}
{/* 添加底部填充,为固定按钮留出空间 */}
<Card style={{ marginBottom: 20 }}>
<CardContent>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
{t('settings.textSplitSettings')}
</Typography>
<Box sx={{ px: 2, py: 1 }}>
{/* 分块策略选择 */}
<FormControl fullWidth sx={{ mb: 3 }}>
<InputLabel id="split-type-label">{t('settings.splitType')}</InputLabel>
<Select
labelId="split-type-label"
value={taskSettings.splitType || 'recursive'}
label={t('settings.splitType')}
name="splitType"
onChange={handleSettingChange}
>
<MenuItem value="markdown">
<Box>
<Typography variant="subtitle2">{t('settings.splitTypeMarkdown')}</Typography>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('settings.splitTypeMarkdownDesc')}
</Typography>
</Box>
</MenuItem>
<MenuItem value="recursive">
<Box>
<Typography variant="subtitle2">{t('settings.splitTypeRecursive')}</Typography>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('settings.splitTypeRecursiveDesc')}
</Typography>
</Box>
</MenuItem>
<MenuItem value="text">
<Box>
<Typography variant="subtitle2">{t('settings.splitTypeText')}</Typography>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('settings.splitTypeTextDesc')}
</Typography>
</Box>
</MenuItem>
<MenuItem value="token">
<Box>
<Typography variant="subtitle2">{t('settings.splitTypeToken')}</Typography>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('settings.splitTypeTokenDesc')}
</Typography>
</Box>
</MenuItem>
<MenuItem value="code">
<Box>
<Typography variant="subtitle2">{t('settings.splitTypeCode')}</Typography>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('settings.splitTypeCodeDesc')}
</Typography>
</Box>
</MenuItem>
{/* 添加自定义符号分割策略选项 */}
<MenuItem value="custom">
<Box>
<Typography variant="subtitle2">{t('settings.splitTypeCustom')}</Typography>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('settings.splitTypeCustomDesc')}
</Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
{/* Markdown模式设置 */}
{(!taskSettings.splitType || taskSettings.splitType === 'markdown') && (
<>
<Typography id="text-split-min-length-slider" gutterBottom>
{t('settings.minLength')}: {taskSettings.textSplitMinLength}
</Typography>
<Slider
value={taskSettings.textSplitMinLength || 2000}
onChange={handleSliderChange('textSplitMinLength')}
aria-labelledby="text-split-min-length-slider"
valueLabelDisplay="auto"
step={100}
marks
min={100}
max={5000}
/>
<Typography id="text-split-max-length-slider" gutterBottom sx={{ mt: 3 }}>
{t('settings.maxLength')}: {taskSettings.textSplitMaxLength}
</Typography>
<Slider
value={taskSettings.textSplitMaxLength || 3000}
onChange={handleSliderChange('textSplitMaxLength')}
aria-labelledby="text-split-max-length-slider"
valueLabelDisplay="auto"
step={100}
marks
min={2000}
max={20000}
/>
</>
)}
{/* 通用 LangChain 参数设置 */}
{taskSettings.splitType && taskSettings.splitType !== 'markdown' && (
<>
<Typography id="chunk-size-slider" gutterBottom>
{t('settings.chunkSize')}: {taskSettings.chunkSize || 3000}
</Typography>
<Slider
value={taskSettings.chunkSize || 3000}
onChange={handleSliderChange('chunkSize')}
aria-labelledby="chunk-size-slider"
valueLabelDisplay="auto"
step={100}
marks
min={500}
max={20000}
/>
<Typography id="chunk-overlap-slider" gutterBottom sx={{ mt: 3 }}>
{t('settings.chunkOverlap')}: {taskSettings.chunkOverlap || 200}
</Typography>
<Slider
value={taskSettings.chunkOverlap || 200}
onChange={handleSliderChange('chunkOverlap')}
aria-labelledby="chunk-overlap-slider"
valueLabelDisplay="auto"
step={50}
marks
min={0}
max={1000}
/>
</>
)}
{/* Text 分块器特殊设置 */}
{taskSettings.splitType === 'text' && (
<TextField
fullWidth
label={t('settings.separator')}
name="separator"
value={taskSettings.separator || '\\n\\n'}
onChange={handleSettingChange}
helperText={t('settings.separatorHelper')}
sx={{ mt: 3 }}
/>
)}
{/* 自定义符号分块器特殊设置 */}
{taskSettings.splitType === 'custom' && (
<TextField
fullWidth
label={t('settings.customSeparator')}
name="customSeparator"
value={taskSettings.customSeparator || '---'}
onChange={handleSettingChange}
helperText={t('settings.customSeparatorHelper')}
sx={{ mt: 3 }}
/>
)}
{/* Code 分块器特殊设置 */}
{taskSettings.splitType === 'code' && (
<FormControl fullWidth sx={{ mt: 3 }}>
<InputLabel id="code-language-label">{t('settings.codeLanguage')}</InputLabel>
<Select
labelId="code-language-label"
value={taskSettings.splitLanguage || 'js'}
label={t('settings.codeLanguage')}
name="splitLanguage"
onChange={handleSettingChange}
>
<MenuItem value="js">JavaScript</MenuItem>
<MenuItem value="python">Python</MenuItem>
<MenuItem value="java">Java</MenuItem>
<MenuItem value="go">Go</MenuItem>
<MenuItem value="ruby">Ruby</MenuItem>
<MenuItem value="cpp">C++</MenuItem>
<MenuItem value="c">C</MenuItem>
<MenuItem value="csharp">C#</MenuItem>
<MenuItem value="php">PHP</MenuItem>
<MenuItem value="rust">Rust</MenuItem>
<MenuItem value="typescript">TypeScript</MenuItem>
<MenuItem value="swift">Swift</MenuItem>
<MenuItem value="kotlin">Kotlin</MenuItem>
<MenuItem value="scala">Scala</MenuItem>
</Select>
<FormHelperText>{t('settings.codeLanguageHelper')}</FormHelperText>
</FormControl>
)}
{/* Recursive 分块器特殊设置 */}
{taskSettings.splitType === 'recursive' && (
<Box sx={{ mt: 3 }}>
<Typography gutterBottom>{t('settings.separators')}</Typography>
<TextField
fullWidth
label={t('settings.separatorsInput')}
name="separatorsInput"
value={taskSettings.separatorsInput || '|,##,>,-'}
onChange={e => {
const value = e.target.value;
// 同时更新输入框值和分隔符数组
setTaskSettings(prev => ({
...prev,
separatorsInput: value,
separators: value.split(',').map(item => item.trim())
}));
}}
helperText={t('settings.separatorsHelper')}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
{(taskSettings.separators || ['|', '##', '>', '-']).map((sep, index) => (
<Chip key={index} label={sep} variant="outlined" />
))}
</Box>
</Box>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 3 }}>
{t('settings.textSplitDescription')}
</Typography>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
<Card style={{ marginBottom: 20 }}>
<CardContent>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
{t('settings.questionGenSettings')}
</Typography>
<Box sx={{ px: 2, py: 1 }}>
<Typography id="question-generation-length-slider" gutterBottom>
{t('settings.questionGenLength', { length: taskSettings.questionGenerationLength })}
</Typography>
<Slider
value={taskSettings.questionGenerationLength}
onChange={handleSliderChange('questionGenerationLength')}
aria-labelledby="question-generation-length-slider"
valueLabelDisplay="auto"
step={10}
marks
min={10}
max={1000}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{t('settings.questionGenDescription')}
</Typography>
<Typography id="question-mark-removing-probability-slider" gutterBottom sx={{ mt: 3 }}>
{t('settings.questionMaskRemovingProbability', {
probability: taskSettings.questionMaskRemovingProbability
})}
</Typography>
<Slider
value={taskSettings.questionMaskRemovingProbability}
onChange={handleSliderChange('questionMaskRemovingProbability')}
aria-labelledby="question-generation-length-slider"
valueLabelDisplay="auto"
step={5}
marks
min={0}
max={100}
/>
<TextField
style={{ marginTop: 20 }}
fullWidth
label={t('settings.concurrencyLimit')}
name="concurrencyLimit"
value={taskSettings.concurrencyLimit}
onChange={handleSettingChange}
type="number"
helperText={t('settings.concurrencyLimitHelper')}
/>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
<Card style={{ marginBottom: 20 }}>
<CardContent>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
{t('settings.pdfSettings')}
</Typography>
<TextField
fullWidth
label={t('settings.minerUToken')}
name="minerUToken"
value={taskSettings.minerUToken}
onChange={handleSettingChange}
type="password"
helperText={t('settings.minerUHelper')}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label={t('settings.minerULocalUrl')}
name="minerULocalUrl"
value={taskSettings.minerULocalUrl}
onChange={handleSettingChange}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label={t('settings.visionConcurrencyLimit')}
name="visionConcurrencyLimit"
value={taskSettings.visionConcurrencyLimit ? taskSettings.visionConcurrencyLimit : 5}
onChange={handleSettingChange}
type="number"
/>
</Grid>
</Grid>
</CardContent>
</Card>
{/* 多轮对话数据集设置 */}
<Card style={{ marginBottom: 20 }}>
<CardContent>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
{t('settings.multiTurnSettings')}
</Typography>
<Box sx={{ px: 2, py: 1 }}>
{/* 系统提示词 */}
<TextField
fullWidth
label={t('settings.multiTurnSystemPrompt')}
name="multiTurnSystemPrompt"
value={taskSettings.multiTurnSystemPrompt || ''}
onChange={handleSettingChange}
multiline
rows={3}
helperText={t('settings.multiTurnSystemPromptHelper')}
sx={{ mb: 2 }}
/>
{/* 对话场景 */}
<TextField
fullWidth
label={t('settings.multiTurnScenario')}
name="multiTurnScenario"
value={taskSettings.multiTurnScenario || ''}
onChange={handleSettingChange}
helperText={t('settings.multiTurnScenarioHelper')}
sx={{ mb: 2 }}
/>
{/* 对话轮数 */}
<Typography id="multi-turn-rounds-slider" gutterBottom sx={{ mt: 2 }}>
{t('settings.multiTurnRounds', { rounds: taskSettings.multiTurnRounds || 3 })}
</Typography>
<Slider
value={taskSettings.multiTurnRounds || 3}
onChange={handleSliderChange('multiTurnRounds')}
aria-labelledby="multi-turn-rounds-slider"
valueLabelDisplay="auto"
step={1}
marks
min={2}
max={8}
sx={{ mb: 2 }}
/>
{/* 角色A设定 */}
<TextField
fullWidth
label={t('settings.multiTurnRoleA')}
name="multiTurnRoleA"
value={taskSettings.multiTurnRoleA || ''}
onChange={handleSettingChange}
multiline
rows={2}
helperText={t('settings.multiTurnRoleAHelper')}
sx={{ mb: 2 }}
/>
{/* 角色B设定 */}
<TextField
fullWidth
label={t('settings.multiTurnRoleB')}
name="multiTurnRoleB"
value={taskSettings.multiTurnRoleB || ''}
onChange={handleSettingChange}
multiline
rows={2}
helperText={t('settings.multiTurnRoleBHelper')}
sx={{ mb: 2 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{t('settings.multiTurnDescription')}
</Typography>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
{/* 测试集生成设置 */}
<Card style={{ marginBottom: 20 }}>
<CardContent>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
{t('settings.evalQuestionSettings')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('settings.evalQuestionSettingsDescription')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={2.4}>
<TextField
fullWidth
label={t('settings.evalTrueFalseRatio')}
type="number"
value={taskSettings.evalQuestionTypeRatios?.true_false || 0}
onChange={e => {
const value = Math.max(0, parseInt(e.target.value) || 0);
setTaskSettings(prev => ({
...prev,
evalQuestionTypeRatios: {
...prev.evalQuestionTypeRatios,
true_false: value
}
}));
}}
InputProps={{ inputProps: { min: 0 } }}
/>
</Grid>
{/* 单选题 */}
<Grid item xs={12} sm={6} md={2.4}>
<TextField
fullWidth
label={t('settings.evalSingleChoiceRatio')}
type="number"
value={taskSettings.evalQuestionTypeRatios?.single_choice || 0}
onChange={e => {
const value = Math.max(0, parseInt(e.target.value) || 0);
setTaskSettings(prev => ({
...prev,
evalQuestionTypeRatios: {
...prev.evalQuestionTypeRatios,
single_choice: value
}
}));
}}
InputProps={{ inputProps: { min: 0 } }}
/>
</Grid>
{/* 多选题 */}
<Grid item xs={12} sm={6} md={2.4}>
<TextField
fullWidth
label={t('settings.evalMultipleChoiceRatio')}
type="number"
value={taskSettings.evalQuestionTypeRatios?.multiple_choice || 0}
onChange={e => {
const value = Math.max(0, parseInt(e.target.value) || 0);
setTaskSettings(prev => ({
...prev,
evalQuestionTypeRatios: {
...prev.evalQuestionTypeRatios,
multiple_choice: value
}
}));
}}
InputProps={{ inputProps: { min: 0 } }}
/>
</Grid>
{/* 固定短答案 */}
<Grid item xs={12} sm={6} md={2.4}>
<TextField
fullWidth
label={t('settings.evalShortAnswerRatio')}
type="number"
value={taskSettings.evalQuestionTypeRatios?.short_answer || 0}
onChange={e => {
const value = Math.max(0, parseInt(e.target.value) || 0);
setTaskSettings(prev => ({
...prev,
evalQuestionTypeRatios: {
...prev.evalQuestionTypeRatios,
short_answer: value
}
}));
}}
InputProps={{ inputProps: { min: 0 } }}
/>
</Grid>
{/* 开放式回答 */}
<Grid item xs={12} sm={6} md={2.4}>
<TextField
fullWidth
label={t('settings.evalOpenEndedRatio')}
type="number"
value={taskSettings.evalQuestionTypeRatios?.open_ended || 0}
onChange={e => {
const value = Math.max(0, parseInt(e.target.value) || 0);
setTaskSettings(prev => ({
...prev,
evalQuestionTypeRatios: {
...prev.evalQuestionTypeRatios,
open_ended: value
}
}));
}}
InputProps={{ inputProps: { min: 0 } }}
/>
</Grid>
</Grid>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2 }}>
{t('settings.evalQuestionRatioHelper')}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
<Card style={{ marginBottom: 20 }}>
<CardContent>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
{t('settings.huggingfaceSettings')}
</Typography>
<TextField
fullWidth
label={t('settings.huggingfaceToken')}
name="huggingfaceToken"
value={taskSettings.huggingfaceToken || ''}
onChange={handleSettingChange}
type="password"
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Snackbar
open={success}
autoHideDuration={2000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
{t('settings.saveSuccess')}
</Alert>
</Snackbar>
<Snackbar
open={!!error}
autoHideDuration={2000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={handleCloseSnackbar} severity="error" sx={{ width: '100%' }}>
{error}
</Alert>
</Snackbar>
{/* 吸底保存按钮 */}
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '8px',
backgroundColor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
zIndex: 1100,
display: 'flex',
justifyContent: 'center',
boxShadow: 3
}}
>
<Button
variant="contained"
color="primary"
size="medium"
startIcon={<SaveIcon />}
onClick={handleSaveTaskSettings}
>
{t('settings.saveTaskConfig')}
</Button>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import React from 'react';
import { IconButton, Tooltip } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import StopCircleIcon from '@mui/icons-material/StopCircle';
import { useTranslation } from 'react-i18next';
// 任务操作组件
export default function TaskActions({ task, onAbort, onDelete }) {
const { t } = useTranslation();
// 处理中的任务显示中断按钮,其他状态显示删除按钮
return task.status === 0 ? (
<Tooltip title={t('tasks.actions.abort')} arrow>
<IconButton size="small" onClick={() => onAbort(task.id)}>
<StopCircleIcon fontSize="small" color="warning" />
</IconButton>
</Tooltip>
) : (
<Tooltip title={t('tasks.actions.delete')} arrow>
<IconButton size="small" onClick={() => onDelete(task.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import React from 'react';
import {
Box,
FormControl,
InputLabel,
Select,
MenuItem,
OutlinedInput,
IconButton,
Tooltip,
CircularProgress
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import { useTranslation } from 'react-i18next';
export default function TaskFilters({ statusFilter, setStatusFilter, typeFilter, setTypeFilter, loading, onRefresh }) {
const { t } = useTranslation();
const taskTypeOptions = [
'text-processing',
'file-processing',
'pdf-processing',
'question-generation',
'answer-generation',
'data-cleaning',
'data-distillation',
'eval-generation',
'multi-turn-generation',
'image-question-generation'
];
return (
<Box sx={{ display: 'flex', gap: 2 }}>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>{t('tasks.filters.status')}</InputLabel>
<Select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
input={<OutlinedInput label={t('tasks.filters.status')} />}
>
<MenuItem value="all">{t('datasets.filterAll')}</MenuItem>
<MenuItem value="0">{t('tasks.status.processing')}</MenuItem>
<MenuItem value="1">{t('tasks.status.completed')}</MenuItem>
<MenuItem value="2">{t('tasks.status.failed')}</MenuItem>
<MenuItem value="3">{t('tasks.status.aborted')}</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>{t('tasks.filters.type')}</InputLabel>
<Select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
input={<OutlinedInput label={t('tasks.filters.type')} />}
>
<MenuItem value="all">{t('datasets.filterAll')}</MenuItem>
{taskTypeOptions.map(type => (
<MenuItem key={type} value={type}>
{t(`tasks.types.${type}`, { defaultValue: type })}
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title={t('tasks.actions.refresh')}>
<IconButton onClick={onRefresh} disabled={loading}>
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
</IconButton>
</Tooltip>
</Box>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import React from 'react';
import { Stack, LinearProgress, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
// 任务进度组件
export default function TaskProgress({ task }) {
const { t } = useTranslation();
// 如果没有总数,则不显示进度条
if (task.totalCount === 0) return '-';
// 计算进度百分比
const progress = (task.completedCount / task.totalCount) * 100;
return (
<Stack direction="column" spacing={0.5}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 3,
width: 120,
'& .MuiLinearProgress-bar': {
transition: 'transform 0.5s ease'
}
}}
/>
<Typography variant="caption" color="text.secondary">
{task.completedCount} / {task.totalCount} ({Math.round(progress)}%)
</Typography>
</Stack>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import React from 'react';
import { Chip, CircularProgress, Box } from '@mui/material';
import { useTranslation } from 'react-i18next';
// 任务状态显示组件
export default function TaskStatusChip({ status }) {
const { t } = useTranslation();
// 状态映射配置
const STATUS_CONFIG = {
0: {
label: t('tasks.status.processing'),
color: 'warning',
loading: true
},
1: {
label: t('tasks.status.completed'),
color: 'success'
},
2: {
label: t('tasks.status.failed'),
color: 'error'
},
3: {
label: t('tasks.status.aborted'),
color: 'default'
}
};
const statusInfo = STATUS_CONFIG[status] || {
label: t('tasks.status.unknown'),
color: 'default'
};
// 处理中状态显示加载动画
if (status === 0) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color="warning" />
<Chip label={statusInfo.label} color={statusInfo.color} size="small" />
</Box>
);
}
return <Chip label={statusInfo.label} color={statusInfo.color} size="small" />;
}

View File

@@ -0,0 +1,293 @@
'use client';
import React from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography,
CircularProgress,
Box,
TablePagination,
Tooltip
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { formatDistanceToNow } from 'date-fns';
import { zhCN, enUS } from 'date-fns/locale';
import TaskStatusChip from './TaskStatusChip';
import TaskProgress from './TaskProgress';
import TaskActions from './TaskActions';
export default function TasksTable({
tasks,
loading,
handleAbortTask,
handleDeleteTask,
page,
rowsPerPage,
handleChangePage,
handleChangeRowsPerPage,
totalCount
}) {
const { t, i18n } = useTranslation();
const formatDate = dateString => {
if (!dateString) return '-';
const date = new Date(dateString);
return formatDistanceToNow(date, {
addSuffix: true,
locale: i18n.language === 'zh-CN' ? zhCN : enUS
});
};
const calculateDuration = (startTimeStr, endTimeStr) => {
if (!startTimeStr || !endTimeStr) return '-';
try {
const startTime = new Date(startTimeStr);
const endTime = new Date(endTimeStr);
const duration = endTime - startTime;
const seconds = Math.floor(duration / 1000);
if (seconds < 60) {
return t('tasks.duration.seconds', { seconds });
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return t('tasks.duration.minutes', { minutes, seconds: remainingSeconds });
}
const hours = Math.floor(seconds / 3600);
const remainingMinutes = Math.floor((seconds % 3600) / 60);
return t('tasks.duration.hours', { hours, minutes: remainingMinutes });
} catch (error) {
console.error('Failed to calculate duration:', error);
return '-';
}
};
const parseModelInfo = modelInfoString => {
let modelInfo = '';
try {
const parsedModel = JSON.parse(modelInfoString);
modelInfo = parsedModel.modelName || parsedModel.name || '-';
} catch {
modelInfo = modelInfoString || '-';
}
return modelInfo;
};
const toTaskTypeLabel = taskType => {
if (!taskType) return '-';
return String(taskType)
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const getLocalizedTaskType = taskType => {
return t(`tasks.types.${taskType}`, { defaultValue: toTaskTypeLabel(taskType) });
};
const parseJsonSafely = input => {
if (!input || typeof input !== 'string') return null;
try {
return JSON.parse(input);
} catch {
return null;
}
};
const formatTaskNote = task => {
const note = String(task?.note || '').trim();
if (!note) return '-';
const noteJson = parseJsonSafely(note);
if (noteJson) {
if (Array.isArray(noteJson.chunkIds)) {
return t('tasks.notes.selectedChunks', { count: noteJson.chunkIds.length });
}
if (Array.isArray(noteJson.fileList)) {
return t('tasks.notes.fileBatch', {
count: noteJson.fileList.length,
strategy: noteJson.strategy || '-'
});
}
return t('tasks.notes.jsonParams');
}
if (note === 'No chunks require question generation' || note.startsWith('No chunks require question gen')) {
return t('tasks.notes.noChunksQuestion');
}
if (note === 'No chunks require cleaning' || note.startsWith('No chunks require clean')) {
return t('tasks.notes.noChunksCleaning');
}
if (note.startsWith('Processing failed:')) {
return t('tasks.notes.processingFailed', {
error: note.replace('Processing failed:', '').trim()
});
}
const summaryMatch = note.match(/Processed:\s*(\d+)\/(\d+),\s*succeeded:\s*(\d+),\s*failed:\s*(\d+)/i);
if (summaryMatch) {
const [, processed, total, succeeded, failed] = summaryMatch;
const questionMatch = note.match(/questions generated:\s*(\d+)/i);
if (questionMatch) {
return t('tasks.notes.questionSummary', {
processed,
total,
succeeded,
failed,
generated: questionMatch[1]
});
}
const datasetMatch = note.match(/datasets generated:\s*(\d+)/i);
if (datasetMatch) {
return t('tasks.notes.datasetSummary', {
processed,
total,
succeeded,
failed,
generated: datasetMatch[1]
});
}
const cleaningMatch = note.match(/total original length:\s*(\d+),\s*total cleaned length:\s*(\d+)/i);
if (cleaningMatch) {
return t('tasks.notes.cleaningSummary', {
processed,
total,
succeeded,
failed,
original: cleaningMatch[1],
cleaned: cleaningMatch[2]
});
}
return t('tasks.notes.genericSummary', {
processed,
total,
succeeded,
failed
});
}
return note;
};
const truncateNote = (note, maxLength = 48) => {
if (!note) return '-';
if (note.length <= maxLength) return note;
return `${note.substring(0, maxLength)}...`;
};
return (
<React.Fragment>
<TableContainer component={Paper} elevation={1} sx={{ borderRadius: 2, mb: 2 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('tasks.table.type')}</TableCell>
<TableCell>{t('tasks.table.status')}</TableCell>
<TableCell>{t('tasks.table.progress')}</TableCell>
<TableCell>{t('tasks.table.createTime')}</TableCell>
<TableCell>{t('tasks.table.duration')}</TableCell>
<TableCell>{t('tasks.table.model')}</TableCell>
<TableCell>{t('tasks.table.note')}</TableCell>
<TableCell align="right">{t('tasks.table.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading && tasks.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<CircularProgress size={40} />
<Typography variant="body2" sx={{ mt: 2 }}>
{t('tasks.loading')}
</Typography>
</Box>
</TableCell>
</TableRow>
) : tasks.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
<Typography variant="body1">{t('tasks.empty')}</Typography>
</TableCell>
</TableRow>
) : (
tasks.map(task => {
const noteText = formatTaskNote(task);
return (
<TableRow key={task.id}>
<TableCell>{getLocalizedTaskType(task.taskType)}</TableCell>
<TableCell>
<TaskStatusChip status={task.status} />
</TableCell>
<TableCell>
<TaskProgress task={task} />
</TableCell>
<TableCell>{formatDate(task.createAt)}</TableCell>
<TableCell>{task.endTime ? calculateDuration(task.startTime, task.endTime) : '-'}</TableCell>
<TableCell>{parseModelInfo(task.modelInfo)}</TableCell>
<TableCell>
{noteText !== '-' ? (
<Tooltip title={noteText} arrow placement="top">
<Typography
variant="body2"
sx={{
cursor: 'pointer',
'&:hover': { color: 'primary.main' }
}}
>
{truncateNote(noteText)}
</Typography>
</Tooltip>
) : (
'-'
)}
</TableCell>
<TableCell align="right">
<TaskActions task={task} onAbort={handleAbortTask} onDelete={handleDeleteTask} />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{tasks.length > 0 && (
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[5, 10, 25]}
labelRowsPerPage={t('datasets.rowsPerPage')}
labelDisplayedRows={({ count }) => {
const calculatedFrom = page * rowsPerPage + 1;
const calculatedTo = Math.min((page + 1) * rowsPerPage, count);
return t('datasets.pagination', {
from: calculatedFrom,
to: calculatedTo,
count
});
}}
/>
)}
</React.Fragment>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
RadioGroup,
FormControlLabel,
Radio,
FormControl,
FormLabel,
Box,
Typography,
Alert,
CircularProgress
} from '@mui/material';
import { useTranslation } from 'react-i18next';
/**
* 批量编辑文本块对话框
* @param {Object} props
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调
* @param {Function} props.onConfirm - 确认编辑的回调
* @param {Array} props.selectedChunks - 选中的文本块ID数组
* @param {number} props.totalChunks - 文本块总数
* @param {boolean} props.loading - 是否正在处理
*/
export default function BatchEditChunksDialog({
open,
onClose,
onConfirm,
selectedChunks = [],
totalChunks = 0,
loading = false
}) {
const { t } = useTranslation();
const [position, setPosition] = useState('start'); // 'start' 或 'end'
const [content, setContent] = useState('');
const [error, setError] = useState('');
// 处理位置变更
const handlePositionChange = event => {
setPosition(event.target.value);
};
// 处理内容变更
const handleContentChange = event => {
setContent(event.target.value);
if (error) setError('');
};
// 处理确认
const handleConfirm = () => {
if (!content.trim()) {
setError(t('batchEdit.contentRequired'));
return;
}
onConfirm({
position,
content: content.trim(),
chunkIds: selectedChunks
});
};
// 处理关闭
const handleClose = () => {
if (!loading) {
setContent('');
setError('');
setPosition('start');
onClose();
}
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth disableEscapeKeyDown={loading}>
<DialogTitle>{t('batchEdit.title')}</DialogTitle>
<DialogContent>
<Box sx={{ py: 1 }}>
{/* 选择提示 */}
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="body2">
{selectedChunks.length === totalChunks
? t('batchEdit.allChunksSelected', { count: totalChunks })
: t('batchEdit.selectedChunks', {
selected: selectedChunks.length,
total: totalChunks
})}
</Typography>
</Alert>
{/* 位置选择 */}
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
<FormLabel component="legend" sx={{ mb: 1 }}>
{t('batchEdit.position')}
</FormLabel>
<RadioGroup value={position} onChange={handlePositionChange} row>
<FormControlLabel value="start" control={<Radio />} label={t('batchEdit.atBeginning')} />
<FormControlLabel value="end" control={<Radio />} label={t('batchEdit.atEnd')} />
</RadioGroup>
</FormControl>
{/* 内容输入 */}
<TextField
fullWidth
label={t('batchEdit.contentToAdd')}
multiline
rows={6}
value={content}
onChange={handleContentChange}
placeholder={t('batchEdit.contentPlaceholder')}
error={!!error}
helperText={error || t('batchEdit.contentHelp')}
disabled={loading}
sx={{ mb: 2 }}
/>
{/* 预览示例 */}
{content.trim() && (
<Box
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
mb: 2
}}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{t('batchEdit.preview')}:
</Typography>
<Box
sx={{
fontFamily: 'monospace',
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
color: 'text.secondary'
}}
>
{position === 'start' ? (
<>
<span style={{ backgroundColor: '#e3f2fd', padding: '2px 4px' }}>{content}</span>
{'\n\n[原始文本块内容...]'}
</>
) : (
<>
{'[原始文本块内容...]\n\n'}
<span style={{ backgroundColor: '#e3f2fd', padding: '2px 4px' }}>{content}</span>
</>
)}
</Box>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
{t('common.cancel')}
</Button>
<Button
onClick={handleConfirm}
variant="contained"
disabled={loading || !content.trim() || selectedChunks.length === 0}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{loading ? t('batchEdit.processing') : t('batchEdit.applyToChunks', { count: selectedChunks.length })}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
CircularProgress
} from '@mui/material';
import { useTranslation } from 'react-i18next';
export default function ChunkBatchDeleteDialog({ open, onClose, onConfirm, loading, count }) {
const { t } = useTranslation();
return (
<Dialog
open={open}
onClose={loading ? undefined : onClose}
aria-labelledby="batch-delete-dialog-title"
aria-describedby="batch-delete-dialog-description"
>
<DialogTitle id="batch-delete-dialog-title">
{t('textSplit.batchDeleteChunksConfirmTitle', { defaultValue: '确认批量删除' })}
</DialogTitle>
<DialogContent>
<DialogContentText id="batch-delete-dialog-description">
{t('textSplit.batchDeleteChunksConfirmMessage', {
count,
defaultValue: `您确定要删除选中的 ${count} 个文本块吗?此操作不可恢复。`
})}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
{t('common.cancel')}
</Button>
<Button onClick={onConfirm} color="error" variant="contained" disabled={loading}>
{loading ? <CircularProgress size={20} /> : t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,449 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
IconButton,
Chip,
Checkbox,
Tooltip,
Card,
CardContent,
CardActions,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
CircularProgress
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import VisibilityIcon from '@mui/icons-material/Visibility';
import QuizIcon from '@mui/icons-material/Quiz';
import EditIcon from '@mui/icons-material/Edit';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import AssignmentIcon from '@mui/icons-material/Assignment';
import { useTheme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
// 编辑文本块对话框组件
const EditChunkDialog = ({ open, chunk, onClose, onSave }) => {
const [content, setContent] = useState(chunk?.content || '');
const { t } = useTranslation();
// 当文本块变化时更新内容
useEffect(() => {
if (chunk?.content) {
setContent(chunk.content);
}
}, [chunk]);
const handleSave = () => {
onSave(content);
onClose();
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{t('textSplit.editChunk', { chunkId: chunk?.name })}</DialogTitle>
<DialogContent dividers>
<TextField
fullWidth
multiline
rows={15}
value={content}
onChange={e => setContent(e.target.value)}
variant="outlined"
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSave} variant="contained" color="primary">
{t('common.save')}
</Button>
</DialogActions>
</Dialog>
);
};
export default function ChunkCard({
chunk,
selected,
onSelect,
onView,
onDelete,
onGenerateQuestions,
onDataCleaning,
onEdit,
onGenerateEvalQuestions, // 新增:生成测评题目的回调
projectId,
selectedModel // 添加selectedModel参数
}) {
const theme = useTheme();
const { t } = useTranslation();
const router = useRouter();
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [chunkForEdit, setChunkForEdit] = useState(null);
const [generatingQuestions, setGeneratingQuestions] = useState(false);
const [generatingEval, setGeneratingEval] = useState(false);
// 获取文本预览
const getTextPreview = (content, maxLength = 150) => {
if (!content) return '';
return content.length > maxLength ? `${content.substring(0, maxLength)}...` : content;
};
// 检查是否有已生成的问题
const hasQuestions = chunk.questions && chunk.questions.length > 0;
// 处理编辑按钮点击
const handleEditClick = async () => {
try {
// 显示加载状态
console.log('正在获取文本块完整内容...');
console.log('projectId:', projectId, 'chunkId:', chunk.id);
// 先获取完整的文本块内容,使用从外部传入的 projectId
const response = await fetch(`/api/projects/${projectId}/chunks/${encodeURIComponent(chunk.id)}`);
if (!response.ok) {
throw new Error(t('textSplit.fetchChunkFailed'));
}
const data = await response.json();
console.log('获取文本块完整内容成功:', data);
// 先设置完整数据,再打开对话框(与 ChunkList.js 中的实现一致)
setChunkForEdit(data);
setEditDialogOpen(true);
} catch (error) {
console.error(t('textSplit.fetchChunkError'), error);
// 如果出错,使用原始预览数据
alert(t('textSplit.fetchChunkError'));
}
};
// 处理保存编辑内容
const handleSaveEdit = newContent => {
if (onEdit) {
onEdit(chunk.id, newContent);
}
};
// 处理生成单个问题 - 后台执行不阻塞UI
const handleGenerateQuestionsClick = async () => {
setGeneratingQuestions(true);
try {
await onGenerateQuestions([chunk.id]);
} finally {
// Always release loading state, even when generation fails.
setTimeout(() => {
setGeneratingQuestions(false);
}, 500);
}
};
// 处理生成测评题目
const handleGenerateEvalQuestionsClick = async () => {
if (!onGenerateEvalQuestions) return;
setGeneratingEval(true);
try {
await onGenerateEvalQuestions(chunk.id);
} finally {
// 延迟关闭加载状态
setTimeout(() => {
setGeneratingEval(false);
}, 500);
}
};
return (
<>
<Card
variant="outlined"
sx={{
mb: 1,
position: 'relative',
transition: 'all 0.2s ease-in-out',
borderColor: selected ? theme.palette.primary.main : theme.palette.divider,
bgcolor: selected ? `${theme.palette.primary.main}10` : 'transparent',
borderRadius: 2,
'&:hover': {
borderColor: theme.palette.primary.main,
transform: 'translateY(-2px)',
boxShadow: `0 4px 12px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.1)'}`
}
}}
>
<CardContent sx={{ pt: 2.5, px: 2.5, pb: '16px !important' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
<Checkbox
checked={selected}
onChange={onSelect}
sx={{
mr: 1,
'&.Mui-checked': {
color: theme.palette.primary.main
}
}}
/>
<Box sx={{ flexGrow: 1 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
flexWrap: 'wrap',
gap: 1
}}
>
<Typography
variant="subtitle1"
fontWeight="600"
sx={{
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.dark
}}
>
{chunk.name}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={`${chunk.fileName || t('textSplit.unknownFile')}`}
size="small"
color="primary"
variant="outlined"
sx={{
borderRadius: 1,
fontWeight: 500,
'& .MuiChip-label': { px: 1 }
}}
/>
<Chip
label={`${chunk.size || 0} ${t('textSplit.characters')}`}
size="small"
color="secondary"
variant="outlined"
sx={{
borderRadius: 1,
fontWeight: 500,
'& .MuiChip-label': { px: 1 }
}}
/>
{chunk.Questions.length > 0 && (
<Tooltip
title={
<Box sx={{ p: 1 }} style={{ maxHeight: '200px', overflow: 'auto' }}>
{chunk.Questions.map((q, index) => (
<Typography key={index} variant="body2" sx={{ mb: 0.5 }}>
{index + 1}. {q.question}
</Typography>
))}
</Box>
}
arrow
placement="top"
>
<Chip
label={`${t('textSplit.generatedQuestions', { count: chunk.Questions.length })}`}
size="small"
color="success"
variant="outlined"
sx={{
borderRadius: 1,
fontWeight: 500,
'& .MuiChip-label': { px: 1 }
}}
onClick={() => {
if (!projectId) return;
router.push(`/projects/${projectId}/questions`);
}}
/>
</Tooltip>
)}
{chunk.EvalDatasets && chunk.EvalDatasets.length > 0 && (
<Chip
label={`${t('textSplit.generatedEvalQuestions', { count: chunk.EvalDatasets.length })}`}
size="small"
color="secondary"
variant="outlined"
sx={{
borderRadius: 1,
fontWeight: 500,
'& .MuiChip-label': { px: 1 }
}}
onClick={() => {
if (!projectId) return;
router.push(`/projects/${projectId}/eval-datasets`);
}}
/>
)}
</Box>
</Box>
<Typography
variant="body2"
color="textSecondary"
sx={{
mb: 1,
lineHeight: 1.6,
opacity: 0.85
}}
>
{getTextPreview(chunk.content)}
</Typography>
</Box>
</Box>
</CardContent>
<CardActions
sx={{
justifyContent: 'flex-end',
px: 2.5,
pb: 2,
gap: 1,
'& .MuiIconButton-root': {
transition: 'all 0.2s',
'&:hover': {
transform: 'scale(1.1)'
}
}
}}
>
<Tooltip title={t('datasets.viewDetails')}>
<IconButton
size="small"
color="primary"
onClick={onView}
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(33, 150, 243, 0.08)'
}}
>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip
title={
selectedModel?.id
? t('textSplit.generateQuestions')
: t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' })
}
>
<span>
<IconButton
size="small"
color="info"
onClick={handleGenerateQuestionsClick}
disabled={!selectedModel?.id || generatingQuestions}
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(41, 182, 246, 0.08)' : 'rgba(2, 136, 209, 0.08)',
'&.Mui-disabled': {
opacity: 0.6,
pointerEvents: 'auto' // 允许鼠标悬停显示tooltip
}
}}
>
{generatingQuestions ? <CircularProgress size={20} color="inherit" /> : <QuizIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip
title={
selectedModel?.id
? t('textSplit.generateEvalQuestions', { defaultValue: '生成测试集' })
: t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' })
}
>
<span>
<IconButton
size="small"
color="secondary"
onClick={handleGenerateEvalQuestionsClick}
disabled={!selectedModel?.id || generatingEval}
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(156, 39, 176, 0.08)' : 'rgba(123, 31, 162, 0.08)',
'&.Mui-disabled': {
opacity: 0.6,
pointerEvents: 'auto'
}
}}
>
{generatingEval ? <CircularProgress size={20} color="inherit" /> : <AssignmentIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip
title={
selectedModel?.id
? t('textSplit.dataCleaning', { defaultValue: '数据清洗' })
: t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' })
}
>
<span>
<IconButton
size="small"
color="success"
onClick={onDataCleaning}
disabled={!selectedModel?.id}
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(76, 175, 80, 0.08)' : 'rgba(46, 125, 50, 0.08)',
'&.Mui-disabled': {
opacity: 0.6,
pointerEvents: 'auto' // 允许鼠标悬停显示tooltip
}
}}
>
<CleaningServicesIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={t('textSplit.editChunk', { chunkId: chunk.name })}>
<IconButton
size="small"
color="warning"
onClick={handleEditClick}
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 152, 0, 0.08)' : 'rgba(237, 108, 2, 0.08)'
}}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
color="error"
onClick={onDelete}
sx={{
bgcolor: theme.palette.mode === 'dark' ? 'rgba(244, 67, 54, 0.08)' : 'rgba(211, 47, 47, 0.08)'
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</CardActions>
</Card>
{/* 编辑文本块对话框 */}
<EditChunkDialog
open={editDialogOpen}
chunk={chunkForEdit || chunk}
onClose={() => {
setEditDialogOpen(false);
setChunkForEdit(null);
}}
onSave={handleSaveEdit}
/>
</>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material';
import { useTranslation } from 'react-i18next';
export default function ChunkDeleteDialog({ open, onClose, onConfirm }) {
const { t } = useTranslation();
return (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">{t('common.confirmDelete')}?</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">{t('common.confirmDelete')}?</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={onConfirm} color="error" variant="contained">
{t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
TextField,
Typography,
Slider,
FormControlLabel,
Checkbox
} from '@mui/material';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
export default function ChunkFilterDialog({ open, onClose, onApply, initialFilters = {} }) {
const { t } = useTranslation();
const [contentKeyword, setContentKeyword] = useState(initialFilters.contentKeyword || '');
const [sizeRange, setSizeRange] = useState(initialFilters.sizeRange || [0, 10000]);
const [hasQuestions, setHasQuestions] = useState(initialFilters.hasQuestions || null);
// 重置筛选条件
const handleReset = () => {
setContentKeyword('');
setSizeRange([0, 10000]);
setHasQuestions(null);
};
// 应用筛选
const handleApply = () => {
onApply({
contentKeyword,
sizeRange,
hasQuestions
});
onClose();
};
// 处理大小范围变化
const handleSizeRangeChange = (event, newValue) => {
setSizeRange(newValue);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('datasets.moreFilters', { defaultValue: '更多筛选' })}</DialogTitle>
<DialogContent dividers sx={{ display: 'flex', flexDirection: 'column', gap: 3, py: 3 }}>
{/* 文本块内容筛选 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
{t('textSplit.contentKeyword', { defaultValue: '文本块内容' })}
</Typography>
<TextField
fullWidth
size="small"
placeholder={t('textSplit.contentKeywordPlaceholder', { defaultValue: '输入关键词搜索文本块内容' })}
value={contentKeyword}
onChange={e => setContentKeyword(e.target.value)}
variant="outlined"
/>
</Box>
{/* 字数范围筛选 */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t('textSplit.characterRange', { defaultValue: '字数范围' })}
</Typography>
<Typography variant="body2" color="textSecondary">
{sizeRange[0]} - {sizeRange[1]}
</Typography>
</Box>
<Slider
value={sizeRange}
onChange={handleSizeRangeChange}
valueLabelDisplay="auto"
min={0}
max={10000}
step={100}
marks={[
{ value: 0, label: '0' },
{ value: 10000, label: '10000' }
]}
/>
</Box>
{/* 是否有问题的筛选 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
{t('textSplit.questionStatus', { defaultValue: '问题状态' })}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={<Checkbox checked={hasQuestions === null} onChange={() => setHasQuestions(null)} />}
label={t('textSplit.allChunks', { defaultValue: '全部' })}
/>
<FormControlLabel
control={<Checkbox checked={hasQuestions === true} onChange={() => setHasQuestions(true)} />}
label={t('textSplit.generatedQuestions2', { defaultValue: '已生成问题' })}
/>
<FormControlLabel
control={<Checkbox checked={hasQuestions === false} onChange={() => setHasQuestions(false)} />}
label={t('textSplit.ungeneratedQuestions', { defaultValue: '未生成问题' })}
/>
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={handleReset} color="inherit">
{t('common.reset', { defaultValue: '重置' })}
</Button>
<Button onClick={onClose} color="inherit">
{t('common.cancel', { defaultValue: '取消' })}
</Button>
<Button onClick={handleApply} variant="contained" color="primary">
{t('common.apply', { defaultValue: '应用' })}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,413 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { Box, Paper, Typography, CircularProgress, Pagination, Grid } from '@mui/material';
import ChunkListHeader from './ChunkListHeader';
import ChunkCard from './ChunkCard';
import ChunkViewDialog from './ChunkViewDialog';
import ChunkDeleteDialog from './ChunkDeleteDialog';
import BatchEditChunksDialog from './BatchEditChunkDialog';
import ChunkBatchDeleteDialog from './ChunkBatchDeleteDialog';
import { useTheme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
/**
* Chunk list component
* @param {Object} props
* @param {string} props.projectId - Project ID
* @param {Array} props.chunks - Chunk array
* @param {Function} props.onDelete - Delete callback
* @param {Function} props.onEdit - Edit callback
* @param {Function} props.onGenerateQuestions - Generate questions callback
* @param {Function} props.onDataCleaning - Data cleaning callback
* @param {string} props.questionFilter - Question filter
* @param {Function} props.onQuestionFilterChange - Question filter change callback
* @param {Object} props.selectedModel - 閫変腑鐨勬ā鍨嬩俊鎭?
*/
export default function ChunkList({
projectId,
chunks = [],
onDelete,
onEdit,
onGenerateQuestions,
onGenerateEvalQuestions,
onDataCleaning,
loading = false,
questionFilter,
setQuestionFilter,
selectedModel,
onChunksUpdate
}) {
const theme = useTheme();
const [page, setPage] = useState(1);
const [selectedChunks, setSelectedChunks] = useState([]);
const [viewChunk, setViewChunk] = useState(null);
const [viewDialogOpen, setViewDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [chunkToDelete, setChunkToDelete] = useState(null);
const [batchEditDialogOpen, setBatchEditDialogOpen] = useState(false);
const [batchEditLoading, setBatchEditLoading] = useState(false);
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false);
const [batchDeleteLoading, setBatchDeleteLoading] = useState(false);
// 娣诲姞楂樼骇绛涢€夌姸鎬?
const [advancedFilters, setAdvancedFilters] = useState({
contentKeyword: '',
sizeRange: [0, 10000],
hasQuestions: null
});
// 璁$畻娲昏穬绛涢€夋潯浠舵暟
const activeFilterCount = useMemo(() => {
let count = 0;
if (advancedFilters.contentKeyword) count++;
if (advancedFilters.sizeRange[0] > 0 || advancedFilters.sizeRange[1] < 10000) count++;
if (advancedFilters.hasQuestions !== null) count++;
return count;
}, [advancedFilters]);
const sortedChunks = useMemo(
() =>
[...chunks].sort((a, b) => {
if (a.fileId !== b.fileId) {
return a.fileId.localeCompare(b.fileId);
}
const getPartNumber = name => {
const match = name.match(/part-(\d+)/);
return match ? parseInt(match[1], 10) : 0;
};
const numA = getPartNumber(a.name);
const numB = getPartNumber(b.name);
return numA - numB;
}),
[chunks]
);
const filteredChunks = useMemo(() => {
return sortedChunks.filter(chunk => {
if (advancedFilters.contentKeyword) {
const keyword = advancedFilters.contentKeyword.toLowerCase();
if (!chunk.content?.toLowerCase().includes(keyword)) {
return false;
}
}
const size = chunk.size || 0;
if (size < advancedFilters.sizeRange[0] || size > advancedFilters.sizeRange[1]) {
return false;
}
if (advancedFilters.hasQuestions !== null) {
const hasQuestions = chunk.Questions && chunk.Questions.length > 0;
if (advancedFilters.hasQuestions !== hasQuestions) {
return false;
}
}
return true;
});
}, [sortedChunks, advancedFilters]);
// 褰撶瓫閫夋潯浠跺彉鍖栨椂锛屾竻闄や笉鍦ㄧ瓫閫夌粨鏋滀腑鐨勯€変腑椤?
useEffect(() => {
const filteredChunkIds = filteredChunks.map(chunk => chunk.id);
setSelectedChunks(prev => prev.filter(id => filteredChunkIds.includes(id)));
}, [filteredChunks]);
const itemsPerPage = 5;
const displayedChunks = useMemo(() => {
const startIndex = (page - 1) * itemsPerPage;
return filteredChunks.slice(startIndex, startIndex + itemsPerPage);
}, [filteredChunks, page]);
const totalPages = useMemo(() => Math.ceil(filteredChunks.length / itemsPerPage), [filteredChunks.length]);
const { t } = useTranslation();
const handlePageChange = (event, value) => {
setPage(value);
};
const handleViewChunk = async chunkId => {
try {
const response = await fetch(`/api/projects/${projectId}/chunks/${chunkId}`);
if (!response.ok) {
throw new Error(t('textSplit.fetchChunksFailed'));
}
const data = await response.json();
setViewChunk(data);
setViewDialogOpen(true);
} catch (error) {
console.error(t('textSplit.fetchChunksError'), error);
}
};
const handleCloseViewDialog = () => {
setViewDialogOpen(false);
};
const handleOpenDeleteDialog = chunkId => {
setChunkToDelete(chunkId);
setDeleteDialogOpen(true);
};
const handleCloseDeleteDialog = () => {
setDeleteDialogOpen(false);
setChunkToDelete(null);
};
const handleConfirmDelete = () => {
if (chunkToDelete && onDelete) {
onDelete(chunkToDelete);
}
handleCloseDeleteDialog();
};
// 澶勭悊缂栬緫鏂囨湰鍧?
const handleEditChunk = async (chunkId, newContent) => {
if (onEdit) {
onEdit(chunkId, newContent);
onChunksUpdate();
}
};
// 澶勭悊閫夋嫨鏂囨湰鍧?
const handleSelectChunk = chunkId => {
setSelectedChunks(prev => {
if (prev.includes(chunkId)) {
return prev.filter(id => id !== chunkId);
} else {
return [...prev, chunkId];
}
});
};
const handleSelectAll = () => {
if (selectedChunks.length === filteredChunks.length) {
setSelectedChunks([]);
} else {
setSelectedChunks(filteredChunks.map(chunk => chunk.id));
}
};
const handleBatchGenerateQuestions = () => {
if (onGenerateQuestions && selectedChunks.length > 0) {
onGenerateQuestions(selectedChunks);
}
};
const handleBatchEdit = async editData => {
try {
setBatchEditLoading(true);
// 璋冪敤鎵归噺缂栬緫API
const response = await fetch(`/api/projects/${projectId}/chunks/batch-edit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
position: editData.position,
content: editData.content,
chunkIds: editData.chunkIds
})
});
if (!response.ok) {
throw new Error('鎵归噺缂栬緫澶辫触');
}
const result = await response.json();
if (result.success) {
// 缂栬緫鎴愬姛鍚庯紝鍒锋柊鏂囨湰鍧楁暟鎹?
if (onChunksUpdate) {
onChunksUpdate();
}
// 娓呯┖閫変腑鐘舵€?
setSelectedChunks([]);
// 鍏抽棴瀵硅瘽妗?
setBatchEditDialogOpen(false);
// 鏄剧ず鎴愬姛娑堟伅
console.log(`鎴愬姛鏇存柊浜?${result.updatedCount} 涓枃鏈潡`);
} else {
throw new Error(result.message || '鎵归噺缂栬緫澶辫触');
}
} catch (error) {
console.error('鎵归噺缂栬緫澶辫触:', error);
// 杩欓噷鍙互娣诲姞閿欒鎻愮ず
} finally {
setBatchEditLoading(false);
}
};
// 鎵撳紑鎵归噺缂栬緫瀵硅瘽妗?
const handleOpenBatchEdit = () => {
setBatchEditDialogOpen(true);
};
// 鍏抽棴鎵归噺缂栬緫瀵硅瘽妗?
const handleCloseBatchEdit = () => {
setBatchEditDialogOpen(false);
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
// 澶勭悊绛涢€夊彉鍖?
const handleFilterChange = filters => {
setAdvancedFilters(filters);
setPage(1); // 閲嶇疆鍒扮涓€椤?
};
// 鎵撳紑鎵归噺鍒犻櫎瀵硅瘽妗?
const handleOpenBatchDelete = () => {
setBatchDeleteDialogOpen(true);
};
// 鍏抽棴鎵归噺鍒犻櫎瀵硅瘽妗?
const handleCloseBatchDelete = () => {
setBatchDeleteDialogOpen(false);
};
// 纭鎵归噺鍒犻櫎
const handleConfirmBatchDelete = async () => {
if (selectedChunks.length === 0) return;
try {
setBatchDeleteLoading(true);
let successCount = 0;
let failCount = 0;
// 寰幆璋冪敤鍗曚釜鍒犻櫎鎺ュ彛
for (const chunkId of selectedChunks) {
try {
await onDelete(chunkId);
successCount++;
} catch (error) {
console.error(`鍒犻櫎鏂囨湰鍧?${chunkId} 澶辫触:`, error);
failCount++;
}
}
// 鏄剧ず鍒犻櫎缁撴灉
if (failCount === 0) {
console.log(`鎴愬姛鍒犻櫎 ${successCount} 涓枃鏈潡`);
} else {
console.log(`删除完成:成功 ${successCount} 个,失败 ${failCount}`);
}
// 娓呯┖閫変腑鐘舵€?
setSelectedChunks([]);
// 鍒锋柊鏁版嵁
if (onChunksUpdate) {
onChunksUpdate();
}
// 鍏抽棴瀵硅瘽妗?
setBatchDeleteDialogOpen(false);
} catch (error) {
console.error('鎵归噺鍒犻櫎澶辫触:', error);
} finally {
setBatchDeleteLoading(false);
}
};
return (
<Box>
<ChunkListHeader
projectId={projectId}
totalChunks={filteredChunks.length}
selectedChunks={selectedChunks}
onSelectAll={handleSelectAll}
onBatchGenerateQuestions={handleBatchGenerateQuestions}
onBatchEditChunks={handleOpenBatchEdit}
onBatchDeleteChunks={handleOpenBatchDelete}
questionFilter={questionFilter}
setQuestionFilter={event => setQuestionFilter(event.target.value)}
chunks={chunks}
selectedModel={selectedModel}
onFilterChange={handleFilterChange}
activeFilterCount={activeFilterCount}
/>
<Grid container spacing={2}>
{displayedChunks.map(chunk => (
<Grid item xs={12} key={chunk.id}>
<ChunkCard
chunk={chunk}
selected={selectedChunks.includes(chunk.id)}
onSelect={() => handleSelectChunk(chunk.id)}
onView={() => handleViewChunk(chunk.id)}
onDelete={() => handleOpenDeleteDialog(chunk.id)}
onEdit={handleEditChunk}
onGenerateQuestions={() => onGenerateQuestions && onGenerateQuestions([chunk.id])}
onGenerateEvalQuestions={() => onGenerateEvalQuestions && onGenerateEvalQuestions(chunk.id)}
onDataCleaning={() => onDataCleaning && onDataCleaning([chunk.id])}
projectId={projectId}
selectedModel={selectedModel}
/>
</Grid>
))}
</Grid>
{chunks.length === 0 && (
<Paper
sx={{
p: 4,
textAlign: 'center',
border: `1px solid ${theme.palette.divider}`,
borderRadius: 2
}}
>
<Typography variant="body1" color="textSecondary">
{t('textSplit.noChunks')}
</Typography>
</Paper>
)}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination count={totalPages} page={page} onChange={handlePageChange} color="primary" />
</Box>
)}
{/* 鏂囨湰鍧楄鎯呭璇濇 */}
<ChunkViewDialog open={viewDialogOpen} chunk={viewChunk} onClose={handleCloseViewDialog} />
{/* 鍒犻櫎纭瀵硅瘽妗?*/}
<ChunkDeleteDialog open={deleteDialogOpen} onClose={handleCloseDeleteDialog} onConfirm={handleConfirmDelete} />
{/* 鎵归噺缂栬緫瀵硅瘽妗?*/}
<BatchEditChunksDialog
open={batchEditDialogOpen}
onClose={handleCloseBatchEdit}
onConfirm={handleBatchEdit}
selectedChunks={selectedChunks}
totalChunks={chunks.length}
loading={batchEditLoading}
/>
{/* 鎵归噺鍒犻櫎纭瀵硅瘽妗?*/}
<ChunkBatchDeleteDialog
open={batchDeleteDialogOpen}
onClose={handleCloseBatchDelete}
onConfirm={handleConfirmBatchDelete}
loading={batchDeleteLoading}
count={selectedChunks.length}
/>
</Box>
);
}

View File

@@ -0,0 +1,400 @@
'use client';
import { Box, Typography, Checkbox, Button, Select, MenuItem, Tooltip, Menu, IconButton, Badge } from '@mui/material';
import QuizIcon from '@mui/icons-material/Quiz';
import DownloadIcon from '@mui/icons-material/Download';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import AssessmentIcon from '@mui/icons-material/Assessment';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import FilterListIcon from '@mui/icons-material/FilterList';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import axios from 'axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import ChunkFilterDialog from './ChunkFilterDialog';
export default function ChunkListHeader({
projectId,
totalChunks,
selectedChunks,
onSelectAll,
onBatchGenerateQuestions,
onBatchEditChunks,
onBatchDeleteChunks,
questionFilter,
setQuestionFilter,
chunks = [], // 添加chunks参数用于导出文本块
selectedModel = {},
onFilterChange = null,
activeFilterCount = 0
}) {
const { t, i18n } = useTranslation();
// 添加更多菜单的状态和锚点
const [moreMenuAnchorEl, setMoreMenuAnchorEl] = useState(null);
const isMoreMenuOpen = Boolean(moreMenuAnchorEl);
// 添加筛选对话框状态
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
// 自动任务菜单状态
const [autoTasksMenuAnchorEl, setAutoTasksMenuAnchorEl] = useState(null);
const isAutoTasksMenuOpen = Boolean(autoTasksMenuAnchorEl);
const handleAutoTasksClick = event => {
setAutoTasksMenuAnchorEl(event.currentTarget);
};
const handleAutoTasksClose = () => {
setAutoTasksMenuAnchorEl(null);
};
// 打开更多菜单
const handleMoreMenuClick = event => {
setMoreMenuAnchorEl(event.currentTarget);
};
// 关闭更多菜单
const handleMoreMenuClose = () => {
setMoreMenuAnchorEl(null);
};
// 处理批量编辑,关闭菜单并调用原有函数
const handleBatchEdit = () => {
handleMoreMenuClose();
onBatchEditChunks();
};
// 处理批量删除,关闭菜单并调用原有函数
const handleBatchDelete = () => {
handleMoreMenuClose();
onBatchDeleteChunks();
};
// 处理导出文本块,关闭菜单并调用原有函数
const handleExport = () => {
handleMoreMenuClose();
handleExportChunks();
};
// 创建自动提取问题任务
const handleCreateAutoQuestionTask = async () => {
if (!projectId || !selectedModel?.id) {
toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' }));
return;
}
try {
// 调用创建任务接口
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
taskType: 'question-generation',
modelInfo: selectedModel,
language: i18n.language,
detail: '批量生成问题任务'
});
if (response.data?.code === 0) {
toast.success(t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理未生成问题的文本块' }));
} else {
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message);
}
} catch (error) {
console.error('创建自动提取问题任务失败:', error);
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
}
};
// 创建自动数据清洗任务
const handleCreateAutoDataCleaningTask = async () => {
if (!projectId || !selectedModel?.id) {
toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' }));
return;
}
try {
// 调用创建任务接口
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
taskType: 'data-cleaning',
modelInfo: selectedModel,
language: i18n.language,
detail: '批量数据清洗任务'
});
if (response.data?.code === 0) {
toast.success(
t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理所有文本块进行数据清洗' })
);
} else {
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message);
}
} catch (error) {
console.error('创建自动数据清洗任务失败:', error);
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
}
};
// 创建自动生成评估数据集任务
const handleCreateAutoEvalGenerationTask = async () => {
if (!projectId || !selectedModel?.id) {
toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' }));
return;
}
try {
// 调用创建任务接口
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
taskType: 'eval-generation',
modelInfo: selectedModel,
language: i18n.language,
detail: '批量生成评估数据集任务'
});
if (response.data?.code === 0) {
toast.success(
t('tasks.createSuccess', {
defaultValue: '后台任务已创建,系统将自动为所有未生成评估题目的文本块生成评估数据集'
})
);
} else {
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message);
}
} catch (error) {
console.error('创建自动生成评估数据集任务失败:', error);
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
}
};
// 导出文本块为JSON文件的函数
const handleExportChunks = () => {
if (!chunks || chunks.length === 0) return;
// 创建要导出的数据对象
const exportData = chunks.map(chunk => ({
name: chunk.name,
projectId: chunk.projectId,
fileName: chunk.fileName,
content: chunk.content,
summary: chunk.summary,
size: chunk.size
}));
// 将数据转换为JSON字符串
const jsonString = JSON.stringify(exportData, null, 2);
// 创建Blob对象
const blob = new Blob([jsonString], { type: 'application/json' });
// 创建下载链接
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `text-chunks-export-${new Date().toISOString().split('T')[0]}.json`;
// 触发下载
document.body.appendChild(a);
a.click();
// 清理
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'flex-start', md: 'center' },
gap: 2,
mb: 3
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Checkbox
checked={selectedChunks.length === totalChunks}
indeterminate={selectedChunks.length > 0 && selectedChunks.length < totalChunks}
onChange={onSelectAll}
/>
<Typography variant="body1">{t('textSplit.selectedCount', { count: selectedChunks.length })}</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' },
flexWrap: 'wrap',
gap: 1.5,
width: { xs: '100%', md: 'auto' }
}}
>
{/* 更多筛选按钮 */}
<Tooltip title={t('datasets.moreFilters', { defaultValue: '更多筛选' })}>
<Badge badgeContent={activeFilterCount} color="error" overlap="circular">
<Button
variant="outlined"
startIcon={<FilterListIcon />}
onClick={() => setFilterDialogOpen(true)}
size="small"
sx={{ borderRadius: 1 }}
>
{t('datasets.moreFilters', { defaultValue: '更多筛选' })}
</Button>
</Badge>
</Tooltip>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1.5,
mt: { xs: 1, sm: 0 },
width: { xs: '100%', sm: 'auto' }
}}
>
<Button
variant="contained"
color="primary"
startIcon={<QuizIcon />}
disabled={selectedChunks.length === 0}
onClick={onBatchGenerateQuestions}
size="medium"
sx={{ minWidth: { xs: '48%', sm: 'auto' } }}
>
{t('textSplit.batchGenerateQuestions')}
</Button>
{/* 自动任务下拉菜单 */}
<Button
variant="outlined"
color="secondary"
startIcon={<AutoFixHighIcon />}
endIcon={<KeyboardArrowDownIcon />}
onClick={handleAutoTasksClick}
disabled={!projectId || !selectedModel?.id}
size="medium"
sx={{ minWidth: { xs: '48%', sm: 'auto' } }}
>
{t('textSplit.autoTasks', { defaultValue: '自动任务' })}
</Button>
<Menu
anchorEl={autoTasksMenuAnchorEl}
open={isAutoTasksMenuOpen}
onClose={handleAutoTasksClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
>
<Tooltip
title={t('textSplit.autoGenerateQuestionsTip', {
defaultValue: '创建后台批量处理任务:自动查询待生成问题的文本块并提取问题'
})}
placement="left"
>
<MenuItem
onClick={() => {
handleCreateAutoQuestionTask();
handleAutoTasksClose();
}}
>
<QuizIcon fontSize="small" sx={{ mr: 1, color: 'secondary.main' }} />
{t('textSplit.autoGenerateQuestions')}
</MenuItem>
</Tooltip>
<Tooltip
title={t('textSplit.autoEvalGenerationTip', {
defaultValue: '创建后台批量处理任务:自动为所有未生成评估题目的文本块生成评估数据集'
})}
placement="left"
>
<MenuItem
onClick={() => {
handleCreateAutoEvalGenerationTask();
handleAutoTasksClose();
}}
>
<AssessmentIcon fontSize="small" sx={{ mr: 1, color: 'secondary.main' }} />
{t('textSplit.autoEvalGeneration', { defaultValue: '自动生成评估集' })}
</MenuItem>
</Tooltip>
<Tooltip
title={t('textSplit.autoDataCleaningTip', {
defaultValue: '创建后台批量处理任务:自动对所有文本块进行数据清洗'
})}
placement="left"
>
<MenuItem
onClick={() => {
handleCreateAutoDataCleaningTask();
handleAutoTasksClose();
}}
>
<CleaningServicesIcon fontSize="small" sx={{ mr: 1, color: 'success.main' }} />
{t('textSplit.autoDataCleaning', { defaultValue: '自动数据清洗' })}
</MenuItem>
</Tooltip>
</Menu>
{/* 更多菜单按钮 */}
<Tooltip title={t('common.more', { defaultValue: '更多操作' })}>
<IconButton
onClick={handleMoreMenuClick}
color="primary"
size="medium"
sx={{
border: '1px solid',
borderColor: 'divider'
}}
>
<MoreVertIcon />
</IconButton>
</Tooltip>
{/* 更多操作下拉菜单 */}
<Menu
anchorEl={moreMenuAnchorEl}
open={isMoreMenuOpen}
onClose={handleMoreMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
>
<MenuItem onClick={handleBatchEdit} disabled={selectedChunks.length === 0}>
<EditIcon fontSize="small" sx={{ mr: 1 }} />
{t('batchEdit.batchEdit', { defaultValue: '批量编辑' })}
</MenuItem>
<MenuItem onClick={handleBatchDelete} disabled={selectedChunks.length === 0}>
<DeleteIcon fontSize="small" sx={{ mr: 1, color: 'error.main' }} />
{t('textSplit.batchDeleteChunks', { defaultValue: '批量删除' })}
</MenuItem>
<MenuItem onClick={handleExport} disabled={chunks.length === 0}>
<DownloadIcon fontSize="small" sx={{ mr: 1 }} />
{t('textSplit.exportChunks', { defaultValue: '导出文本块' })}
</MenuItem>
</Menu>
</Box>
</Box>
{/* 筛选对话框 */}
<ChunkFilterDialog open={filterDialogOpen} onClose={() => setFilterDialogOpen(false)} onApply={onFilterChange} />
</Box>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import { Box, Button, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress } from '@mui/material';
import ReactMarkdown from 'react-markdown';
import { useTranslation } from 'react-i18next';
import 'github-markdown-css/github-markdown-light.css';
export default function ChunkViewDialog({ open, chunk, onClose }) {
const { t } = useTranslation();
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{t('textSplit.chunkDetails', { chunkId: chunk?.name })}</DialogTitle>
<DialogContent dividers>
{chunk ? (
<Box sx={{ maxHeight: '60vh', overflow: 'auto' }}>
<div className="markdown-body">
<ReactMarkdown>{chunk.content}</ReactMarkdown>
</div>
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.close')}</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,560 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Paper,
Typography,
Divider,
CircularProgress,
Tabs,
Tab,
List,
ListItem,
ListItemText,
Collapse,
IconButton,
TextField,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Tooltip,
Menu,
MenuItem
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import TabPanel from './components/TabPanel';
import ReactMarkdown from 'react-markdown';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import axios from 'axios';
import { toast } from 'sonner';
import 'github-markdown-css/github-markdown-light.css';
/**
* 领域分析组件
* @param {Object} props
* @param {string} props.projectId - 项目ID
* @param {Array} props.toc - 目录结构数组
* @param {Array} props.tags - 标签树数组
* @param {boolean} props.loading - 是否加载中
* @param {Function} props.onTagsUpdate - 标签更新回调
*/
// 领域树节点组件
function TreeNode({ node, level = 0, onEdit, onDelete, onAddChild }) {
const [open, setOpen] = useState(true);
const theme = useTheme();
const hasChildren = node.child && node.child.length > 0;
const [anchorEl, setAnchorEl] = useState(null);
const menuOpen = Boolean(anchorEl);
const { t } = useTranslation();
const handleClick = () => {
if (hasChildren) {
setOpen(!open);
}
};
const handleMenuOpen = event => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const handleMenuClose = event => {
if (event) event.stopPropagation();
setAnchorEl(null);
};
const handleEdit = event => {
event.stopPropagation();
onEdit(node);
handleMenuClose();
};
const handleDelete = event => {
event.stopPropagation();
onDelete(node);
handleMenuClose();
};
const handleAddChild = event => {
event.stopPropagation();
onAddChild(node);
handleMenuClose();
};
return (
<>
<ListItem
button
onClick={handleClick}
sx={{
pl: level * 2 + 1,
bgcolor: level === 0 ? theme.palette.primary.light : 'transparent',
color: level === 0 ? theme.palette.primary.contrastText : 'inherit',
'&:hover': {
bgcolor: level === 0 ? theme.palette.primary.main : theme.palette.action.hover
},
borderRadius: '4px',
mb: 0.5,
pr: 1
}}
>
<ListItemText
primary={node.label}
primaryTypographyProps={{
fontWeight: level === 0 ? 600 : 400,
fontSize: level === 0 ? '1rem' : '0.9rem'
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<IconButton
size="small"
onClick={handleMenuOpen}
sx={{
color: level === 0 ? 'inherit' : theme.palette.text.secondary,
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.1)' }
}}
>
<MoreVertIcon fontSize="small" />
</IconButton>
{hasChildren && (open ? <ExpandLess /> : <ExpandMore />)}
</Box>
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose} onClick={e => e.stopPropagation()}>
<MenuItem onClick={handleEdit}>
<EditIcon fontSize="small" sx={{ mr: 1 }} />
{t('textSplit.editTag')}
</MenuItem>
<MenuItem onClick={handleDelete}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} />
{t('textSplit.deleteTag')}
</MenuItem>
{level === 0 && (
<MenuItem onClick={handleAddChild}>
<AddIcon fontSize="small" sx={{ mr: 1 }} />
{t('textSplit.addTag')}
</MenuItem>
)}
</Menu>
</ListItem>
{hasChildren && (
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{node.child.map((childNode, index) => (
<TreeNode
key={index}
node={childNode}
level={level + 1}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))}
</List>
</Collapse>
)}
</>
);
}
// 领域树组件
function DomainTree({ tags, onEdit, onDelete, onAddChild }) {
return (
<List component="nav" aria-label="domain tree">
{tags.map((node, index) => (
<TreeNode key={index} node={node} onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} />
))}
</List>
);
}
export default function DomainAnalysis({ projectId, toc = '', loading = false }) {
const theme = useTheme();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [currentNode, setCurrentNode] = useState(null);
const [parentNode, setParentNode] = useState('');
const [dialogMode, setDialogMode] = useState('add');
const [labelValue, setLabelValue] = useState({});
const [saving, setSaving] = useState(false);
const [tags, setTags] = useState([]);
const [snackbar, setSnackbar] = useState({
open: false,
message: '',
severity: 'success'
});
const handleCloseSnackbar = () => {
setSnackbar(prev => ({ ...prev, open: false }));
};
useEffect(() => {
getTags();
}, []);
const getTags = async () => {
const response = await axios.get(`/api/projects/${projectId}/tags`);
setTags(response.data.tags);
};
// 处理标签切换
const handleTabChange = (event, newValue) => {
setActiveTab(newValue);
};
// 打开添加标签对话框
const handleAddTag = () => {
setDialogMode('add');
setCurrentNode(null);
setParentNode(null);
setLabelValue({});
setDialogOpen(true);
};
// 打开编辑标签对话框
const handleEditTag = node => {
setDialogMode('edit');
setCurrentNode({ id: node.id, label: node.label });
setLabelValue({ id: node.id, label: node.label });
setDialogOpen(true);
};
// 打开添加子标签对话框
const handleAddChildTag = parentNode => {
setDialogMode('addChild');
setParentNode(parentNode.label);
setLabelValue({ parentId: parentNode.id });
setDialogOpen(true);
};
// 打开删除标签对话框
const handleDeleteTag = node => {
setCurrentNode(node);
setDeleteDialogOpen(true);
};
// 关闭对话框
const handleCloseDialog = () => {
setDialogOpen(false);
setDeleteDialogOpen(false);
};
// 查找并更新节点
const findAndUpdateNode = (nodes, targetNode, newLabel) => {
return nodes.map(node => {
if (node === targetNode) {
return { ...node, label: newLabel };
}
if (node.child && node.child.length > 0) {
return { ...node, child: findAndUpdateNode(node.child, targetNode, newLabel) };
}
return node;
});
};
// 查找并删除节点
const findAndDeleteNode = (nodes, targetNode) => {
return nodes
.filter(node => node !== targetNode)
.map(node => {
if (node.child && node.child.length > 0) {
return { ...node, child: findAndDeleteNode(node.child, targetNode) };
}
return node;
});
};
// 查找并添加子节点
const findAndAddChildNode = (nodes, parentNode, childLabel) => {
return nodes.map(node => {
if (node === parentNode) {
const childArray = node.child || [];
return {
...node,
child: [...childArray, { label: childLabel, child: [] }]
};
}
if (node.child && node.child.length > 0) {
return { ...node, child: findAndAddChildNode(node.child, parentNode, childLabel) };
}
return node;
});
};
// 保存标签更改
const saveTagChanges = async updatedTags => {
console.log('保存标签更改:', updatedTags);
setSaving(true);
try {
const response = await fetch(`/api/projects/${projectId}/tags`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ tags: updatedTags })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || t('domain.errors.saveFailed'));
}
getTags();
setSnackbar({
open: true,
message: t('domain.messages.updateSuccess'),
severity: 'success'
});
} catch (error) {
console.error('保存标签失败:', error);
setSnackbar({
open: true,
message: error.message || '保存标签失败',
severity: 'error'
});
} finally {
setSaving(false);
}
};
// 提交表单
const handleSubmit = async () => {
if (!labelValue.label.trim()) {
setSnackbar({
open: true,
message: '标签名称不能为空',
severity: 'error'
});
return;
}
await saveTagChanges(labelValue);
handleCloseDialog();
};
const handleConfirmDelete = async () => {
if (!currentNode) return;
const res = await axios.delete(`/api/projects/${projectId}/tags?id=${currentNode.id}`);
if (res.status === 200) {
toast.success('删除成功');
getTags();
}
setDeleteDialogOpen(false);
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
if (toc.length === 0) {
return (
<Paper
sx={{
p: 4,
textAlign: 'center',
border: `1px solid ${theme.palette.divider}`,
borderRadius: 2
}}
>
<Typography variant="body1" color="textSecondary">
{t('domain.noToc')}
</Typography>
</Paper>
);
}
return (
<Box>
<Paper
sx={{
p: 0,
mb: 3,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 2,
overflow: 'hidden'
}}
>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
indicatorColor="secondary"
sx={{
borderBottom: 1,
borderColor: 'divider',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)',
borderTopLeftRadius: 2,
borderTopRightRadius: 2
}}
>
<Tab label={t('domain.tabs.tree')} />
<Tab label={t('domain.tabs.structure')} />
</Tabs>
<Box
sx={{
p: 3,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.02)' : 'rgba(255, 255, 255, 0.8)',
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.03)'
}}
>
<TabPanel value={activeTab} index={0}>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">{t('domain.tabs.tree')}</Typography>
<Tooltip title="添加一级标签">
<Button variant="outlined" size="small" startIcon={<AddIcon />} onClick={handleAddTag}>
{t('domain.addRootTag')}
</Button>
</Tooltip>
</Box>
<Divider sx={{ mb: 2 }} />
<Box
sx={{
p: 2,
bgcolor: theme.palette.background.paper,
borderRadius: 1,
maxHeight: '800px',
overflow: 'auto'
}}
>
{tags && tags.length > 0 ? (
<DomainTree
tags={tags}
onEdit={handleEditTag}
onDelete={handleDeleteTag}
onAddChild={handleAddChildTag}
/>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="textSecondary" gutterBottom>
{t('domain.noTags')}
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={handleAddTag}
sx={{ mt: 1 }}
>
{t('domain.addFirstTag')}
</Button>
</Box>
)}
</Box>
</Box>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<Box>
<Typography variant="h6" gutterBottom>
{t('domain.docStructure')}
</Typography>
<Divider sx={{ mb: 2 }} />
<Box
sx={{
p: 2,
bgcolor: theme.palette.background.paper,
borderRadius: 1,
maxHeight: '600px',
overflow: 'auto'
}}
>
<div className="markdown-body">
<ReactMarkdown
components={{
root: ({ children }) => (
<div
style={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{children}
</div>
)
}}
>
{toc}
</ReactMarkdown>
</div>
</Box>
</Box>
</TabPanel>
</Box>
</Paper>
{/* 添加/编辑标签对话框 */}
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{dialogMode === 'add'
? t('domain.dialog.addTitle')
: dialogMode === 'edit'
? t('domain.dialog.editTitle')
: t('domain.dialog.addChildTitle')}
</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
{dialogMode === 'add'
? t('domain.dialog.inputRoot')
: dialogMode === 'edit'
? t('domain.dialog.inputEdit')
: t('domain.dialog.inputChild', { label: parentNode })}
</DialogContentText>
<TextField
autoFocus
margin="dense"
label={t('domain.dialog.labelName')}
type="text"
fullWidth
variant="outlined"
value={labelValue.label}
onChange={e => setLabelValue({ ...labelValue, label: e.target.value })}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} variant="contained" disabled={saving || !labelValue?.label?.trim()}>
{saving ? t('common.saving') : t('common.save')}
</Button>
</DialogActions>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={deleteDialogOpen} onClose={handleCloseDialog}>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogContent>
<DialogContentText>
{t('domain.dialog.deleteConfirm', { label: currentNode?.label })}
{currentNode?.child && currentNode.child.length > 0 && t('domain.dialog.deleteWarning')}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button onClick={handleConfirmDelete} color="error" variant="contained">
{saving ? t('common.deleting') : t('common.delete')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,360 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Paper, Grid } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { useAtomValue } from 'jotai/index';
import { selectedModelInfoAtom } from '@/lib/store';
import UploadArea from './components/UploadArea';
import FileList from './components/FileList';
import DeleteConfirmDialog from './components/DeleteConfirmDialog';
import PdfProcessingDialog from './components/PdfProcessingDialog';
import DomainTreeActionDialog from './components/DomainTreeActionDialog';
import FileLoadingProgress from './components/FileLoadingProgress';
import { fileApi, taskApi } from '@/lib/api';
import { getContent, checkMaxSize, checkInvalidFiles, getvalidFiles } from '@/lib/file/file-process';
import { toast } from 'sonner';
export default function FileUploader({
projectId,
onUploadSuccess,
onFileDeleted,
sendToPages,
setPdfStrategy,
pdfStrategy,
selectedViosnModel,
setSelectedViosnModel,
setPageLoading,
taskFileProcessing,
fileTask
}) {
const theme = useTheme();
const { t } = useTranslation();
const [files, setFiles] = useState([]);
const [pdfFiles, setPdfFiles] = useState([]);
const [uploadedFiles, setUploadedFiles] = useState({});
const selectedModelInfo = useAtomValue(selectedModelInfoAtom);
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pdfProcessConfirmOpen, setpdfProcessConfirmOpen] = useState(false);
const [fileToDelete, setFileToDelete] = useState({});
const [domainTreeActionOpen, setDomainTreeActionOpen] = useState(false);
const [domainTreeAction, setDomainTreeAction] = useState('');
const [isFirstUpload, setIsFirstUpload] = useState(false);
const [pendingAction, setPendingAction] = useState(null);
const [taskSettings, setTaskSettings] = useState(null);
const [visionModels, setVisionModels] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(10);
const [searchFileName, setSearchFileName] = useState('');
useEffect(() => {
fetchUploadedFiles();
}, [currentPage, searchFileName]);
/**
* 处理 PDF 处理方式选择
*/
const handleRadioChange = event => {
const modelId = event.target.selectedVision;
setPdfStrategy(event.target.value);
if (event.target.value === 'mineru') {
toast.success(t('textSplit.mineruSelected'));
} else if (event.target.value === 'mineru-local') {
toast.success(t('textSplit.mineruLocalSelected'));
} else if (event.target.value === 'vision') {
const model = visionModels.find(item => item.id === modelId);
toast.success(
t('textSplit.customVisionModelSelected', {
name: model.modelName,
provider: model.projectName
})
);
} else {
toast.success(t('textSplit.defaultSelected'));
}
};
/**
* 获取上传的文件列表
* @param {*} page
* @param {*} size
* @param {*} fileName
*/
const fetchUploadedFiles = async (page = currentPage, size = pageSize, fileName = searchFileName) => {
try {
setLoading(true);
const data = await fileApi.getFiles({ projectId, page, size, fileName, t });
setUploadedFiles(data);
setIsFirstUpload(data.total === 0);
const taskData = await taskApi.getProjectTasks(projectId);
setTaskSettings(taskData);
//使用Jotai会出现数据获取的延迟导致这里模型获取不到改用localStorage获取模型信息
const model = JSON.parse(localStorage.getItem('modelConfigList'));
//过滤出视觉模型
const visionItems = model.filter(item => item.type === 'vision');
//先默认选择第一个配置的视觉模型
if (visionItems.length > 0) {
setSelectedViosnModel(visionItems[0].id);
}
setVisionModels(visionItems);
} catch (error) {
toast.error(error.message);
} finally {
setLoading(false);
}
};
/**
* 处理文件选择
*/
const handleFileSelect = event => {
const selectedFiles = Array.from(event.target.files);
checkMaxSize(selectedFiles);
checkInvalidFiles(selectedFiles);
const validFiles = getvalidFiles(selectedFiles);
if (validFiles.length > 0) {
setFiles(prev => [...prev, ...validFiles]);
}
const hasPdfFiles = selectedFiles.filter(file => file.name.endsWith('.pdf'));
if (hasPdfFiles.length > 0) {
setpdfProcessConfirmOpen(true);
setPdfFiles(hasPdfFiles);
}
};
/**
* 从待上传文件列表中移除文件
*/
const removeFile = index => {
const fileToRemove = files[index];
setFiles(prev => prev.filter((_, i) => i !== index));
if (fileToRemove && fileToRemove.name.toLowerCase().endsWith('.pdf')) {
setPdfFiles(prevPdfFiles => prevPdfFiles.filter(pdfFile => pdfFile.name !== fileToRemove.name));
}
};
/**
* 上传文件
*/
const uploadFiles = async () => {
if (files.length === 0) return;
// 如果是第一次上传,直接走默认逻辑
if (isFirstUpload) {
handleStartUpload('rebuild');
return;
}
// 否则打开领域树操作选择对话框
setDomainTreeAction('upload');
setPendingAction({ type: 'upload' });
setDomainTreeActionOpen(true);
};
/**
* 处理领域树操作选择
*/
const handleDomainTreeAction = action => {
setDomainTreeActionOpen(false);
// 执行挂起的操作
if (pendingAction && pendingAction.type === 'upload') {
handleStartUpload(action);
} else if (pendingAction && pendingAction.type === 'delete') {
handleDeleteFile(action);
}
// 清除挂起的操作
setPendingAction(null);
};
/**
* 开始上传文件
*/
const handleStartUpload = async domainTreeActionType => {
setUploading(true);
try {
const uploadedFileInfos = [];
for (const file of files) {
const { fileContent, fileName } = await getContent(file);
const data = await fileApi.uploadFile({ file, projectId, fileContent, fileName, t });
uploadedFileInfos.push({ fileName: data.fileName, fileId: data.fileId });
}
toast.success(t('textSplit.uploadSuccess', { count: files.length }));
setFiles([]);
setCurrentPage(1);
await fetchUploadedFiles();
if (onUploadSuccess) {
await onUploadSuccess(uploadedFileInfos, pdfFiles, domainTreeActionType);
}
} catch (err) {
toast.error(err.message || t('textSplit.uploadFailed'));
} finally {
setUploading(false);
}
};
// 打开删除确认对话框
const openDeleteConfirm = (fileId, fileName) => {
setFileToDelete({ fileId, fileName });
setDeleteConfirmOpen(true);
};
// 关闭删除确认对话框
const closeDeleteConfirm = () => {
setDeleteConfirmOpen(false);
setFileToDelete(null);
};
// 删除文件前确认领域树操作
const confirmDeleteFile = () => {
setDeleteConfirmOpen(false);
// 如果没有其他文件了(删除后会变为空),直接删除
if (uploadedFiles.total <= 1) {
handleDeleteFile('keep');
return;
}
// 否则打开领域树操作选择对话框
setDomainTreeAction('delete');
setPendingAction({ type: 'delete' });
setDomainTreeActionOpen(true);
};
// 处理删除文件
const handleDeleteFile = async domainTreeActionType => {
if (!fileToDelete) return;
try {
setLoading(true);
closeDeleteConfirm();
await fileApi.deleteFile({
fileToDelete,
projectId,
domainTreeActionType,
modelInfo: selectedModelInfo || {},
t
});
await fetchUploadedFiles();
if (onFileDeleted) {
const filesLength = uploadedFiles.total;
onFileDeleted(fileToDelete, filesLength);
}
if (uploadedFiles.data && uploadedFiles.data.length <= 1 && currentPage > 1) {
setCurrentPage(1);
}
toast.success(t('textSplit.deleteSuccess', { fileName: fileToDelete.fileName }));
} catch (error) {
console.error('Error deleting file:', error);
toast.error(error.message);
} finally {
setLoading(false);
setFileToDelete(null);
}
};
return (
<Paper
elevation={0}
sx={{
p: 3,
mb: 3,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 2
}}
>
{taskFileProcessing ? (
<FileLoadingProgress fileTask={fileTask} />
) : (
<>
<Grid container spacing={3}>
{/* 左侧:上传文件区域 */}
<Grid item xs={10} md={5} sx={{ maxWidth: '100%', width: '100%' }}>
<UploadArea
theme={theme}
files={files}
uploading={uploading}
uploadedFiles={uploadedFiles}
onFileSelect={handleFileSelect}
onRemoveFile={removeFile}
onUpload={uploadFiles}
selectedModel={selectedModelInfo}
/>
</Grid>
{/* 右侧:已上传文件列表 */}
<Grid item xs={14} md={7} sx={{ maxWidth: '100%', width: '100%' }}>
<FileList
theme={theme}
files={uploadedFiles}
loading={loading}
setPageLoading={setPageLoading}
sendToFileUploader={array => sendToPages(array)}
onDeleteFile={openDeleteConfirm}
projectId={projectId}
currentPage={currentPage}
onPageChange={(page, fileName) => {
if (fileName !== undefined) {
// 搜索时更新搜索关键词和页码
setSearchFileName(fileName);
setCurrentPage(page);
} else {
// 翻页时只更新页码
setCurrentPage(page);
}
}}
onRefresh={fetchUploadedFiles} // 传递刷新函数
/>
</Grid>
</Grid>
<DeleteConfirmDialog
open={deleteConfirmOpen}
fileName={fileToDelete?.fileName}
onClose={closeDeleteConfirm}
onConfirm={confirmDeleteFile}
/>
{/* 领域树操作选择对话框 */}
<DomainTreeActionDialog
open={domainTreeActionOpen}
onClose={() => setDomainTreeActionOpen(false)}
onConfirm={handleDomainTreeAction}
isFirstUpload={isFirstUpload}
action={domainTreeAction}
/>
{/* 检测到pdf的处理框 */}
<PdfProcessingDialog
open={pdfProcessConfirmOpen}
onClose={() => setpdfProcessConfirmOpen(false)}
onRadioChange={handleRadioChange}
value={pdfStrategy}
projectId={projectId}
taskSettings={taskSettings}
visionModels={visionModels}
selectedViosnModel={selectedViosnModel}
setSelectedViosnModel={setSelectedViosnModel}
/>
</>
)}
</Paper>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { Backdrop, Paper, CircularProgress, Typography, Box, LinearProgress } from '@mui/material';
export default function LoadingBackdrop({ open, title, description, progress = null }) {
return (
<Backdrop
sx={{
color: '#fff',
zIndex: theme => theme.zIndex.drawer + 1,
position: 'fixed',
backdropFilter: 'blur(5px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
open={open}
>
<Paper
elevation={4}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
borderRadius: 3,
bgcolor: 'background.paper',
minWidth: 280,
maxWidth: '90%',
textAlign: 'center',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
border: '1px solid rgba(0, 0, 0, 0.05)'
}}
>
<CircularProgress
size={48}
thickness={4}
sx={{
mb: 3,
color: theme => theme.palette.primary.main
}}
/>
<Typography
variant="h6"
sx={{
fontWeight: 600,
mb: 1,
textAlign: 'center',
width: '100%'
}}
>
{title}
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{
mb: 2,
textAlign: 'center',
width: '100%',
mx: 'auto'
}}
>
{description}
</Typography>
{progress && progress.total > 0 && (
<Box sx={{ width: '100%', mt: 2 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mb: 1.5
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{
textAlign: 'center',
mb: 0.5
}}
>
{progress.completed}/{progress.total} ({progress.percentage}%)
</Typography>
{progress.questionCount > 0 && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
已生成问题数: {progress.questionCount}
</Typography>
)}
</Box>
<LinearProgress
variant="determinate"
value={progress.percentage}
sx={{
height: 6,
borderRadius: 3,
'& .MuiLinearProgress-bar': {
borderRadius: 3
}
}}
/>
</Box>
)}
</Paper>
</Backdrop>
);
}

View File

@@ -0,0 +1,433 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
CircularProgress,
Typography,
Divider,
Chip,
Switch,
FormControlLabel,
Alert,
DialogContentText
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import SaveIcon from '@mui/icons-material/Save';
import ReactMarkdown from 'react-markdown';
import { useTranslation } from 'react-i18next';
import 'github-markdown-css/github-markdown-light.css';
export default function MarkdownViewDialog({ open, text, onClose, projectId, onSaveSuccess }) {
const { t } = useTranslation();
const [customSplitMode, setCustomSplitMode] = useState(false);
const [splitPoints, setSplitPoints] = useState([]);
const [selectedText, setSelectedText] = useState('');
const [savedMessage, setSavedMessage] = useState('');
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const contentRef = useRef(null);
const [chunksPreview, setChunksPreview] = useState([]);
// 根据分块点计算每个块的字数
const calculateChunksPreview = points => {
if (!text || !text.content) return [];
const content = text.content;
const sortedPoints = [...points].sort((a, b) => a.position - b.position);
const chunks = [];
let startPos = 0;
// 计算每个分块
for (let i = 0; i < sortedPoints.length; i++) {
const endPos = sortedPoints[i].position;
const chunkContent = content.substring(startPos, endPos);
if (chunkContent.trim().length > 0) {
chunks.push({
index: i + 1,
length: chunkContent.length,
preview: chunkContent.substring(0, 20) + (chunkContent.length > 20 ? '...' : '')
});
}
startPos = endPos;
}
// 添加最后一个分块
const lastChunkContent = content.substring(startPos);
if (lastChunkContent.trim().length > 0) {
chunks.push({
index: chunks.length + 1,
length: lastChunkContent.length,
preview: lastChunkContent.substring(0, 20) + (lastChunkContent.length > 20 ? '...' : '')
});
}
return chunks;
};
// 重置组件状态
useEffect(() => {
if (!open) {
setSplitPoints([]);
setCustomSplitMode(false);
setSelectedText('');
setSavedMessage('');
}
}, [open]);
// 当分块点变化时更新预览
useEffect(() => {
if (splitPoints.length > 0 && text?.content) {
const preview = calculateChunksPreview(splitPoints);
setChunksPreview(preview);
} else {
setChunksPreview([]);
}
}, [splitPoints, text?.content]);
// 处理用户选择文本事件
const handleTextSelection = () => {
if (!customSplitMode) return;
const selection = window.getSelection();
if (!selection.toString().trim()) return;
// 获取选择的文本内容和位置
const selectedContent = selection.toString();
// 计算选择位置在文档中的偏移量
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(contentRef.current);
preCaretRange.setEnd(range.endContainer, range.endOffset);
const position = preCaretRange.toString().length;
// 添加到分割点列表
const newPoint = {
id: Date.now(),
position,
preview: selectedContent.substring(0, 40) + (selectedContent.length > 40 ? '...' : '')
};
setSplitPoints(prev => [...prev, newPoint].sort((a, b) => a.position - b.position));
setSelectedText('');
};
// 删除分割点
const handleDeletePoint = id => {
setSplitPoints(prev => prev.filter(point => point.id !== id));
};
// 弹出确认对话框
const handleConfirmSave = () => {
setConfirmDialogOpen(true);
};
// 取消保存
const handleCancelSave = () => {
setConfirmDialogOpen(false);
};
// 确认并执行保存
const handleSavePoints = async () => {
// 输出调试信息
console.log('保存分块点时的数据:', {
projectId,
text: text
? {
fileId: text.fileId,
fileName: text.fileName,
contentLength: text.content ? text.content.length : 0
}
: null,
splitPointsCount: splitPoints.length
});
if (!text) {
setError(t('textSplit.missingRequiredData') + ': text 为空');
return;
}
if (!text.fileId) {
setError(t('textSplit.missingRequiredData') + ': fileId 不存在');
return;
}
if (!text.fileName) {
setError(t('textSplit.missingRequiredData') + ': fileName 不存在');
return;
}
if (!text.content) {
setError(t('textSplit.missingRequiredData') + ': content 不存在');
return;
}
if (!projectId) {
setError(t('textSplit.missingRequiredData') + ': projectId 不存在');
return;
}
setConfirmDialogOpen(false);
setSaving(true);
setError('');
try {
// 准备要发送的数据
const customSplitData = {
fileId: text.fileId,
fileName: text.fileName,
content: text.content,
splitPoints: splitPoints.map(point => ({
position: point.position,
preview: point.preview
}))
};
// 发送请求到待创建的API接口
const response = await fetch(`/api/projects/${projectId}/custom-split`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(customSplitData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || t('textSplit.customSplitFailed'));
}
// 保存成功
setSavedMessage(t('textSplit.customSplitSuccess'));
// 短暂显示成功消息后关闭对话框并刷新列表
setTimeout(() => {
setSavedMessage('');
// 关闭对话框
onClose();
// 调用父组件的刷新方法(如果提供了)
if (typeof onSaveSuccess === 'function') {
onSaveSuccess();
}
}, 1500);
} catch (err) {
console.error('保存自定义分块出错:', err);
setError(err.message || t('textSplit.customSplitFailed'));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">{text ? text.fileName : ''}</Typography>
<FormControlLabel
control={
<Switch checked={customSplitMode} onChange={e => setCustomSplitMode(e.target.checked)} color="primary" />
}
label={t('textSplit.customSplitMode')}
sx={{ ml: 2 }}
/>
</DialogTitle>
{customSplitMode && (
<Box sx={{ px: 3, py: 1, bgcolor: 'action.hover' }}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
{t('textSplit.customSplitInstructions')}
</Typography>
{/* 分割点列表 */}
{splitPoints.length > 0 && (
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
{t('textSplit.splitPointsList')} ({splitPoints.length}):
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{splitPoints.map((point, index) => (
<Chip
key={point.id}
label={`${index + 1}. ${point.preview}`}
onDelete={() => handleDeletePoint(point.id)}
deleteIcon={<DeleteIcon />}
color="primary"
variant="outlined"
/>
))}
</Box>
{/* 文本块字数预览 */}
{chunksPreview.length > 0 && (
<Box
sx={{
mt: 2,
p: 1,
bgcolor: 'background.paper',
borderRadius: 1,
border: '1px dashed',
borderColor: 'divider'
}}
>
<Typography variant="subtitle2" gutterBottom>
{t('textSplit.chunksPreview')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{chunksPreview.map(chunk => (
<Chip
key={chunk.index}
size="small"
label={`${t('textSplit.chunk')} ${chunk.index}: ${chunk.length}${t('textSplit.characters')}`}
color="info"
variant="outlined"
/>
))}
</Box>
</Box>
)}
</Box>
)}
{/* 保存按钮 */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
variant="contained"
startIcon={<SaveIcon />}
disabled={splitPoints.length === 0 || saving}
onClick={handleConfirmSave}
size="small"
>
{saving ? t('common.saving') : t('textSplit.saveSplitPoints')}
</Button>
</Box>
{/* 提示消息 */}
{savedMessage && (
<Alert severity="success" sx={{ mt: 1 }}>
{savedMessage}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
)}
</Box>
)}
<Divider />
<DialogContent dividers>
{text ? (
<Box
sx={{
maxHeight: '60vh',
overflow: 'auto',
cursor: customSplitMode ? 'text' : 'default',
position: 'relative',
'::selection': {
backgroundColor: customSplitMode ? 'primary.light' : 'inherit',
color: customSplitMode ? 'primary.contrastText' : 'inherit'
}
}}
onMouseUp={handleTextSelection}
ref={contentRef}
>
{/* 渲染带有分割点标记的内容 */}
{customSplitMode && splitPoints.length > 0 ? (
<Box>
<pre style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word', fontFamily: 'inherit' }}>
{text.content.split('').map((char, index) => {
const isSplitPoint = splitPoints.some(point => point.position === index);
const splitPointIndex = splitPoints.findIndex(point => point.position === index);
if (isSplitPoint) {
return (
<React.Fragment key={index}>
<span
style={{
display: 'inline-block',
width: '100%',
borderTop: '2px dashed #1976d2',
marginTop: '8px',
marginBottom: '8px',
position: 'relative'
}}
>
<span
style={{
position: 'absolute',
left: '0',
top: '-15px',
backgroundColor: '#1976d2',
color: 'white',
padding: '0 6px',
borderRadius: '4px',
fontSize: '12px'
}}
>
{splitPointIndex + 1}
</span>
</span>
{char}
</React.Fragment>
);
}
return char;
})}
</pre>
</Box>
) : (
<Box>
<div className="markdown-body">
<ReactMarkdown>{text.content}</ReactMarkdown>
</div>
</Box>
)}
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.close')}</Button>
</DialogActions>
{/* 确认对话框 */}
<Dialog
open={confirmDialogOpen}
onClose={handleCancelSave}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{t('textSplit.confirmCustomSplitTitle')}</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{t('textSplit.confirmCustomSplitMessage')}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelSave}>{t('common.cancel')}</Button>
<Button onClick={handleSavePoints} color="primary" variant="contained" autoFocus>
{t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
</Dialog>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { Box, Select, MenuItem, Typography, FormControl, InputLabel } from '@mui/material';
import { useTranslation } from 'react-i18next';
export default function PdfSettings({ pdfStrategy, setPdfStrategy, selectedViosnModel, setSelectedViosnModel }) {
const { t } = useTranslation();
return (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2, mt: 2 }}>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel id="pdf-strategy-label">{t('textSplit.pdfStrategy')}</InputLabel>
<Select
labelId="pdf-strategy-label"
value={pdfStrategy}
onChange={e => setPdfStrategy(e.target.value)}
label={t('textSplit.pdfStrategy')}
size="small"
>
<MenuItem value="default">{t('textSplit.defaultStrategy')}</MenuItem>
<MenuItem value="vision">{t('textSplit.visionStrategy')}</MenuItem>
</Select>
</FormControl>
{pdfStrategy === 'vision' && (
<FormControl sx={{ minWidth: 200 }}>
<InputLabel id="vision-model-label">{t('textSplit.visionModel')}</InputLabel>
<Select
labelId="vision-model-label"
value={selectedViosnModel}
onChange={e => setSelectedViosnModel(e.target.value)}
label={t('textSplit.visionModel')}
size="small"
>
<MenuItem value="gpt-4-vision-preview">GPT-4 Vision</MenuItem>
<MenuItem value="claude-3-opus">Claude-3 Opus</MenuItem>
<MenuItem value="claude-3-sonnet">Claude-3 Sonnet</MenuItem>
</Select>
</FormControl>
)}
</Box>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
Typography,
Box,
Alert
} from '@mui/material';
import { useTranslation } from 'react-i18next';
export default function DeleteConfirmDialog({ open, fileName, onClose, onConfirm }) {
const { t } = useTranslation();
return (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
maxWidth="sm"
fullWidth
>
<DialogTitle id="delete-dialog-title">
{t('common.confirmDelete')}{fileName}?
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">{t('common.confirmDeleteDescription')}</DialogContentText>
<Alert severity="warning" sx={{ my: 2 }}>
<Typography variant="body2" component="div" fontWeight="medium">
{t('textSplit.deleteFileWarning')}
</Typography>
<Box sx={{ mt: 1 }}>
<Typography variant="body2" component="div">
{t('textSplit.deleteFileWarningChunks')}
</Typography>
<Typography variant="body2" component="div">
{t('textSplit.deleteFileWarningQuestions')}
</Typography>
<Typography variant="body2" component="div">
{t('textSplit.deleteFileWarningDatasets')}
</Typography>
</Box>
</Alert>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
{t('common.cancel')}
</Button>
<Button onClick={onConfirm} color="error" variant="contained">
{t('common.delete')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { Box, List, ListItem, ListItemIcon, ListItemText, Collapse, IconButton } from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import ArticleIcon from '@mui/icons-material/Article';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { useTheme } from '@mui/material/styles';
/**
* 目录结构组件
* @param {Object} props
* @param {Array} props.items - 目录项数组
* @param {Object} props.expandedItems - 展开状态对象
* @param {Function} props.onToggleItem - 展开/折叠回调
* @param {number} props.level - 当前层级
* @param {string} props.parentId - 父级ID
*/
export default function DirectoryView({ items, expandedItems, onToggleItem, level = 0, parentId = '' }) {
const theme = useTheme();
if (!items || items.length === 0) return null;
return (
<List sx={{ pl: level > 0 ? 2 : 0 }}>
{items.map((item, index) => {
const itemId = `${parentId}-${index}`;
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems[itemId] || false;
return (
<Box key={itemId}>
<ListItem
sx={{
pl: level * 2,
borderLeft: level > 0 ? `1px solid ${theme.palette.divider}` : 'none',
ml: level > 0 ? 1 : 0
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{hasChildren ? <FolderIcon color="primary" /> : <ArticleIcon color="info" />}
</ListItemIcon>
<ListItemText
primary={item.text}
primaryTypographyProps={{
fontWeight: level === 0 ? 'bold' : 'normal',
variant: level === 0 ? 'subtitle1' : 'body2'
}}
/>
{hasChildren && (
<IconButton size="small" onClick={() => onToggleItem(itemId)}>
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
)}
</ListItem>
{hasChildren && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<DirectoryView
items={item.children}
expandedItems={expandedItems}
onToggleItem={onToggleItem}
level={level + 1}
parentId={itemId}
/>
</Collapse>
)}
</Box>
);
})}
</List>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Radio,
RadioGroup,
FormControlLabel,
FormControl,
Typography
} from '@mui/material';
import { useTranslation } from 'react-i18next';
/**
* 领域树操作选择对话框
* 提供三种选项:修订领域树、重建领域树、不更改领域树
*/
export default function DomainTreeActionDialog({ open, onClose, onConfirm, isFirstUpload, action }) {
const { t } = useTranslation();
const [value, setValue] = useState(isFirstUpload ? 'rebuild' : 'revise');
// 处理选项变更
const handleChange = event => {
setValue(event.target.value);
};
// 确认选择
const handleConfirm = () => {
onConfirm(value);
};
// 获取对话框标题
const getDialogTitle = () => {
if (isFirstUpload) {
return t('textSplit.domainTree.firstUploadTitle');
}
return action === 'upload' ? t('textSplit.domainTree.uploadTitle') : t('textSplit.domainTree.deleteTitle');
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{getDialogTitle()}</DialogTitle>
<DialogContent>
<FormControl component="fieldset">
<RadioGroup value={value} onChange={handleChange}>
{!isFirstUpload && (
<FormControlLabel
value="revise"
control={<Radio />}
label={
<>
<Typography variant="subtitle1">{t('textSplit.domainTree.reviseOption')}</Typography>
<Typography variant="body2" color="text.secondary">
{t('textSplit.domainTree.reviseDesc')}
</Typography>
</>
}
/>
)}
<FormControlLabel
value="rebuild"
control={<Radio />}
label={
<>
<Typography variant="subtitle1">{t('textSplit.domainTree.rebuildOption')}</Typography>
<Typography variant="body2" color="text.secondary">
{t('textSplit.domainTree.rebuildDesc')}
</Typography>
</>
}
/>
{!isFirstUpload && (
<FormControlLabel
value="keep"
control={<Radio />}
label={
<>
<Typography variant="subtitle1">{t('textSplit.domainTree.keepOption')}</Typography>
<Typography variant="body2" color="text.secondary">
{t('textSplit.domainTree.keepDesc')}
</Typography>
</>
}
/>
)}
</RadioGroup>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleConfirm} variant="contained" color="primary">
{t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { Box } from '@mui/material';
import { TreeView, TreeItem } from '@mui/lab';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
/**
* 领域知识树组件
* @param {Object} props
* @param {Array} props.nodes - 树节点数组
*/
export default function DomainTreeView({ nodes = [] }) {
if (!nodes || nodes.length === 0) return null;
const renderTreeItems = nodes => {
return nodes.map((node, index) => (
<TreeItem key={`node-${index}`} nodeId={`node-${index}`} label={node.text} sx={{ mb: 1 }}>
{node.children && node.children.length > 0 && renderTreeItems(node.children)}
</TreeItem>
));
};
return (
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
sx={{ flexGrow: 1, overflowY: 'auto' }}
>
{renderTreeItems(nodes)}
</TreeView>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
'use client';
import { Box, Typography, keyframes, Paper } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { handleLongFileName } from '@/lib/file/file-process';
import { useState, useEffect } from 'react';
// 定义动画效果
const pulse = keyframes`
0% {
box-shadow: 0 0 0 0 rgba(32, 76, 255, 0.2);
}
70% {
box-shadow: 0 0 0 15px rgba(32, 76, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(32, 76, 255, 0);
}
`;
const rotateAnimation = keyframes`
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
`;
const shimmer = keyframes`
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
`;
/**
* 文件处理进度展示组件 - 美化版
*
* @param {Object} props
* @param {Object} props.fileTask - 文件处理任务信息
*/
export default function FileLoadingProgress({ fileTask }) {
const { t } = useTranslation();
const [animationStep, setAnimationStep] = useState(0);
// 创建动态效果
useEffect(() => {
const interval = setInterval(() => {
setAnimationStep(prev => (prev + 1) % 4);
}, 600);
return () => clearInterval(interval);
}, []);
if (!fileTask) {
return null;
}
const pageProgress = (fileTask.current.processedPage / fileTask.current.totalPage) * 100;
const filesProgress = (fileTask.processedFiles / fileTask.totalFiles) * 100;
// 生成进度指示器文本
const getProgressIndicator = () => {
const dots = '.';
return dots.repeat(animationStep + 1);
};
return (
<Paper
elevation={3}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: 'auto',
minHeight: '25vh',
width: '80%',
maxWidth: '600px',
margin: '0 auto',
padding: 4,
borderRadius: 3,
position: 'relative',
overflow: 'hidden',
background: 'linear-gradient(45deg, #f9f9f9 0%, #ffffff 100%)',
animation: `${pulse} 2s infinite`
}}
>
{/* 背景动画元素 */}
<Box
sx={{
position: 'absolute',
top: '-50%',
left: '-50%',
width: '200%',
height: '200%',
background: 'radial-gradient(circle, rgba(32,76,255,0.05) 0%, rgba(255,255,255,0) 70%)',
animation: `${rotateAnimation} 15s linear infinite`,
zIndex: 0
}}
/>
{/* 主标题 */}
<Typography
variant="h5"
fontWeight="bold"
sx={{
mb: 3,
position: 'relative',
zIndex: 1,
background: 'linear-gradient(90deg, #3a7bd5 0%, #00d2ff 100%)',
backgroundClip: 'text',
textFillColor: 'transparent',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
{t('textSplit.pdfProcessingLoading')}
{getProgressIndicator()}
</Typography>
{/* 处理进度显示区域 */}
<Box sx={{ width: '90%', mt: 2, mb: 3, position: 'relative', zIndex: 1 }}>
{/* 当前文件进度 */}
<ProgressSection
label={t('textSplit.pdfPageProcessStatus', {
fileName: handleLongFileName(fileTask.current.fileName),
total: fileTask.current.totalPage,
completed: fileTask.current.processedPage
})}
progress={pageProgress}
color="#3a7bd5"
/>
{/* 总文件进度 */}
<ProgressSection
label={t('textSplit.pdfProcessStatus', {
total: fileTask.totalFiles,
completed: fileTask.processedFiles
})}
progress={filesProgress}
color="#00d2ff"
mt={3}
/>
</Box>
</Paper>
);
}
/**
* 进度条区域组件
*/
function ProgressSection({ label, progress, color, mt = 0 }) {
return (
<Box sx={{ mt }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
mb: 1,
alignItems: 'center'
}}
>
<Typography
variant="body2"
fontWeight="medium"
sx={{
color: 'text.primary',
fontSize: '0.9rem'
}}
>
{label}
</Typography>
<Typography
variant="h6"
fontWeight="bold"
sx={{
color,
fontSize: '1.1rem'
}}
>
{Math.round(progress)}%
</Typography>
</Box>
{/* 自定义进度条 */}
<Box
sx={{
height: 10,
borderRadius: 5,
background: '#f0f0f0',
position: 'relative',
overflow: 'hidden',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.1)'
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${progress}%`,
borderRadius: 5,
background: `linear-gradient(90deg, ${color} 0%, ${color}80 100%)`,
transition: 'width 0.5s ease',
backgroundSize: '200% 100%',
animation: `${shimmer} 2s infinite linear`
}}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
Card,
CardContent,
Typography,
Box,
Stack,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { styled } from '@mui/material/styles';
import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined';
import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined';
import LaunchOutlinedIcon from '@mui/icons-material/LaunchOutlined';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined';
const StyledCard = styled(Card)(({ theme, disabled }) => ({
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
'&:hover': disabled
? {}
: {
transform: 'translateY(-4px)',
boxShadow: theme.shadows[4]
}
}));
const OptionCard = ({
icon,
title,
description,
disabled,
onClick,
selected,
isVisionEnabled,
visionModels,
selectorName,
handleSettingChange,
selectedViosnModel
}) => (
<StyledCard
disabled={disabled}
onClick={disabled ? undefined : onClick}
sx={{
height: '100%',
border: selected ? '2px solid primary.main' : '1px solid divider',
backgroundColor: selected ? 'action.selected' : 'background.paper'
}}
>
<CardContent>
<Stack spacing={1}>
<Box sx={{ color: 'primary.main', mb: 1 }}>{icon}</Box>
<Typography variant="h6" component="div">
{title}
</Typography>
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
{isVisionEnabled && (
<FormControl fullWidth>
<InputLabel>{selectorName}</InputLabel>
<Select
label={selectorName}
value={selectedViosnModel}
onChange={e => handleSettingChange(e)}
name="vision"
>
{visionModels.map(item => (
<MenuItem key={item.id} value={item.id}>
{item.modelName} ({item.providerName})
</MenuItem>
))}
</Select>
</FormControl>
)}
</Stack>
</CardContent>
</StyledCard>
);
export default function PdfProcessingDialog({
open,
onClose,
onRadioChange,
value,
taskSettings,
visionModels,
selectedViosnModel,
setSelectedViosnModel
}) {
const { t } = useTranslation();
//检查配置中是否启用MinerU
const isMinerUEnabled = taskSettings && taskSettings.minerUToken ? true : false;
const isMinerULocalEnabled = taskSettings && taskSettings.minerULocalUrl ? true : false;
//检查配置中是否启用Vision策略
const isVisionEnabled = visionModels.length > 0 ? true : false;
//用于传递到父组件,显示当前选中的模型
let selectedModel = selectedViosnModel;
const handleOptionClick = optionValue => {
if (optionValue === 'mineru-web') {
window.open('https://mineru.net/OpenSourceTools/Extractor', '_blank');
} else {
onRadioChange({ target: { value: optionValue, selectedVision: selectedModel } });
onClose();
}
};
// 处理设置变更
const handleSettingChange = e => {
const { value } = e.target;
selectedModel = value;
setSelectedViosnModel(value);
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{t('textSplit.pdfProcess')}</DialogTitle>
<DialogContent>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: 2,
p: 1
}}
>
<OptionCard
icon={<ArticleOutlinedIcon fontSize="large" />}
title={t('textSplit.basicPdfParsing')}
description={t('textSplit.basicPdfParsingDesc')}
onClick={() => handleOptionClick('default')}
selected={value === 'default'}
/>
<OptionCard
icon={<ScienceOutlinedIcon fontSize="large" />}
title="MinerU API"
description={isMinerUEnabled ? t('textSplit.mineruApiDesc') : t('textSplit.mineruApiDescDisabled')}
disabled={!isMinerUEnabled}
onClick={() => handleOptionClick('mineru')}
selected={value === 'mineru'}
/>
<OptionCard
icon={<ChangeCircleOutlinedIcon fontSize="large" />}
title="MinerU Local"
description={isMinerULocalEnabled ? t('textSplit.mineruLocalDesc') : t('textSplit.mineruLocalDisabled')}
disabled={!isMinerULocalEnabled}
onClick={() => handleOptionClick('mineru-local')}
selected={value === 'mineru-local'}
/>
<OptionCard
icon={<LaunchOutlinedIcon fontSize="large" />}
title={t('textSplit.mineruWebPlatform')}
description={t('textSplit.mineruWebPlatformDesc')}
onClick={() => handleOptionClick('mineru-web')}
/>
<OptionCard
icon={<SmartToyOutlinedIcon fontSize="large" />}
title={t('textSplit.customVisionModel')}
description={t('textSplit.customVisionModelDesc')}
disabled={!isVisionEnabled}
onClick={() => handleOptionClick('vision')}
selected={value === 'vision'}
isVisionEnabled={isVisionEnabled}
visionModels={visionModels}
selectorName={t('settings.vision')}
selectedViosnModel={selectedViosnModel}
handleSettingChange={handleSettingChange}
/>
</Box>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import { Box } from '@mui/material';
/**
* 标签页面板组件
* @param {Object} props
* @param {number} props.value - 当前激活的标签索引
* @param {number} props.index - 当前面板对应的索引
* @param {ReactNode} props.children - 子组件
*/
export default function TabPanel({ value, index, children }) {
return (
<Box
role="tabpanel"
hidden={value !== index}
id={`domain-tabpanel-${index}`}
aria-labelledby={`domain-tab-${index}`}
sx={{ height: '100%' }}
>
{value === index && <Box sx={{ height: '100%' }}>{children}</Box>}
</Box>
);
}

View File

@@ -0,0 +1,207 @@
'use client';
import {
Box,
Button,
Typography,
List,
ListItem,
ListItemText,
Divider,
CircularProgress,
Tooltip
} from '@mui/material';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import DeleteIcon from '@mui/icons-material/Delete';
import { alpha } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import React, { useRef, useState } from 'react';
export default function UploadArea({
theme,
files,
uploading,
uploadedFiles,
onFileSelect,
onRemoveFile,
onUpload,
selectedModel
}) {
const { t } = useTranslation();
const [dragActive, setDragActive] = useState(false);
const inputRef = useRef(null);
// 拖拽进入
const handleDragOver = e => {
e.preventDefault();
e.stopPropagation();
if (!dragActive) setDragActive(true);
};
// 拖拽离开
const handleDragLeave = e => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
};
// 拖拽释放
const handleDrop = e => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (!selectedModel?.id || uploading) return;
const files = e.dataTransfer.files;
if (files && files.length > 0) {
// 构造一个模拟的 event 以复用 onFileSelect
const event = { target: { files } };
onFileSelect(event);
}
};
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
height: '100%',
border: `2px dashed ${dragActive ? theme.palette.primary.main : alpha(theme.palette.primary.main, 0.2)}`,
borderRadius: 2,
bgcolor: dragActive ? alpha(theme.palette.primary.main, 0.12) : alpha(theme.palette.primary.main, 0.05),
transition: 'all 0.3s ease',
'&:hover': {
bgcolor: alpha(theme.palette.primary.main, 0.08),
borderColor: alpha(theme.palette.primary.main, 0.3)
},
cursor: uploading || !selectedModel?.id ? 'not-allowed' : 'pointer',
position: 'relative'
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{dragActive && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
bgcolor: alpha(theme.palette.primary.main, 0.3),
zIndex: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
borderRadius: 2,
border: `3px solid ${theme.palette.primary.main}`,
backdropFilter: 'blur(2px)'
}}
>
<Box
sx={{
bgcolor: 'rgba(255, 255, 255, 0.9)',
p: 3,
borderRadius: 1,
boxShadow: '0 4px 8px rgba(0,0,0,0.1)',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
border: `1px solid ${theme.palette.primary.main}`
}}
>
<UploadFileIcon color="primary" sx={{ fontSize: 40, mb: 1 }} />
<Typography variant="h6" color="primary" sx={{ fontWeight: 'bold' }}>
{t('textSplit.dragToUpload', { defaultValue: '拖拽文件到此处上传' })}
</Typography>
</Box>
</Box>
)}
<Typography variant="subtitle1" gutterBottom>
{t('textSplit.uploadNewDocument')}
</Typography>
<Tooltip
title={!selectedModel?.id ? t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' }) : ''}
>
<span>
<Button
component="label"
variant="contained"
startIcon={<UploadFileIcon />}
sx={{ mb: 2, mt: 2 }}
disabled={!selectedModel?.id || uploading}
>
{t('textSplit.selectFile')}
<input
type="file"
hidden
accept=".md,.txt,.docx,.pdf,.epub"
multiple
onChange={onFileSelect}
disabled={!selectedModel?.id || uploading}
/>
</Button>
</span>
</Tooltip>
<Typography variant="body2" color="textSecondary">
{uploadedFiles.total > 0 ? t('textSplit.mutilFileMessage') : t('textSplit.supportedFormats')}
</Typography>
{files.length > 0 && (
<Box sx={{ mt: 3, width: '100%' }}>
<Typography variant="subtitle2" gutterBottom>
{t('textSplit.selectedFiles', { count: files.length })}
</Typography>
<List sx={{ bgcolor: theme.palette.background.paper, borderRadius: 1, maxHeight: '200px', overflow: 'auto' }}>
{files.map((file, index) => (
<Box key={index}>
<ListItem
secondaryAction={
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={() => onRemoveFile(index)}
disabled={uploading}
>
{t('common.delete')}
</Button>
}
>
<ListItemText primary={file.name} secondary={`${(file.size / 1024).toFixed(2)} KB`} />
</ListItem>
{index < files.length - 1 && <Divider />}
</Box>
))}
</List>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'center' }}>
<Tooltip
title={
!selectedModel?.id ? t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' }) : ''
}
>
<span>
<Button
variant="contained"
color="primary"
onClick={onUpload}
disabled={uploading || !selectedModel?.id}
sx={{ minWidth: 120 }}
>
{uploading ? <CircularProgress size={24} /> : t('textSplit.uploadAndProcess')}
</Button>
</span>
</Tooltip>
</Box>
</Box>
)}
</Box>
);
}