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