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