Files

566 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
});