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,58 @@
'use client';
import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button } from '@mui/material';
import { useTranslation } from 'react-i18next';
/**
* 确认对话框组件
* @param {Object} props
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调函数
* @param {Function} props.onConfirm - 确认操作的回调函数
* @param {string} props.title - 对话框标题
* @param {string} props.content - 对话框内容
* @param {string} props.confirmText - 确认按钮文本,默认为 "确认删除"
* @param {string} props.cancelText - 取消按钮文本,默认为 "取消"
* @param {string} props.confirmColor - 确认按钮颜色,默认为 "error"
*/
export default function ConfirmDialog({
open,
onClose,
onConfirm,
title,
content,
confirmText,
cancelText,
confirmColor = 'error'
}) {
const { t } = useTranslation();
const handleConfirm = () => {
onClose();
if (onConfirm) {
onConfirm();
}
};
return (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
>
<DialogTitle id="confirm-dialog-title">{title}</DialogTitle>
<DialogContent>
<DialogContentText id="confirm-dialog-description">{content}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
{cancelText || t('common.cancel')}
</Button>
<Button onClick={handleConfirm} color={confirmColor} variant="contained" autoFocus>
{confirmText || t('common.confirmDelete')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
Box,
Divider
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download';
export default function ExportQuestionsDialog({ open, onClose, onExport, selectedCount, totalCount }) {
const { t } = useTranslation();
const [format, setFormat] = useState('json');
const [exportScope, setExportScope] = useState('all');
const handleExport = () => {
const exportOptions = {
format,
selectedIds: exportScope === 'selected' ? [] : undefined
};
onExport(exportOptions);
onClose();
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('questions.exportQuestions')}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 2 }}>
{/* 导出范围 */}
<FormControl component="fieldset">
<FormLabel component="legend">{t('questions.exportScope')}</FormLabel>
<RadioGroup value={exportScope} onChange={e => setExportScope(e.target.value)}>
<FormControlLabel
value="all"
control={<Radio />}
label={t('questions.exportAll', { count: totalCount })}
/>
{selectedCount > 0 && (
<FormControlLabel
value="selected"
control={<Radio />}
label={t('questions.exportSelected', { count: selectedCount })}
/>
)}
</RadioGroup>
</FormControl>
<Divider />
{/* 导出格式 */}
<FormControl component="fieldset">
<FormLabel component="legend">{t('questions.exportFormat')}</FormLabel>
<RadioGroup value={format} onChange={e => setFormat(e.target.value)}>
<FormControlLabel value="json" control={<Radio />} label="JSON" />
<FormControlLabel value="jsonl" control={<Radio />} label="JSONL" />
<FormControlLabel value="txt" control={<Radio />} label={t('questions.txtFormat')} />
<FormControlLabel value="csv" control={<Radio />} label="CSV" />
</RadioGroup>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleExport} variant="contained" startIcon={<DownloadIcon />}>
{t('export.title')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
Autocomplete,
TextField as MuiTextField,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import axios from 'axios';
export default function QuestionEditDialog({
open,
onClose,
onSubmit,
initialData,
projectId,
tags,
mode = 'create' // 'create' or 'edit'
}) {
const [chunks, setChunks] = useState([]);
const [images, setImages] = useState([]);
const { t } = useTranslation();
// 获取文本块的标题
const getChunkTitle = chunkId => {
const chunk = chunks.find(c => c.id === chunkId);
return chunk?.name || chunkId; // 直接使用文件名
};
const [formData, setFormData] = useState({
id: '',
question: '',
sourceType: 'text', // 新增:数据源类型
chunkId: '',
imageId: '', // 新增图片ID
label: '' // 默认不选中任何标签
});
const getChunks = async projectId => {
// 获取文本块列表
const response = await axios.get(`/api/projects/${projectId}/split`);
if (response.status !== 200) {
throw new Error(t('common.fetchError'));
}
setChunks(response.data.chunks || []);
};
const getImages = async projectId => {
// 获取图片列表只获取ID和名称
try {
const response = await axios.get(`/api/projects/${projectId}/images?page=1&pageSize=10000&simple=true`);
if (response.status === 200) {
setImages(response.data.data || []);
}
} catch (error) {
console.error('Failed to fetch images:', error);
}
};
useEffect(() => {
getChunks(projectId);
getImages(projectId);
if (initialData) {
// 根据 imageId 判断数据源类型
console.log('initialData:', initialData);
const sourceType = initialData.imageId ? 'image' : 'text';
setFormData({
id: initialData.id,
question: initialData.question || '',
sourceType: sourceType,
chunkId: initialData.chunkId || '',
imageId: initialData.imageId || '',
label: initialData.label || 'other' // 改用 label 而不是 label
});
} else {
setFormData({
id: '',
question: '',
sourceType: 'text',
chunkId: '',
imageId: '',
label: ''
});
}
}, [initialData]);
const handleSubmit = () => {
onSubmit(formData);
onClose();
};
const flattenTags = (tags = [], prefix = '') => {
let flatTags = [];
const traverse = node => {
flatTags.push({
id: node.label, // 使用标签名作为 id
label: node.label, // 直接使用原始标签名
originalLabel: node.label
});
if (node.child && node.child.length > 0) {
node.child.forEach(child => traverse(child));
}
};
tags.forEach(tag => traverse(tag));
flatTags.push({
id: 'other',
label: t('datasets.uncategorized'),
originalLabel: 'other'
});
return flatTags;
};
const flattenedTags = useMemo(() => flattenTags(tags), [tags, t]);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{mode === 'create' ? t('questions.createQuestion') : t('questions.editQuestion')}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
{/* 数据源类型选择 */}
<FormControl fullWidth>
<InputLabel>{t('questions.sourceType', { defaultValue: '数据源类型' })}</InputLabel>
<Select
value={formData.sourceType}
label={t('questions.sourceType', { defaultValue: '数据源类型' })}
onChange={e => {
setFormData({
...formData,
sourceType: e.target.value,
chunkId: '',
imageId: ''
});
}}
>
<MenuItem value="text">{t('questions.template.sourceType.text')}</MenuItem>
<MenuItem value="image">{t('questions.template.sourceType.image')}</MenuItem>
</Select>
</FormControl>
{/* 问题内容 */}
<TextField
label={t('questions.questionContent')}
multiline
rows={4}
fullWidth
value={formData.question}
onChange={e => setFormData({ ...formData, question: e.target.value })}
/>
{/* 文本块选择(仅当数据源为文本时显示) */}
{formData.sourceType === 'text' && (
<Autocomplete
fullWidth
options={chunks}
getOptionLabel={chunk => getChunkTitle(chunk.id)}
value={chunks.find(chunk => chunk.id === formData.chunkId) || null}
onChange={(e, newValue) => setFormData({ ...formData, chunkId: newValue ? newValue.id : '' })}
renderInput={params => (
<MuiTextField {...params} label={t('questions.selectChunk')} placeholder={t('questions.searchChunk')} />
)}
/>
)}
{/* 图片选择(仅当数据源为图片时显示) */}
{formData.sourceType === 'image' && (
<Autocomplete
fullWidth
options={images}
getOptionLabel={image => image.imageName || ''}
value={images.find(image => image.id === formData.imageId) || null}
onChange={(e, newValue) => setFormData({ ...formData, imageId: newValue ? newValue.id : '' })}
renderInput={params => (
<MuiTextField
{...params}
label={t('questions.selectImage', { defaultValue: '选择图片' })}
placeholder={t('questions.searchImage', { defaultValue: '搜索图片...' })}
/>
)}
/>
)}
{/* 标签选择 */}
{formData.sourceType === 'text' && (
<Autocomplete
fullWidth
options={flattenedTags}
getOptionLabel={tag => tag.label}
value={flattenedTags.find(tag => tag.id === formData.label) || null}
onChange={(e, newValue) => setFormData({ ...formData, label: newValue ? newValue.id : '' })}
renderInput={params => (
<MuiTextField {...params} label={t('questions.selectTag')} placeholder={t('questions.searchTag')} />
)}
/>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={!formData.question || (formData.sourceType === 'text' ? !formData.chunkId : !formData.imageId)}
>
{mode === 'create' ? t('common.create') : t('common.save')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { Box, Stack, Checkbox, Typography, TextField, InputAdornment, Select, MenuItem, useTheme } from '@mui/material';
import { useTranslation } from 'react-i18next';
import SearchIcon from '@mui/icons-material/Search';
export default function QuestionsFilter({
// 选择相关
selectedQuestionsCount,
totalQuestions,
isAllSelected,
isIndeterminate,
onSelectAll,
// 搜索相关
searchTerm,
onSearchChange,
searchMatchMode,
onSearchMatchModeChange,
// 过滤相关
answerFilter,
onFilterChange,
// 文本块名称筛选
chunkNameFilter,
onChunkNameFilterChange,
// 数据源类型筛选
sourceTypeFilter,
onSourceTypeFilterChange,
activeTab
}) {
const { t } = useTranslation();
const theme = useTheme();
if (activeTab === 1) {
return <></>;
}
return (
<Box sx={{ p: 2 }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
alignItems={{ xs: 'stretch', sm: 'center' }}
justifyContent="space-between"
>
{/* 选择区域 */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Checkbox checked={isAllSelected} indeterminate={isIndeterminate} onChange={onSelectAll} />
<Typography variant="body2" sx={{ ml: 1 }}>
{selectedQuestionsCount > 0
? t('questions.selectedCount', { count: selectedQuestionsCount })
: t('questions.selectAll')}
(
{t('questions.totalCount', {
count: totalQuestions
})}
)
</Typography>
</Box>
{/* 搜索和过滤区域 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
{/* 组合搜索框:下拉选择(匹配/不匹配)+ 输入框 */}
<Box sx={{ display: 'flex', alignItems: 'center', width: { xs: '100%', sm: 300 } }}>
<Select
value={searchMatchMode}
onChange={onSearchMatchModeChange}
size="small"
sx={{
width: 110,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'white',
'& .MuiOutlinedInput-notchedOutline': {
borderRight: 'none',
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.23)'
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.87)'
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'primary.main'
}
}}
>
<MenuItem value="match">{t('questions.searchMatch')}</MenuItem>
<MenuItem value="notMatch">{t('questions.searchNotMatch')}</MenuItem>
</Select>
<TextField
placeholder={t('questions.searchPlaceholder')}
variant="outlined"
size="small"
fullWidth
value={searchTerm}
onChange={onSearchChange}
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
)
}}
/>
</Box>
<TextField
placeholder={t('questions.filterChunkNamePlaceholder')}
variant="outlined"
size="small"
sx={{ width: { xs: '100%', sm: 200 } }}
value={chunkNameFilter}
onChange={onChunkNameFilterChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
)
}}
/>
<Select
value={sourceTypeFilter}
onChange={onSourceTypeFilterChange}
size="small"
sx={{
width: { xs: '100%', sm: 150 },
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'white',
borderRadius: '8px',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.23)'
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.87)'
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'primary.main'
}
}}
MenuProps={{
PaperProps: {
elevation: 2,
sx: { mt: 1, borderRadius: 2 }
}
}}
>
<MenuItem value="all">{t('questions.sourceTypeAll')}</MenuItem>
<MenuItem value="text">{t('questions.sourceTypeText')}</MenuItem>
<MenuItem value="image">{t('questions.sourceTypeImage')}</MenuItem>
</Select>
<Select
value={answerFilter}
onChange={onFilterChange}
size="small"
sx={{
width: { xs: '100%', sm: 150 },
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'white',
borderRadius: '8px',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.23)'
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.87)'
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'primary.main'
}
}}
MenuProps={{
PaperProps: {
elevation: 2,
sx: { mt: 1, borderRadius: 2 }
}
}}
>
<MenuItem value="all">{t('questions.filterAll')}</MenuItem>
<MenuItem value="answered">{t('questions.filterAnswered')}</MenuItem>
<MenuItem value="unanswered">{t('questions.filterUnanswered')}</MenuItem>
</Select>
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useState } from 'react';
import { Box, Typography, Button, Tooltip, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
import { useTranslation } from 'react-i18next';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import DatasetIcon from '@mui/icons-material/Dataset';
import ChatIcon from '@mui/icons-material/Chat';
import ImageIcon from '@mui/icons-material/Image';
import LibraryAddIcon from '@mui/icons-material/LibraryAdd';
import DownloadIcon from '@mui/icons-material/Download';
export default function QuestionsPageHeader({
questionsTotal,
selectedQuestionsCount,
onBatchDeleteQuestions,
onOpenCreateDialog,
onOpenCreateTemplateDialog,
onBatchGenerateAnswers,
onAutoGenerateDatasets,
onAutoGenerateMultiTurnDatasets,
onAutoGenerateImageDatasets,
onExportQuestions,
activeTab
}) {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const [createAnchorEl, setCreateAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const createMenuOpen = Boolean(createAnchorEl);
const handleMenuClick = event => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleCreateMenuClick = event => {
setCreateAnchorEl(event.currentTarget);
};
const handleCreateMenuClose = () => {
setCreateAnchorEl(null);
};
const handleCreateQuestion = () => {
handleCreateMenuClose();
onOpenCreateDialog();
};
const handleCreateTemplate = () => {
handleCreateMenuClose();
onOpenCreateTemplateDialog();
};
const handleSingleTurnGenerate = () => {
handleMenuClose();
onAutoGenerateDatasets();
};
const handleMultiTurnGenerate = () => {
handleMenuClose();
onAutoGenerateMultiTurnDatasets();
};
const handleImageDatasetGenerate = () => {
handleMenuClose();
onAutoGenerateImageDatasets();
};
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">
{t('questions.title')} ({questionsTotal})
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="outlined" startIcon={<DownloadIcon />} onClick={onExportQuestions}>
{t('questions.exportQuestions')}
</Button>
<Button
variant="outlined"
color={selectedQuestionsCount > 0 ? 'error' : 'primary'}
startIcon={<DeleteIcon />}
onClick={onBatchDeleteQuestions}
disabled={selectedQuestionsCount === 0}
>
{t('questions.deleteSelected')}
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
endIcon={<ArrowDropDownIcon />}
onClick={handleCreateMenuClick}
>
{t('questions.createQuestion')}
</Button>
<Menu
anchorEl={createAnchorEl}
open={createMenuOpen}
onClose={handleCreateMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
>
<MenuItem onClick={handleCreateQuestion}>
<ListItemIcon>
<AddIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('questions.createNormalQuestion')} />
</MenuItem>
<MenuItem onClick={handleCreateTemplate}>
<ListItemIcon>
<LibraryAddIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('questions.createQuestionTemplate')} />
</MenuItem>
</Menu>
{/* <Button
variant="contained"
startIcon={<AutoFixHighIcon />}
onClick={onBatchGenerateAnswers}
disabled={selectedQuestionsCount === 0}
>
{t('questions.batchGenerate')}
</Button> */}
<Tooltip
title={t('questions.autoGenerateDatasetTip', {
defaultValue: '创建后台批量处理任务:自动查询待生成答案的问题并生成数据集'
})}
>
<Button
variant="outlined"
color="secondary"
startIcon={<AutoFixHighIcon />}
endIcon={<ArrowDropDownIcon />}
onClick={handleMenuClick}
>
{t('questions.autoGenerateDataset', { defaultValue: '自动生成数据集' })}
</Button>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
>
<MenuItem onClick={handleSingleTurnGenerate}>
<ListItemIcon>
<DatasetIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('questions.generateSingleTurnDataset', { defaultValue: '生成单轮对话数据集' })} />
</MenuItem>
<MenuItem onClick={handleMultiTurnGenerate}>
<ListItemIcon>
<ChatIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('questions.generateMultiTurnDataset', { defaultValue: '生成多轮对话数据集' })} />
</MenuItem>
<MenuItem onClick={handleImageDatasetGenerate}>
<ListItemIcon>
<ImageIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('questions.generateImageDataset', { defaultValue: '生成图像问答数据集' })} />
</MenuItem>
</Menu>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Chip,
Typography,
Alert
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { useTranslation } from 'react-i18next';
export default function TemplateListView({ templates, onEditTemplate, onDeleteTemplate, loading }) {
const { t } = useTranslation();
const getAnswerTypeLabel = type => {
const labels = {
text: t('questions.template.answerType.text'),
label: t('questions.template.answerType.tags'),
custom_format: t('questions.template.answerType.customFormat')
};
return labels[type] || type;
};
const getSourceTypeLabel = type => {
const labels = {
image: t('questions.template.sourceType.image'),
text: t('questions.template.sourceType.text')
};
return labels[type] || type;
};
if (loading) {
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography>{t('common.loading')}</Typography>
</Box>
);
}
if (!templates || templates.length === 0) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="info">{t('questions.template.noTemplates')}</Alert>
</Box>
);
}
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('questions.template.question')}</TableCell>
<TableCell>{t('questions.template.sourceType.label')}</TableCell>
<TableCell>{t('questions.template.answerType.label')}</TableCell>
<TableCell>{t('questions.template.description')}</TableCell>
<TableCell>{t('questions.template.used')}</TableCell>
<TableCell align="right">{t('common.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{templates.map(template => (
<TableRow key={template.id} hover>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300 }}>
{template.question}
</Typography>
</TableCell>
<TableCell>
<Chip
label={getSourceTypeLabel(template.sourceType)}
size="small"
color={template.sourceType === 'image' ? 'primary' : 'secondary'}
variant="outlined"
/>
</TableCell>
<TableCell>
<Chip label={getAnswerTypeLabel(template.answerType)} size="small" color="default" />
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 200 }}>
{template.description || '-'}
</Typography>
</TableCell>
<TableCell>
{template.usageCount > 0 ? (
<Chip label={template.usageCount} size="small" color="success" />
) : (
<Typography variant="body2" color="text.secondary">
0
</Typography>
)}
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => onEditTemplate(template)} sx={{ mr: 1 }}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => onDeleteTemplate(template.id)}
disabled={template.usageCount > 0}
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@@ -0,0 +1,335 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
Chip,
Typography,
Alert,
FormControlLabel,
Checkbox
} from '@mui/material';
import { useTranslation } from 'react-i18next';
export default function TemplateFormDialog({ open, onClose, onSubmit, template }) {
const { t } = useTranslation();
const [formData, setFormData] = useState({
question: '',
sourceType: 'text',
answerType: 'text',
description: '',
labels: [],
customFormat: '',
autoGenerate: true
});
const [labelInput, setLabelInput] = useState('');
const [errors, setErrors] = useState({});
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
useEffect(() => {
if (template) {
setFormData({
question: template.question || '',
sourceType: template.sourceType || 'text',
answerType: template.answerType || 'text',
description: template.description || '',
labels: template.labels || [],
customFormat: template.customFormat ? JSON.stringify(template.customFormat, null, 2) : '',
autoGenerate: true // 编辑模式下默认不自动生成
});
} else {
setFormData({
question: '',
sourceType: 'text',
answerType: 'text',
description: '',
labels: [],
customFormat: '',
autoGenerate: true
});
}
setErrors({});
setShowConfirmDialog(false);
}, [template, open]);
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// 清除该字段的错误
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const handleAddLabel = () => {
const trimmed = labelInput.trim();
if (trimmed && !formData.labels.includes(trimmed)) {
setFormData(prev => ({
...prev,
labels: [...prev.labels, trimmed]
}));
setLabelInput('');
}
};
const handleDeleteLabel = labelToDelete => {
setFormData(prev => ({
...prev,
labels: prev.labels.filter(label => label !== labelToDelete)
}));
};
const validate = () => {
const newErrors = {};
if (!formData.question.trim()) {
newErrors.question = t('questions.template.errors.questionRequired');
}
if (formData.answerType === 'label' && formData.labels.length === 0) {
newErrors.labels = t('questions.template.errors.labelsRequired');
}
if (formData.answerType === 'custom_format') {
if (!formData.customFormat.trim()) {
newErrors.customFormat = t('questions.template.errors.customFormatRequired');
} else {
try {
JSON.parse(formData.customFormat);
} catch (e) {
newErrors.customFormat = t('questions.template.errors.invalidJson');
}
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (!validate()) {
return;
}
// 如果选择了自动生成,显示确认对话框
if (formData.autoGenerate) {
setShowConfirmDialog(true);
return;
}
// 直接提交
submitTemplate();
};
const submitTemplate = () => {
const submitData = {
question: formData.question.trim(),
sourceType: formData.sourceType,
answerType: formData.answerType,
description: formData.description.trim(),
autoGenerate: formData.autoGenerate,
templateId: template?.id // 编辑模式时传递模板ID用于查找未创建问题的数据源
};
if (formData.answerType === 'label') {
submitData.labels = formData.labels;
}
if (formData.answerType === 'custom_format') {
try {
submitData.customFormat = JSON.parse(formData.customFormat);
} catch (e) {
// 已在验证中处理
return;
}
}
onSubmit(submitData);
setShowConfirmDialog(false);
};
const handleConfirmGenerate = () => {
submitTemplate();
};
const handleCancelGenerate = () => {
setShowConfirmDialog(false);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{template ? t('questions.template.edit') : t('questions.template.create')}</DialogTitle>
<DialogContent dividers>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* 数据源类型 */}
<FormControl fullWidth>
<InputLabel>{t('questions.template.sourceTypeInfo')}</InputLabel>
<Select
value={formData.sourceType}
label={t('questions.template.sourceTypeInfo')}
onChange={e => handleChange('sourceType', e.target.value)}
>
<MenuItem value="text">{t('questions.template.sourceType.text')}</MenuItem>
<MenuItem value="image">{t('questions.template.sourceType.image')}</MenuItem>
</Select>
</FormControl>
{/* 问题内容 */}
<TextField
fullWidth
label={t('questions.template.question')}
value={formData.question}
onChange={e => handleChange('question', e.target.value)}
error={!!errors.question}
helperText={errors.question}
required
/>
{/* 答案类型 */}
<FormControl fullWidth>
<InputLabel>{t('questions.template.answerType.label')}</InputLabel>
<Select
value={formData.answerType}
label={t('questions.template.answerType.label')}
onChange={e => handleChange('answerType', e.target.value)}
>
<MenuItem value="text">{t('questions.template.answerType.text')}</MenuItem>
<MenuItem value="label">{t('questions.template.answerType.tags')}</MenuItem>
<MenuItem value="custom_format">{t('questions.template.answerType.customFormat')}</MenuItem>
</Select>
</FormControl>
{/* 描述 */}
<TextField
fullWidth
label={t('questions.template.description')}
value={formData.description}
onChange={e => handleChange('description', e.target.value)}
helperText={t('questions.template.descriptionHelp')}
multiline
rows={2}
/>
{/* 标签输入 (仅当答案类型为 label 时显示) */}
{formData.answerType === 'label' && (
<Box>
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
<TextField
fullWidth
label={t('questions.template.addLabel')}
value={labelInput}
onChange={e => setLabelInput(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddLabel();
}
}}
error={!!errors.labels}
helperText={errors.labels}
/>
<Button variant="outlined" onClick={handleAddLabel} sx={{ minWidth: '100px' }}>
{t('common.add')}
</Button>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{formData.labels.map(label => (
<Chip
key={label}
label={label}
onDelete={() => handleDeleteLabel(label)}
color="primary"
variant="outlined"
/>
))}
</Box>
</Box>
)}
{/* 自定义格式输入 (仅当答案类型为 custom_format 时显示) */}
{formData.answerType === 'custom_format' && (
<Box>
<TextField
fullWidth
label={t('questions.template.customFormat')}
value={formData.customFormat}
onChange={e => handleChange('customFormat', e.target.value)}
multiline
rows={6}
error={!!errors.customFormat}
helperText={errors.customFormat || t('questions.template.customFormatHelp')}
placeholder='{"field1": "description", "field2": "description"}'
/>
<Alert severity="info" sx={{ mt: 1 }}>
{t('questions.template.customFormatInfo')}
</Alert>
</Box>
)}
{/* 自动生成问题选项 */}
<Box>
<FormControlLabel
control={
<Checkbox
checked={formData.autoGenerate}
onChange={e => handleChange('autoGenerate', e.target.checked)}
color="primary"
/>
}
label={t('questions.template.autoGenerate')}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 4, mt: 0.5 }}>
{formData.sourceType === 'text'
? t('questions.template.autoGenerateHelpText')
: t('questions.template.autoGenerateHelpImage')}
</Typography>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} variant="contained">
{template ? t('common.save') : t('common.create')}
</Button>
</DialogActions>
{/* 自动生成确认对话框 */}
<Dialog open={showConfirmDialog} onClose={handleCancelGenerate}>
<DialogTitle>{t('questions.template.confirmAutoGenerate')}</DialogTitle>
<DialogContent>
<Typography>
{template
? formData.sourceType === 'text'
? t('questions.template.confirmAutoGenerateEditTextMessage', {
defaultValue: '您选择了自动生成问题。系统将为所有还未创建此模板问题的文本块创建问题。'
})
: t('questions.template.confirmAutoGenerateEditImageMessage', {
defaultValue: '您选择了自动生成问题。系统将为所有还未创建此模板问题的图片创建问题。'
})
: formData.sourceType === 'text'
? t('questions.template.confirmAutoGenerateTextMessage')
: t('questions.template.confirmAutoGenerateImageMessage')}
</Typography>
<Alert severity="warning" sx={{ mt: 2 }}>
{t('questions.template.autoGenerateWarning')}
</Alert>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelGenerate}>{t('common.cancel')}</Button>
<Button onClick={handleConfirmGenerate} variant="contained" color="primary">
{t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
</Dialog>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Chip,
Typography,
Alert,
Tabs,
Tab
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { useTranslation } from 'react-i18next';
import TemplateFormDialog from './TemplateFormDialog';
export default function TemplateManagementDialog({
open,
onClose,
templates,
onCreateTemplate,
onUpdateTemplate,
onDeleteTemplate,
loading
}) {
const { t } = useTranslation();
const [formOpen, setFormOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState(null);
const [currentTab, setCurrentTab] = useState(0); // 0: image, 1: text
const handleCreate = () => {
setEditingTemplate(null);
setFormOpen(true);
};
const handleEdit = template => {
setEditingTemplate(template);
setFormOpen(true);
};
const handleDelete = async templateId => {
const confirmed = window.confirm(t('questions.template.deleteConfirm'));
if (confirmed) {
await onDeleteTemplate(templateId);
}
};
const handleFormSubmit = async data => {
// 根据当前tab添加sourceType
const sourceType = currentTab === 0 ? 'image' : 'text';
const templateData = { ...data, sourceType };
if (editingTemplate) {
await onUpdateTemplate(editingTemplate.id, templateData);
} else {
await onCreateTemplate(templateData);
}
setFormOpen(false);
};
const getAnswerTypeLabel = type => {
const labels = {
text: t('questions.template.answerType.text'),
label: t('questions.template.answerType.tags'),
custom_format: t('questions.template.answerType.customFormat')
};
return labels[type] || type;
};
// 按数据源类型分组模板
const imageTemplates = templates.filter(t => t.sourceType === 'image');
const textTemplates = templates.filter(t => t.sourceType === 'text');
const currentTemplates = currentTab === 0 ? imageTemplates : textTemplates;
const renderTemplateList = templateList => {
if (templateList.length === 0) {
return <Alert severity="info">{t('questions.template.noTemplates')}</Alert>;
}
return (
<List>
{templateList.map(template => (
<ListItem key={template.id} divider>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography>{template.question}</Typography>
<Chip
label={getAnswerTypeLabel(template.answerType)}
size="small"
color="primary"
variant="outlined"
/>
{template.usageCount > 0 && (
<Chip
label={`${t('questions.template.used')} ${template.usageCount}`}
size="small"
color="default"
/>
)}
</Box>
}
secondary={template.description}
/>
<ListItemSecondaryAction>
<IconButton edge="end" onClick={() => handleEdit(template)} sx={{ mr: 1 }}>
<EditIcon />
</IconButton>
<IconButton edge="end" onClick={() => handleDelete(template.id)} disabled={template.usageCount > 0}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
);
};
return (
<>
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">{t('questions.template.management')}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleCreate} size="small">
{t('questions.template.create')}
</Button>
</Box>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={currentTab} onChange={(e, newValue) => setCurrentTab(newValue)}>
<Tab label={t('questions.template.sourceType.image')} />
<Tab label={t('questions.template.sourceType.text')} />
</Tabs>
</Box>
{renderTemplateList(currentTemplates)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.close')}</Button>
</DialogActions>
</Dialog>
<TemplateFormDialog
open={formOpen}
onClose={() => setFormOpen(false)}
onSubmit={handleFormSubmit}
template={editingTemplate}
sourceType={currentTab === 0 ? 'image' : 'text'}
/>
</>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import { toast } from 'sonner';
export function useQuestionDelete(projectId, onDeleteSuccess) {
const { t } = useTranslation();
// 确认对话框状态
const [confirmDialog, setConfirmDialog] = useState({
open: false,
title: '',
content: '',
confirmAction: null
});
// 执行单个问题删除
const executeDeleteQuestion = async (questionId, selectedQuestions, setSelectedQuestions) => {
toast.promise(axios.delete(`/api/projects/${projectId}/questions/${questionId}`), {
loading: '数据删除中',
success: data => {
// 更新选中状态
setSelectedQuestions(prev => (prev.includes(questionId) ? prev.filter(id => id !== questionId) : prev));
// 调用成功回调
if (onDeleteSuccess) {
onDeleteSuccess();
}
return t('common.deleteSuccess');
},
error: error => {
return error.response?.data?.message || '删除失败';
}
});
};
// 确认删除单个问题
const confirmDeleteQuestion = (questionId, selectedQuestions, setSelectedQuestions) => {
setConfirmDialog({
open: true,
title: t('common.confirmDelete'),
content: t('common.confirmDeleteQuestion'),
confirmAction: () => executeDeleteQuestion(questionId, selectedQuestions, setSelectedQuestions)
});
};
// 处理删除单个问题的入口函数
const handleDeleteQuestion = (questionId, selectedQuestions, setSelectedQuestions) => {
confirmDeleteQuestion(questionId, selectedQuestions, setSelectedQuestions);
};
// 执行批量删除问题
const executeBatchDeleteQuestions = async (selectedQuestions, setSelectedQuestions) => {
toast.promise(
axios.delete(`/api/projects/${projectId}/questions/batch-delete`, {
data: { questionIds: selectedQuestions }
}),
{
loading: `正在删除 ${selectedQuestions.length} 个问题...`,
success: data => {
// 调用成功回调
if (onDeleteSuccess) {
onDeleteSuccess();
}
// 清空选中状态
setSelectedQuestions([]);
return `成功删除 ${selectedQuestions.length} 个问题`;
},
error: error => {
return error.response?.data?.message || '批量删除问题失败';
}
}
);
};
// 确认批量删除问题
const confirmBatchDeleteQuestions = (selectedQuestions, setSelectedQuestions) => {
if (selectedQuestions.length === 0) {
toast.warning('请先选择问题');
return;
}
setConfirmDialog({
open: true,
title: '确认批量删除问题',
content: `您确定要删除选中的 ${selectedQuestions.length} 个问题吗?此操作不可恢复。`,
confirmAction: () => executeBatchDeleteQuestions(selectedQuestions, setSelectedQuestions)
});
};
// 处理批量删除问题的入口函数
const handleBatchDeleteQuestions = (selectedQuestions, setSelectedQuestions) => {
confirmBatchDeleteQuestions(selectedQuestions, setSelectedQuestions);
};
// 关闭确认对话框
const closeConfirmDialog = () => {
setConfirmDialog({
open: false,
title: '',
content: '',
confirmAction: null
});
};
// 确认对话框的确认操作
const handleConfirmAction = () => {
closeConfirmDialog();
if (confirmDialog.confirmAction) {
confirmDialog.confirmAction();
}
};
return {
// 状态
confirmDialog,
// 方法
handleDeleteQuestion,
handleBatchDeleteQuestions,
closeConfirmDialog,
handleConfirmAction
};
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import request from '@/lib/util/request';
export function useQuestionEdit(projectId, onSuccess) {
const { t } = useTranslation();
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editMode, setEditMode] = useState('create');
const [editingQuestion, setEditingQuestion] = useState(null);
const handleOpenCreateDialog = () => {
setEditMode('create');
setEditingQuestion(null);
setEditDialogOpen(true);
};
const handleOpenEditDialog = question => {
setEditMode('edit');
setEditingQuestion(question);
setEditDialogOpen(true);
};
const handleCloseDialog = () => {
setEditDialogOpen(false);
setEditingQuestion(null);
};
const handleSubmitQuestion = async formData => {
try {
const response = await request(`/api/projects/${projectId}/questions`, {
method: editMode === 'create' ? 'POST' : 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(
editMode === 'create'
? {
question: formData.question,
chunkId: formData.chunkId,
label: formData.label,
imageId: formData.imageId,
imageName: formData.imageName
}
: {
id: formData.id,
question: formData.question,
chunkId: formData.chunkId,
label: formData.label,
imageId: formData.imageId,
imageName: formData.imageName
}
)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || t('questions.operationFailed'));
}
// 获取更新后的问题数据
const updatedQuestion = await response.json();
// 直接更新问题列表中的数据,而不是重新获取整个列表
if (onSuccess) {
onSuccess(updatedQuestion);
}
handleCloseDialog();
} catch (error) {
console.error('操作失败:', error);
}
};
return {
editDialogOpen,
editMode,
editingQuestion,
handleOpenCreateDialog,
handleOpenEditDialog,
handleCloseDialog,
handleSubmitQuestion
};
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import axios from 'axios';
const useQuestionExport = projectId => {
const { t } = useTranslation();
// 导出问题集
const exportQuestions = async exportOptions => {
try {
const apiUrl = `/api/projects/${projectId}/questions/export`;
const requestBody = {
format: exportOptions.format || 'json'
};
// 如果有选中的问题 ID传递 ID 列表
if (exportOptions.selectedIds && exportOptions.selectedIds.length > 0) {
requestBody.selectedIds = exportOptions.selectedIds;
}
// 如果有筛选条件,传递筛选参数
if (exportOptions.filters) {
requestBody.filters = exportOptions.filters;
}
const response = await axios.post(apiUrl, requestBody);
const questions = response.data;
// 处理和下载数据
await processAndDownloadData(questions, exportOptions);
toast.success(t('questions.exportSuccess'));
return true;
} catch (error) {
console.error('Export failed:', error);
toast.error(error.message || t('questions.exportFailed'));
return false;
}
};
// 处理和下载数据的通用函数
const processAndDownloadData = async (data, exportOptions) => {
const format = exportOptions.format || 'json';
let content;
let filename;
let mimeType;
const timestamp = new Date().toISOString().split('T')[0];
switch (format) {
case 'json':
content = JSON.stringify(data, null, 2);
filename = `questions-${projectId}-${timestamp}.json`;
mimeType = 'application/json';
break;
case 'jsonl':
content = data.map(item => JSON.stringify(item)).join('\n');
filename = `questions-${projectId}-${timestamp}.jsonl`;
mimeType = 'application/jsonl';
break;
case 'txt':
content = data.map(item => item.question).join('\n\n');
filename = `questions-${projectId}-${timestamp}.txt`;
mimeType = 'text/plain';
break;
case 'csv':
// CSV 格式
const headers = Object.keys(data[0] || {});
const csvRows = [headers.join(',')];
data.forEach(item => {
const values = headers.map(header => {
const value = item[header] || '';
// 处理包含逗号或引号的值
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
csvRows.push(values.join(','));
});
content = csvRows.join('\n');
filename = `questions-${projectId}-${timestamp}.csv`;
mimeType = 'text/csv';
break;
default:
content = JSON.stringify(data, null, 2);
filename = `questions-${projectId}-${timestamp}.json`;
mimeType = 'application/json';
}
// 创建下载链接
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return {
exportQuestions
};
};
export default useQuestionExport;

View File

@@ -0,0 +1,291 @@
'use client';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import axios from 'axios';
import i18n from '@/lib/i18n';
import request from '@/lib/util/request';
import { processInParallel } from '@/lib/util/processInParallel';
export function useQuestionGeneration(projectId, model, taskSettings, getQuestionList) {
const { t } = useTranslation();
// 处理状态
const [processing, setProcessing] = useState(false);
// 进度状态
const [progress, setProgress] = useState({
total: 0, // 总共选择的问题数量
completed: 0, // 已处理完成的数量
percentage: 0, // 进度百分比
datasetCount: 0 // 已生成的数据集数量
});
// 批量生成答案
const handleBatchGenerateAnswers = async selectedQuestions => {
if (selectedQuestions.length === 0) {
toast.warning(t('questions.noQuestionsSelected'));
return;
}
if (!model) {
toast.warning(t('models.configNotFound'));
return;
}
try {
setProgress({
total: selectedQuestions.length,
completed: 0,
percentage: 0,
datasetCount: 0
});
// 然后设置处理状态为真,确保进度条显示
setProcessing(true);
toast.info(t('questions.batchGenerateStart', { count: selectedQuestions.length }));
// 单个问题处理函数
const processQuestion = async questionId => {
try {
console.log('开始生成数据集:', { questionId });
const language = i18n.language === 'zh-CN' ? '中文' : 'en';
// 调用API生成数据集
const response = await request(`/api/projects/${projectId}/datasets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
questionId,
model,
language
})
});
if (!response.ok) {
const errorData = await response.json();
console.error(t('datasets.generateError'), errorData.error || t('datasets.generateFailed'));
// 更新进度状态(即使失败也计入已处理)
setProgress(prev => {
const completed = prev.completed + 1;
const percentage = Math.round((completed / prev.total) * 100);
return {
...prev,
completed,
percentage
};
});
return { success: false, questionId, error: errorData.error || t('datasets.generateFailed') };
}
const data = await response.json();
// 更新进度状态
setProgress(prev => {
const completed = prev.completed + 1;
const percentage = Math.round((completed / prev.total) * 100);
const datasetCount = prev.datasetCount + 1;
return {
...prev,
completed,
percentage,
datasetCount
};
});
console.log(`数据集生成成功: ${questionId}`);
return { success: true, questionId, data: data.dataset };
} catch (error) {
console.error('生成数据集失败:', error);
// 更新进度状态(即使失败也计入已处理)
setProgress(prev => {
const completed = prev.completed + 1;
const percentage = Math.round((completed / prev.total) * 100);
return {
...prev,
completed,
percentage
};
});
return { success: false, questionId, error: error.message };
}
};
// 并行处理所有问题最多同时处理2个
const results = await processInParallel(selectedQuestions, processQuestion, taskSettings.concurrencyLimit);
// 刷新数据
getQuestionList();
// 处理完成后设置结果消息
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
if (failCount > 0) {
toast.warning(
t('datasets.partialSuccess', {
successCount,
total: selectedQuestions.length,
failCount
})
);
} else {
toast.success(t('common.success', { successCount }));
}
} catch (error) {
console.error('生成数据集出错:', error);
toast.error(error.message || '生成数据集失败');
} finally {
// 延迟关闭处理状态,确保用户可以看到完成的进度
setTimeout(() => {
setProcessing(false);
// 再次延迟重置进度状态
setTimeout(() => {
setProgress({
total: 0,
completed: 0,
percentage: 0,
datasetCount: 0
});
}, 500);
}, 2000); // 延迟关闭处理状态,让用户看到完成的进度
}
};
// 自动生成数据集
const handleAutoGenerateDatasets = async () => {
try {
if (!model) {
toast.error(t('questions.selectModelFirst', { defaultValue: '请先选择模型' }));
return;
}
// 调用创建任务接口
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
taskType: 'answer-generation',
modelInfo: model,
language: i18n.language
});
if (response.data?.code === 0) {
toast.success(t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理未生成答案的问题' }));
} else {
toast.error(t('tasks.createFailed', { defaultValue: '创建后台任务失败' }));
}
} catch (error) {
console.error('创建任务失败:', error);
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
}
};
// 自动生成图像问答数据集
const handleAutoGenerateImageDatasets = async () => {
try {
if (!model) {
toast.error(t('questions.selectModelFirst', { defaultValue: '请先选择模型' }));
return;
}
if (model.type !== 'vision') {
toast.error(t('images.visionModelRequired', { defaultValue: '请选择支持视觉的模型' }));
return;
}
// 调用创建任务接口
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
taskType: 'image-dataset-generation',
modelInfo: model,
language: i18n.language
});
if (response.data?.code === 0) {
toast.success(t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理未生成答案的图片问题' }));
} else {
toast.error(t('tasks.createFailed', { defaultValue: '创建后台任务失败' }));
}
} catch (error) {
console.error('创建图片数据集任务失败:', error);
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
}
};
// 自动生成多轮对话数据集
const handleAutoGenerateMultiTurnDatasets = async () => {
try {
if (!model) {
toast.error(t('questions.selectModelFirst', { defaultValue: '请先选择模型' }));
return;
}
// 首先检查项目是否配置了多轮对话设置
const configResponse = await axios.get(`/api/projects/${projectId}/tasks`);
if (configResponse.status !== 200) {
throw new Error('获取项目配置失败');
}
const config = configResponse.data;
const multiTurnConfig = {
systemPrompt: config.multiTurnSystemPrompt,
scenario: config.multiTurnScenario,
rounds: config.multiTurnRounds,
roleA: config.multiTurnRoleA,
roleB: config.multiTurnRoleB
};
// 检查是否已配置必要的多轮对话设置
if (
!multiTurnConfig.scenario ||
!multiTurnConfig.roleA ||
!multiTurnConfig.roleB ||
!multiTurnConfig.rounds ||
multiTurnConfig.rounds < 1
) {
toast.error(t('questions.multiTurnNotConfigured', '请先在项目设置中配置多轮对话相关参数'));
return;
}
// 调用创建任务接口
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
taskType: 'multi-turn-generation',
modelInfo: model,
language: i18n.language,
config: JSON.stringify(multiTurnConfig)
});
if (response.data?.code === 0) {
toast.success(
t('tasks.multiTurnCreateSuccess', {
defaultValue: '多轮对话生成任务已创建,系统将自动处理未生成多轮对话的问题'
})
);
} else {
toast.error(t('tasks.createFailed', { defaultValue: '创建后台任务失败' }));
}
} catch (error) {
console.error('创建多轮对话任务失败:', error);
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
}
};
return {
// 状态
processing,
progress,
// 方法
handleBatchGenerateAnswers,
handleAutoGenerateDatasets,
handleAutoGenerateMultiTurnDatasets,
handleAutoGenerateImageDatasets
};
}

View File

@@ -0,0 +1,122 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
/**
* 问题模板管理 Hook
* @param {string} projectId - 项目ID
* @param {string} sourceType - 数据源类型: 'image' | 'text' | null (null表示获取所有)
*/
export function useQuestionTemplates(projectId, sourceType = null) {
const { t } = useTranslation();
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(false);
// 获取模板列表
const fetchTemplates = async () => {
try {
setLoading(true);
const params = sourceType ? `?sourceType=${sourceType}` : '';
const response = await axios.get(`/api/projects/${projectId}/questions/templates${params}`);
if (response.data.success) {
setTemplates(response.data.templates);
}
} catch (error) {
console.error('Failed to fetch templates:', error);
toast.error(t('questions.fetchTemplatesFailed'));
} finally {
setLoading(false);
}
};
// 创建模板
const createTemplate = async data => {
try {
const response = await axios.post(`/api/projects/${projectId}/questions/templates`, data);
if (response.data.success) {
const { template, generation } = response.data;
// 显示模板创建成功消息
toast.success(t('questions.createTemplateSuccess'));
// 如果有自动生成结果,显示相应消息
if (generation) {
if (generation.success) {
if (generation.successCount > 0) {
toast.success(
t('questions.template.autoGenerateSuccess', {
count: generation.successCount
})
);
}
if (generation.failCount > 0) {
toast.warning(
t('questions.template.autoGeneratePartialFail', {
success: generation.successCount,
fail: generation.failCount
})
);
}
} else {
toast.error(generation.message || t('questions.template.autoGenerateFailed'));
}
}
fetchTemplates();
return template;
}
} catch (error) {
console.error('Failed to create template:', error);
toast.error(t('questions.createTemplateFailed'));
throw error;
}
};
// 更新模板
const updateTemplate = async (templateId, data) => {
try {
const response = await axios.put(`/api/projects/${projectId}/questions/templates/${templateId}`, data);
if (response.data.success) {
toast.success(t('questions.updateTemplateSuccess'));
fetchTemplates();
return response.data.template;
}
} catch (error) {
console.error('Failed to update template:', error);
toast.error(t('questions.updateTemplateFailed'));
throw error;
}
};
// 删除模板
const deleteTemplate = async templateId => {
try {
const response = await axios.delete(`/api/projects/${projectId}/questions/templates/${templateId}`);
if (response.data.success) {
toast.success(t('questions.deleteTemplateSuccess'));
fetchTemplates();
}
} catch (error) {
console.error('Failed to delete template:', error);
toast.error(t('questions.deleteTemplateFailed'));
throw error;
}
};
// 初始加载
useEffect(() => {
if (projectId) {
fetchTemplates();
}
}, [projectId, sourceType]);
return {
templates,
loading,
createTemplate,
updateTemplate,
deleteTemplate,
refetch: fetchTemplates
};
}

View File

@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import axios from 'axios';
export function useQuestionsFilter(projectId) {
// 过滤和搜索状态
const [answerFilter, setAnswerFilter] = useState('all'); // 'all', 'answered', 'unanswered'
const [searchTerm, setSearchTerm] = useState('');
const [searchMatchMode, setSearchMatchMode] = useState('match'); // 'match', 'notMatch'
const [chunkNameFilter, setChunkNameFilter] = useState('');
const [sourceTypeFilter, setSourceTypeFilter] = useState('all'); // 'all', 'text', 'image'
const debouncedSearchTerm = useDebounce(searchTerm);
const debouncedChunkNameFilter = useDebounce(chunkNameFilter);
// 选择状态
const [selectedQuestions, setSelectedQuestions] = useState([]);
// 处理问题选择
const handleSelectQuestion = (questionKey, newSelected) => {
if (newSelected) {
// 处理批量选择的情况
setSelectedQuestions(newSelected);
} else {
// 处理单个问题选择的情况
setSelectedQuestions(prev => {
if (prev.includes(questionKey)) {
return prev.filter(id => id !== questionKey);
} else {
return [...prev, questionKey];
}
});
}
};
// 全选/取消全选
const handleSelectAll = async () => {
if (selectedQuestions.length > 0) {
setSelectedQuestions([]);
} else {
const response = await axios.get(
`/api/projects/${projectId}/questions?status=${answerFilter}&input=${searchTerm}&searchMatchMode=${searchMatchMode}&chunkName=${encodeURIComponent(chunkNameFilter)}&sourceType=${sourceTypeFilter}&selectedAll=1`
);
setSelectedQuestions(response.data.map(dataset => dataset.id));
}
};
// 处理搜索输入变化
const handleSearchChange = event => {
setSearchTerm(event.target.value);
};
// 处理过滤器变化
const handleFilterChange = event => {
setAnswerFilter(event.target.value);
};
// 处理文本块名称筛选变化
const handleChunkNameFilterChange = event => {
setChunkNameFilter(event.target.value);
};
// 处理数据源类型筛选变化
const handleSourceTypeFilterChange = event => {
setSourceTypeFilter(event.target.value);
};
// 处理搜索匹配模式变化
const handleSearchMatchModeChange = event => {
setSearchMatchMode(event.target.value);
};
// 清空选择
const clearSelection = () => {
setSelectedQuestions([]);
};
// 重置所有过滤条件
const resetFilters = () => {
setSearchTerm('');
setSearchMatchMode('match');
setAnswerFilter('all');
setChunkNameFilter('');
setSourceTypeFilter('all');
setSelectedQuestions([]);
};
return {
// 状态
answerFilter,
searchTerm,
debouncedSearchTerm,
searchMatchMode,
chunkNameFilter,
debouncedChunkNameFilter,
sourceTypeFilter,
selectedQuestions,
// 方法
setAnswerFilter,
setSearchTerm,
setSearchMatchMode,
setChunkNameFilter,
setSourceTypeFilter,
setSelectedQuestions,
handleSelectQuestion,
handleSelectAll,
handleSearchChange,
handleFilterChange,
handleChunkNameFilterChange,
handleSourceTypeFilterChange,
handleSearchMatchModeChange,
clearSelection,
resetFilters
};
}

View File

@@ -0,0 +1,416 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Container, Typography, Box, Paper, Tabs, Tab, CircularProgress, Divider, LinearProgress } from '@mui/material';
import QuestionListView from '@/components/questions/QuestionListView';
import QuestionTreeView from '@/components/questions/QuestionTreeView';
import TabPanel from '@/components/text-split/components/TabPanel';
import useTaskSettings from '@/hooks/useTaskSettings';
import QuestionEditDialog from './components/QuestionEditDialog';
import QuestionsPageHeader from './components/QuestionsPageHeader';
import ConfirmDialog from './components/ConfirmDialog';
import TemplateListView from './components/TemplateListView';
import TemplateFormDialog from './components/template/TemplateFormDialog';
import ExportQuestionsDialog from './components/ExportQuestionsDialog';
import { useQuestionTemplates } from './hooks/useQuestionTemplates';
import { useQuestionEdit } from './hooks/useQuestionEdit';
import { useQuestionDelete } from './hooks/useQuestionDelete';
import { useQuestionsFilter } from './hooks/useQuestionsFilter';
import QuestionsFilter from './components/QuestionsFilter';
import { useQuestionGeneration } from './hooks/useQuestionGeneration';
import useQuestionExport from './hooks/useQuestionExport';
import axios from 'axios';
import { toast } from 'sonner';
import { useAtomValue } from 'jotai/index';
import { selectedModelInfoAtom } from '@/lib/store';
export default function QuestionsPage({ params }) {
const { t } = useTranslation();
const { projectId } = params;
const [loading, setLoading] = useState(true);
const [questions, setQuestions] = useState({});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [tags, setTags] = useState([]);
const model = useAtomValue(selectedModelInfoAtom);
const [activeTab, setActiveTab] = useState(0);
// 模板管理
const {
templates,
loading: templatesLoading,
createTemplate,
updateTemplate,
deleteTemplate
} = useQuestionTemplates(projectId, null); // null 表示获取所有类型的模板
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState(null);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
// 使用新的过滤和搜索 Hook
const {
answerFilter,
searchTerm,
debouncedSearchTerm,
searchMatchMode,
chunkNameFilter,
debouncedChunkNameFilter,
sourceTypeFilter,
selectedQuestions,
setSelectedQuestions,
handleSelectQuestion,
handleSelectAll,
handleSearchChange,
handleFilterChange,
handleChunkNameFilterChange,
handleSourceTypeFilterChange,
handleSearchMatchModeChange
} = useQuestionsFilter(projectId);
const getQuestionList = async () => {
try {
// 获取问题列表
const questionsResponse = await axios.get(
`/api/projects/${projectId}/questions?page=${currentPage}&size=10&status=${answerFilter}&input=${searchTerm}&searchMatchMode=${searchMatchMode}&chunkName=${encodeURIComponent(debouncedChunkNameFilter)}&sourceType=${sourceTypeFilter}`
);
if (questionsResponse.status !== 200) {
throw new Error(t('common.fetchError'));
}
setQuestions(questionsResponse.data || {});
// 获取标签树
const tagsResponse = await axios.get(`/api/projects/${projectId}/tags`);
if (tagsResponse.status !== 200) {
throw new Error(t('common.fetchError'));
}
setTags(tagsResponse.data.tags || []);
setLoading(false);
} catch (error) {
console.error(t('common.fetchError'), error);
toast.error(error.message);
}
};
// 当筛选条件改变时重置页码到第1页
useEffect(() => {
setCurrentPage(1);
}, [answerFilter, debouncedSearchTerm, debouncedChunkNameFilter, sourceTypeFilter, searchMatchMode]);
useEffect(() => {
getQuestionList();
}, [currentPage, answerFilter, debouncedSearchTerm, debouncedChunkNameFilter, sourceTypeFilter, searchMatchMode]);
const { taskSettings } = useTaskSettings(projectId);
// 使用新的问题生成 Hook
const {
processing,
progress,
handleBatchGenerateAnswers,
handleAutoGenerateDatasets,
handleAutoGenerateMultiTurnDatasets,
handleAutoGenerateImageDatasets
} = useQuestionGeneration(projectId, model, taskSettings, getQuestionList);
const {
editDialogOpen,
editMode,
editingQuestion,
handleOpenCreateDialog,
handleOpenEditDialog,
handleCloseDialog,
handleSubmitQuestion
} = useQuestionEdit(projectId, updatedQuestion => {
getQuestionList();
toast.success(t('questions.operationSuccess'));
});
const { confirmDialog, handleDeleteQuestion, handleBatchDeleteQuestions, closeConfirmDialog, handleConfirmAction } =
useQuestionDelete(projectId, () => {
getQuestionList();
});
const { exportQuestions } = useQuestionExport(projectId);
// 获取所有数据
useEffect(() => {
getQuestionList();
}, [projectId]);
// 处理标签页切换
const handleTabChange = (event, newValue) => {
setActiveTab(newValue);
};
// 模板管理函数
const handleOpenCreateTemplateDialog = () => {
setEditingTemplate(null);
setTemplateDialogOpen(true);
};
const handleEditTemplate = template => {
setEditingTemplate(template);
setTemplateDialogOpen(true);
};
const handleCloseTemplateDialog = () => {
setTemplateDialogOpen(false);
setEditingTemplate(null);
};
const handleSubmitTemplate = async data => {
try {
if (editingTemplate) {
await updateTemplate(editingTemplate.id, data);
} else {
await createTemplate(data);
}
getQuestionList();
handleCloseTemplateDialog();
} catch (error) {
console.error('Failed to save template:', error);
}
};
const handleDeleteTemplate = async templateId => {
const confirmed = window.confirm(t('questions.template.deleteConfirm'));
if (confirmed) {
try {
await deleteTemplate(templateId);
} catch (error) {
console.error('Failed to delete template:', error);
}
}
};
const handleOpenExportDialog = () => {
setExportDialogOpen(true);
};
const handleCloseExportDialog = () => {
setExportDialogOpen(false);
};
const handleExportQuestions = async exportOptions => {
const options = {
...exportOptions,
selectedIds: selectedQuestions,
filters: {
searchTerm: debouncedSearchTerm,
chunkName: debouncedChunkNameFilter,
sourceType: sourceTypeFilter
}
};
await exportQuestions(options);
};
if (loading) {
return (
<Container maxWidth="lg" sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '70vh' }}>
<CircularProgress />
</Box>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
{/* 处理中的进度显示 - 全局蒙版样式 */}
{processing && (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Paper
elevation={6}
sx={{
width: '90%',
maxWidth: 500,
p: 3,
borderRadius: 2,
textAlign: 'center'
}}
>
<Typography variant="h6" sx={{ mb: 2, color: 'primary.main', fontWeight: 'bold' }}>
{t('datasets.generatingDataset')}
</Typography>
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="body1" sx={{ mr: 1 }}>
{progress.percentage}%
</Typography>
<Box sx={{ width: '100%' }}>
<LinearProgress
variant="determinate"
value={progress.percentage}
sx={{ height: 8, borderRadius: 4 }}
color="primary"
/>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Typography variant="body2">
{t('questions.generatingProgress', {
completed: progress.completed,
total: progress.total
})}
</Typography>
<Typography variant="body2" color="success.main" sx={{ fontWeight: 'medium' }}>
{t('questions.generatedCount', { count: progress.datasetCount })}
</Typography>
</Box>
</Box>
<CircularProgress size={60} thickness={4} sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary">
{t('questions.pleaseWait')}
</Typography>
</Paper>
</Box>
)}
<QuestionsPageHeader
questionsTotal={questions.total}
selectedQuestionsCount={selectedQuestions.length}
onBatchDeleteQuestions={() => handleBatchDeleteQuestions(selectedQuestions, setSelectedQuestions)}
onOpenCreateDialog={handleOpenCreateDialog}
onOpenCreateTemplateDialog={handleOpenCreateTemplateDialog}
onBatchGenerateAnswers={() => handleBatchGenerateAnswers(selectedQuestions)}
onAutoGenerateDatasets={handleAutoGenerateDatasets}
onAutoGenerateMultiTurnDatasets={handleAutoGenerateMultiTurnDatasets}
onAutoGenerateImageDatasets={handleAutoGenerateImageDatasets}
onExportQuestions={handleOpenExportDialog}
/>
<Paper sx={{ mb: 4 }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
indicatorColor="primary"
sx={{
borderBottom: 1,
borderColor: 'divider'
}}
>
<Tab label={t('questions.listView')} />
<Tab label={t('questions.template.management')} />
<Tab label={t('questions.treeView')} />
</Tabs>
<QuestionsFilter
selectedQuestionsCount={selectedQuestions.length}
totalQuestions={questions?.total || 0}
isAllSelected={selectedQuestions.length > 0 && selectedQuestions.length === questions?.total}
isIndeterminate={selectedQuestions.length > 0 && selectedQuestions.length < questions?.total}
onSelectAll={handleSelectAll}
searchTerm={searchTerm}
onSearchChange={handleSearchChange}
searchMatchMode={searchMatchMode}
onSearchMatchModeChange={handleSearchMatchModeChange}
answerFilter={answerFilter}
onFilterChange={handleFilterChange}
chunkNameFilter={chunkNameFilter}
onChunkNameFilterChange={handleChunkNameFilterChange}
sourceTypeFilter={sourceTypeFilter}
onSourceTypeFilterChange={handleSourceTypeFilterChange}
activeTab={activeTab}
/>
<Divider />
<TabPanel value={activeTab} index={0}>
<QuestionListView
questions={questions.data}
currentPage={currentPage}
totalQuestions={Math.ceil(questions.total / pageSize)}
handlePageChange={(_, newPage) => setCurrentPage(newPage)}
selectedQuestions={selectedQuestions}
onSelectQuestion={handleSelectQuestion}
onDeleteQuestion={questionId => handleDeleteQuestion(questionId, selectedQuestions, setSelectedQuestions)}
onEditQuestion={handleOpenEditDialog}
refreshQuestions={getQuestionList}
projectId={projectId}
/>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<TemplateListView
templates={templates}
onEditTemplate={handleEditTemplate}
onDeleteTemplate={handleDeleteTemplate}
loading={templatesLoading}
/>
</TabPanel>
<TabPanel value={activeTab} index={2}>
<QuestionTreeView
questions={questions.data}
tags={tags}
selectedQuestions={selectedQuestions}
onSelectQuestion={handleSelectQuestion}
onDeleteQuestion={questionId => handleDeleteQuestion(questionId, selectedQuestions, setSelectedQuestions)}
onEditQuestion={handleOpenEditDialog}
projectId={projectId}
searchTerm={searchTerm}
/>
</TabPanel>
</Paper>
{/* 确认对话框 */}
<ConfirmDialog
open={confirmDialog.open}
onClose={closeConfirmDialog}
onConfirm={handleConfirmAction}
title={confirmDialog.title}
content={confirmDialog.content}
/>
<QuestionEditDialog
open={editDialogOpen}
onClose={handleCloseDialog}
onSubmit={handleSubmitQuestion}
initialData={editingQuestion}
tags={tags}
mode={editMode}
projectId={projectId}
/>
<TemplateFormDialog
open={templateDialogOpen}
onClose={handleCloseTemplateDialog}
onSubmit={handleSubmitTemplate}
template={editingTemplate}
/>
<ExportQuestionsDialog
open={exportDialogOpen}
onClose={handleCloseExportDialog}
onExport={handleExportQuestions}
selectedCount={selectedQuestions.length}
totalCount={questions.total || 0}
/>
</Container>
);
}