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