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