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,135 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
CircularProgress,
Alert,
Box,
Typography
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { selectedModelInfoAtom } from '@/lib/store';
import { toast } from 'sonner';
import axios from 'axios';
export default function DatasetDialog({ open, projectId, image, onClose, onSuccess }) {
const { t, i18n } = useTranslation();
const selectedModel = useAtomValue(selectedModelInfoAtom);
const [question, setQuestion] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setQuestion('');
setError('');
}
}, [open]);
const handleGenerate = async () => {
if (!selectedModel) {
setError(t('images.selectModelFirst'));
return;
}
if (selectedModel.type !== 'vision') {
setError(t('images.visionModelRequired'));
return;
}
if (!question.trim()) {
setError(t('images.questionRequired'));
return;
}
try {
setLoading(true);
setError('');
await axios.post(`/api/projects/${projectId}/images/datasets`, {
imageName: image.imageName,
question: { question: question.trim() },
model: selectedModel,
language: i18n.language
});
toast.success(t('images.datasetGenerated'));
onSuccess?.();
onClose();
} catch (err) {
console.error('Failed to generate dataset:', err);
setError(err.response?.data?.error || t('images.generateFailed'));
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
onClose();
}
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('images.generateDataset')}</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{image && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{t('images.imageName')}
</Typography>
<Typography variant="body2" color="text.secondary">
{image.imageName}
</Typography>
</Box>
)}
<TextField
fullWidth
multiline
rows={4}
label={t('images.question')}
value={question}
onChange={e => setQuestion(e.target.value)}
placeholder={t('images.questionPlaceholder')}
disabled={loading}
/>
{selectedModel && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
{t('images.currentModel')}: {selectedModel.modelName}
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
{t('common.cancel')}
</Button>
<Button
onClick={handleGenerate}
variant="contained"
disabled={loading || !selectedModel || !question.trim()}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('datasets.generateDataset')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import {
Box,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
InputAdornment,
Card,
CardContent,
ToggleButtonGroup,
ToggleButton
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import GridViewIcon from '@mui/icons-material/GridView';
import ViewListIcon from '@mui/icons-material/ViewList';
import { useTranslation } from 'react-i18next';
import { useDebounce } from '@/hooks/useDebounce';
import { useEffect, useState } from 'react';
import { imageStyles } from '../styles/imageStyles';
export default function ImageFilters({
imageName,
onImageNameChange,
hasQuestions,
onHasQuestionsChange,
hasDatasets,
onHasDatasetsChange,
viewMode = 'grid',
onViewModeChange
}) {
const { t } = useTranslation();
const [localImageName, setLocalImageName] = useState(imageName);
const debouncedImageName = useDebounce(localImageName, 500);
useEffect(() => {
onImageNameChange(debouncedImageName);
}, [debouncedImageName]);
return (
<Card sx={imageStyles.filterCard}>
<CardContent>
<Box sx={imageStyles.filterContent}>
{/* 搜索框 */}
<TextField
placeholder={t('images.searchPlaceholder', { defaultValue: '搜索图片名称...' })}
value={localImageName}
onChange={e => setLocalImageName(e.target.value)}
size="small"
sx={imageStyles.searchField}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
)
}}
/>
{/* 问题状态筛选 */}
<FormControl size="small" sx={imageStyles.filterSelect}>
<InputLabel>{t('images.hasQuestions', { defaultValue: '问题状态' })}</InputLabel>
<Select
value={hasQuestions}
onChange={e => onHasQuestionsChange(e.target.value)}
label={t('images.hasQuestions', { defaultValue: '问题状态' })}
>
<MenuItem value="all">{t('common.all', { defaultValue: '全部' })}</MenuItem>
<MenuItem value="true">{t('images.withQuestions', { defaultValue: '有问题' })}</MenuItem>
<MenuItem value="false">{t('images.withoutQuestions', { defaultValue: '无问题' })}</MenuItem>
</Select>
</FormControl>
{/* 数据集状态筛选 */}
<FormControl size="small" sx={imageStyles.filterSelect}>
<InputLabel>{t('images.hasDatasets', { defaultValue: '数据集状态' })}</InputLabel>
<Select
value={hasDatasets}
onChange={e => onHasDatasetsChange(e.target.value)}
label={t('images.hasDatasets', { defaultValue: '数据集状态' })}
>
<MenuItem value="all">{t('common.all', { defaultValue: '全部' })}</MenuItem>
<MenuItem value="true">{t('images.withDatasets', { defaultValue: '已生成' })}</MenuItem>
<MenuItem value="false">{t('images.withoutDatasets', { defaultValue: '未生成' })}</MenuItem>
</Select>
</FormControl>
{/* 视图切换 */}
{onViewModeChange && (
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(e, newMode) => newMode && onViewModeChange(newMode)}
size="small"
sx={imageStyles.viewToggle}
>
<ToggleButton value="grid" aria-label="grid view">
<GridViewIcon fontSize="small" />
</ToggleButton>
<ToggleButton value="list" aria-label="list view">
<ViewListIcon fontSize="small" />
</ToggleButton>
</ToggleButtonGroup>
)}
</Box>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useState } from 'react';
import {
Grid,
Card,
CardMedia,
CardContent,
CardActions,
Typography,
Chip,
Box,
Pagination,
Tooltip,
Dialog,
DialogContent,
IconButton,
Button
} from '@mui/material';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import DatasetIcon from '@mui/icons-material/Dataset';
import DeleteIcon from '@mui/icons-material/Delete';
import EditNoteIcon from '@mui/icons-material/EditNote';
import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary';
import { useTranslation } from 'react-i18next';
import { imageStyles } from '../styles/imageStyles';
export default function ImageGrid({
images,
total,
page,
pageSize,
onPageChange,
onGenerateQuestions,
onGenerateDataset,
onDelete,
onAnnotate
}) {
const { t } = useTranslation();
const [previewImage, setPreviewImage] = useState(null);
if (!images || images.length === 0) {
return (
<Box sx={imageStyles.emptyState}>
<Box sx={imageStyles.emptyIcon}>
<PhotoLibraryIcon sx={{ fontSize: 60, color: 'primary.main' }} />
</Box>
<Typography variant="h5" sx={imageStyles.emptyTitle}>
{t('images.noImages', { defaultValue: '还没有图片' })}
</Typography>
<Typography variant="body2" sx={imageStyles.emptyDescription}>
{t('images.noImagesDescription', { defaultValue: '开始导入图片,创建您的第一个图片数据集' })}
</Typography>
</Box>
);
}
return (
<>
<Grid container spacing={3}>
{images.map(image => (
<Grid item xs={12} sm={6} md={4} lg={3} key={image.id}>
<Card sx={imageStyles.imageCard}>
{/* 图片区域 */}
<Box sx={imageStyles.imageWrapper}>
<CardMedia
component="img"
image={image.base64 || image.path}
alt={image.imageName}
sx={imageStyles.imageMedia}
onClick={() => setPreviewImage(image)}
/>
{/* 悬停遮罩 */}
<Box sx={imageStyles.imageOverlay} />
{/* 状态标签 - 悬浮在图片右上角 */}
<Box sx={imageStyles.statusChipsContainer}>
<Chip
label={`${image.questionCount || 0} ${t('images.questions', { defaultValue: '问题' })}`}
size="small"
color={image.questionCount > 0 ? 'primary' : 'default'}
sx={imageStyles.statusChip}
/>
<Chip
label={`${image.datasetCount || 0} ${t('images.datasets', { defaultValue: '数据集' })}`}
size="small"
color={image.datasetCount > 0 ? 'success' : 'default'}
sx={imageStyles.statusChip}
/>
</Box>
{/* 文件名标签 - 悬浮在图片底部 */}
<Box sx={imageStyles.imageNameContainer}>
<Tooltip title={image.imageName}>
<Chip label={image.imageName} size="small" sx={imageStyles.imageNameChip} />
</Tooltip>
</Box>
</Box>
{/* 操作按钮区域 */}
<CardActions sx={imageStyles.cardActions}>
<Button
size="small"
startIcon={<EditNoteIcon />}
onClick={() => onAnnotate(image)}
variant="contained"
color="primary"
sx={imageStyles.primaryActionButton}
>
{t('images.annotate', { defaultValue: '标注' })}
</Button>
<Tooltip title={t('images.generateQuestions', { defaultValue: '生成问题' })}>
<IconButton size="small" onClick={() => onGenerateQuestions(image)} sx={imageStyles.actionIconButton}>
<QuestionMarkIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('images.generateDataset', { defaultValue: '生成数据集' })}>
<IconButton size="small" onClick={() => onGenerateDataset(image)} sx={imageStyles.actionIconButton}>
<DatasetIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete', { defaultValue: '删除' })}>
<IconButton
size="small"
color="error"
onClick={() => onDelete(image.id)}
sx={imageStyles.actionIconButton}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</CardActions>
</Card>
</Grid>
))}
</Grid>
{total > pageSize && (
<Box sx={imageStyles.pagination}>
<Pagination
count={Math.ceil(total / pageSize)}
page={page}
onChange={(_, newPage) => onPageChange(newPage)}
color="primary"
size="large"
showFirstButton
showLastButton
/>
</Box>
)}
{/* 图片预览对话框 */}
<Dialog
open={!!previewImage}
onClose={() => setPreviewImage(null)}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
bgcolor: 'transparent',
boxShadow: 'none',
overflow: 'hidden'
}
}}
>
<DialogContent
sx={{ p: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{previewImage && (
<Box sx={{ width: '100%', textAlign: 'center' }}>
<img
src={previewImage.base64 || previewImage.path}
alt={previewImage.imageName}
style={{
maxWidth: '100%',
maxHeight: '90vh',
objectFit: 'contain'
}}
/>
<Typography
variant="caption"
sx={{ display: 'block', mt: 2, color: 'white', textShadow: '0 0 4px rgba(0,0,0,0.8)' }}
>
{previewImage.imageName}
</Typography>
</Box>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,315 @@
'use client';
import { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Box,
Pagination,
Tooltip,
IconButton,
Avatar,
Dialog,
DialogContent,
Typography,
Button,
Checkbox
} from '@mui/material';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import DatasetIcon from '@mui/icons-material/Dataset';
import DeleteIcon from '@mui/icons-material/Delete';
import EditNoteIcon from '@mui/icons-material/EditNote';
import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { useTranslation } from 'react-i18next';
import { imageStyles } from '../styles/imageStyles';
export default function ImageList({
images,
total,
page,
pageSize,
onPageChange,
onGenerateQuestions,
onGenerateDataset,
onDelete,
onAnnotate,
selectedIds = [],
onSelectionChange
}) {
const { t } = useTranslation();
const [previewImage, setPreviewImage] = useState(null);
// 处理全选/取消全选
const handleSelectAll = event => {
if (event.target.checked) {
const allIds = images.map(img => img.id);
onSelectionChange?.(allIds);
} else {
onSelectionChange?.([]);
}
};
// 处理单个选择
const handleSelectOne = (imageId, checked) => {
if (checked) {
onSelectionChange?.([...selectedIds, imageId]);
} else {
onSelectionChange?.(selectedIds.filter(id => id !== imageId));
}
};
// 判断是否全选
const isAllSelected = images.length > 0 && selectedIds.length === images.length;
const isSomeSelected = selectedIds.length > 0 && selectedIds.length < images.length;
// 格式化日期
const formatDate = dateString => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// 格式化文件大小
const formatSize = bytes => {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
if (!images || images.length === 0) {
return (
<Box sx={imageStyles.emptyState}>
<Box sx={imageStyles.emptyIcon}>
<PhotoLibraryIcon sx={{ fontSize: 60, color: 'primary.main' }} />
</Box>
<Typography variant="h5" sx={imageStyles.emptyTitle}>
{t('images.noImages', { defaultValue: '还没有图片' })}
</Typography>
<Typography variant="body2" sx={imageStyles.emptyDescription}>
{t('images.noImagesDescription', { defaultValue: '开始导入图片,创建您的第一个图片数据集' })}
</Typography>
</Box>
);
}
return (
<>
<TableContainer component={Paper} sx={{ borderRadius: 2, boxShadow: 1 }}>
<Table>
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell padding="checkbox">
<Checkbox indeterminate={isSomeSelected} checked={isAllSelected} onChange={handleSelectAll} />
</TableCell>
<TableCell width="60">{t('images.preview', { defaultValue: '预览' })}</TableCell>
<TableCell>{t('images.fileName', { defaultValue: '文件名' })}</TableCell>
<TableCell width="120">{t('images.size', { defaultValue: '大小' })}</TableCell>
<TableCell width="120">{t('images.dimensions', { defaultValue: '尺寸' })}</TableCell>
<TableCell width="100">{t('images.questionCount', { defaultValue: '问题数' })}</TableCell>
<TableCell width="100">{t('images.datasetCount', { defaultValue: '数据集数' })}</TableCell>
<TableCell width="180">{t('images.uploadTime', { defaultValue: '上传时间' })}</TableCell>
<TableCell width="200" align="center">
{t('common.actions', { defaultValue: '操作' })}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{images.map(image => (
<TableRow
key={image.id}
hover
selected={selectedIds.includes(image.id)}
sx={{
'&:hover': {
bgcolor: 'action.hover'
}
}}
>
{/* 复选框 */}
<TableCell padding="checkbox">
<Checkbox
checked={selectedIds.includes(image.id)}
onChange={e => handleSelectOne(image.id, e.target.checked)}
/>
</TableCell>
{/* 预览缩略图 */}
<TableCell>
<Avatar
src={image.base64 || image.path}
alt={image.imageName}
variant="rounded"
sx={{
width: 48,
height: 48,
cursor: 'pointer',
'&:hover': {
opacity: 0.8
}
}}
onClick={() => setPreviewImage(image)}
/>
</TableCell>
{/* 文件名 */}
<TableCell>
<Tooltip title={image.imageName}>
<Typography variant="body2" noWrap sx={{ maxWidth: 300 }}>
{image.imageName}
</Typography>
</Tooltip>
</TableCell>
{/* 文件大小 */}
<TableCell>
<Typography variant="body2" color="text.secondary">
{formatSize(image.size)}
</Typography>
</TableCell>
{/* 尺寸 */}
<TableCell>
{image.width && image.height ? (
<Typography variant="body2" color="text.secondary">
{image.width} × {image.height}
</Typography>
) : (
<Typography variant="body2" color="text.disabled">
-
</Typography>
)}
</TableCell>
{/* 问题数 */}
<TableCell>
<Chip
label={image.questionCount || 0}
size="small"
color={image.questionCount > 0 ? 'primary' : 'default'}
variant="outlined"
/>
</TableCell>
{/* 数据集数 */}
<TableCell>
<Chip
label={image.datasetCount || 0}
size="small"
color={image.datasetCount > 0 ? 'success' : 'default'}
variant="outlined"
/>
</TableCell>
{/* 上传时间 */}
<TableCell>
<Typography variant="body2" color="text.secondary">
{formatDate(image.createAt)}
</Typography>
</TableCell>
{/* 操作按钮 */}
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5, justifyContent: 'center' }}>
<Tooltip title={t('images.preview', { defaultValue: '预览' })}>
<IconButton size="small" onClick={() => setPreviewImage(image)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('images.annotate', { defaultValue: '标注' })}>
<IconButton size="small" color="primary" onClick={() => onAnnotate(image)}>
<EditNoteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('images.generateQuestions', { defaultValue: '生成问题' })}>
<IconButton size="small" onClick={() => onGenerateQuestions(image)}>
<QuestionMarkIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('images.generateDataset', { defaultValue: '生成数据集' })}>
<IconButton size="small" onClick={() => onGenerateDataset(image)}>
<DatasetIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete', { defaultValue: '删除' })}>
<IconButton size="small" color="error" onClick={() => onDelete(image.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* 分页 */}
{total > pageSize && (
<Box sx={imageStyles.pagination}>
<Pagination
count={Math.ceil(total / pageSize)}
page={page}
onChange={(_, newPage) => onPageChange(newPage)}
color="primary"
size="large"
showFirstButton
showLastButton
/>
</Box>
)}
{/* 图片预览对话框 */}
<Dialog
open={!!previewImage}
onClose={() => setPreviewImage(null)}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
bgcolor: 'transparent',
boxShadow: 'none',
overflow: 'hidden'
}
}}
>
<DialogContent
sx={{ p: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{previewImage && (
<Box sx={{ width: '100%', textAlign: 'center' }}>
<img
src={previewImage.base64 || previewImage.path}
alt={previewImage.imageName}
style={{
maxWidth: '100%',
maxHeight: '90vh',
objectFit: 'contain'
}}
/>
<Typography
variant="caption"
sx={{ display: 'block', mt: 2, color: 'white', textShadow: '0 0 4px rgba(0,0,0,0.8)' }}
>
{previewImage.imageName}
</Typography>
</Box>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,416 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
List,
ListItem,
ListItemText,
IconButton,
CircularProgress,
Alert,
TextField,
Tabs,
Tab,
Paper,
Chip,
Card
} from '@mui/material';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import FolderZipIcon from '@mui/icons-material/FolderZip';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import axios from 'axios';
export default function ImportDialog({ open, projectId, onClose, onSuccess }) {
const { t } = useTranslation();
const [mode, setMode] = useState(0); // 0: 目录导入, 1: PDF 导入, 2: 压缩包导入
const [directories, setDirectories] = useState([]);
const [loading, setLoading] = useState(false);
const [inputPath, setInputPath] = useState('');
const [selectedPdf, setSelectedPdf] = useState(null);
const [selectedZip, setSelectedZip] = useState(null);
const handleAddDirectory = () => {
if (inputPath.trim() && !directories.includes(inputPath.trim())) {
setDirectories([...directories, inputPath.trim()]);
setInputPath('');
}
};
const handleRemoveDirectory = index => {
setDirectories(directories.filter((_, i) => i !== index));
};
const handleImport = async () => {
if (directories.length === 0) {
toast.error(t('images.selectAtLeastOne'));
return;
}
try {
setLoading(true);
const response = await axios.post(`/api/projects/${projectId}/images`, {
directories
});
toast.success(t('images.importSuccess', { count: response.data.count }));
setDirectories([]);
onSuccess?.();
} catch (error) {
console.error('Failed to import images:', error);
toast.error(error.response?.data?.error || t('images.importFailed'));
} finally {
setLoading(false);
}
};
const handlePdfSelect = event => {
const file = event.target.files?.[0];
if (file && file.type === 'application/pdf') {
setSelectedPdf(file);
} else {
toast.error(t('images.invalidPdfFile', { defaultValue: '请选择有效的 PDF 文件' }));
}
};
const handlePdfImport = async () => {
if (!selectedPdf) {
toast.error(t('images.selectPdfFile', { defaultValue: '请选择 PDF 文件' }));
return;
}
try {
setLoading(true);
const formData = new FormData();
formData.append('file', selectedPdf);
// 调用 PDF 转换 API
const response = await axios.post(`/api/projects/${projectId}/images/pdf-convert`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
toast.success(
t('images.pdfImportSuccess', {
defaultValue: `成功从 PDF "${response.data.pdfName}" 导入 ${response.data.count} 张图片`,
count: response.data.count,
name: response.data.pdfName
})
);
setSelectedPdf(null);
onSuccess?.();
} catch (error) {
console.error('Failed to import PDF:', error);
toast.error(error.response?.data?.error || t('images.pdfImportFailed', { defaultValue: 'PDF 导入失败' }));
} finally {
setLoading(false);
}
};
const handleZipSelect = event => {
const file = event.target.files?.[0];
if (file && file.name.toLowerCase().endsWith('.zip')) {
setSelectedZip(file);
} else {
toast.error(t('images.invalidZipFile', { defaultValue: '请选择有效的 ZIP 文件' }));
}
};
const handleZipImport = async () => {
if (!selectedZip) {
toast.error(t('images.selectZipFile', { defaultValue: '请选择 ZIP 文件' }));
return;
}
try {
setLoading(true);
const formData = new FormData();
formData.append('file', selectedZip);
// 调用压缩包导入 API
const response = await axios.post(`/api/projects/${projectId}/images/zip-import`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
toast.success(
t('images.zipImportSuccess', {
defaultValue: `成功从压缩包 "${response.data.zipName}" 导入 ${response.data.count} 张图片`,
count: response.data.count,
name: response.data.zipName
})
);
setSelectedZip(null);
onSuccess?.();
} catch (error) {
console.error('Failed to import ZIP:', error);
toast.error(error.response?.data?.error || t('images.zipImportFailed', { defaultValue: '压缩包导入失败' }));
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
setDirectories([]);
setSelectedPdf(null);
setSelectedZip(null);
setMode(0);
onClose();
}
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>{t('images.importImages')}</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<Tabs
value={mode}
onChange={(e, newValue) => setMode(newValue)}
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
>
<Tab
label={t('images.importFromDirectory', { defaultValue: '从目录导入' })}
icon={<FolderOpenIcon />}
iconPosition="start"
/>
<Tab
label={t('images.importFromPdf', { defaultValue: '从 PDF 导入' })}
icon={<PictureAsPdfIcon />}
iconPosition="start"
/>
<Tab
label={t('images.importFromZip', { defaultValue: '从压缩包导入' })}
icon={<FolderZipIcon />}
iconPosition="start"
/>
</Tabs>
{mode === 0 ? (
<>
<Alert severity="info" sx={{ mb: 2 }}>
{t('images.importTip')}
</Alert>
<Box sx={{ display: 'flex', gap: 1.5, mb: 3 }}>
<TextField
fullWidth
size="small"
label={t('images.directoryPath')}
placeholder={t('images.enterDirectoryPath')}
value={inputPath}
onChange={e => setInputPath(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
handleAddDirectory();
}
}}
disabled={loading}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2
}
}}
/>
<Button
variant="contained"
startIcon={<FolderOpenIcon />}
onClick={handleAddDirectory}
disabled={loading || !inputPath.trim()}
sx={{
borderRadius: 2,
px: 2.5,
fontWeight: 600,
textTransform: 'none',
whiteSpace: 'nowrap',
boxShadow: 1,
transition: 'all 0.2s',
'&:hover:not(:disabled)': {
boxShadow: 2,
transform: 'translateY(-1px)'
}
}}
>
{t('images.addDirectory', { defaultValue: '添加目录' })}
</Button>
</Box>
{directories.length > 0 && (
<Card
sx={{
p: 2.5,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<FolderOpenIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t('images.selectedDirectories')} ({directories.length})
</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{directories.map((dir, index) => (
<Chip
key={index}
label={dir}
onDelete={() => handleRemoveDirectory(index)}
disabled={loading}
icon={<FolderOpenIcon />}
sx={{
borderRadius: 1.5,
fontWeight: 500,
maxWidth: '100%',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
}}
/>
))}
</Box>
</Card>
)}
</>
) : mode === 1 ? (
<>
<Alert severity="info" sx={{ mb: 2 }}>
{t('images.pdfImportTip', { defaultValue: '选择 PDF 文件,系统会自动将其转换为图片并导入' })}
</Alert>
<Paper
variant="outlined"
sx={{
p: 4,
textAlign: 'center',
cursor: 'pointer',
bgcolor: 'background.default',
border: '2px dashed',
borderColor: selectedPdf ? 'primary.main' : 'divider',
transition: 'all 0.3s',
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main'
}
}}
onClick={() => document.getElementById('pdf-file-input').click()}
>
<input
id="pdf-file-input"
type="file"
accept=".pdf,application/pdf"
style={{ display: 'none' }}
onChange={handlePdfSelect}
disabled={loading}
/>
<UploadFileIcon sx={{ fontSize: 64, color: selectedPdf ? 'primary.main' : 'text.secondary', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{selectedPdf ? selectedPdf.name : t('images.clickToSelectPdf', { defaultValue: '点击选择 PDF 文件' })}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('images.supportedFormat', { defaultValue: '支持格式PDF' })}
</Typography>
{selectedPdf && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{t('images.fileSize', { defaultValue: '文件大小' })}: {(selectedPdf.size / 1024 / 1024).toFixed(2)} MB
</Typography>
)}
</Paper>
</>
) : (
<>
<Alert severity="info" sx={{ mb: 2 }}>
{t('images.zipImportTip', { defaultValue: '选择 ZIP 压缩包文件,系统会自动解压并导入其中的图片' })}
</Alert>
<Paper
variant="outlined"
sx={{
p: 4,
textAlign: 'center',
cursor: 'pointer',
bgcolor: 'background.default',
border: '2px dashed',
borderColor: selectedZip ? 'primary.main' : 'divider',
transition: 'all 0.3s',
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main'
}
}}
onClick={() => document.getElementById('zip-file-input').click()}
>
<input
id="zip-file-input"
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
style={{ display: 'none' }}
onChange={handleZipSelect}
disabled={loading}
/>
<FolderZipIcon sx={{ fontSize: 64, color: selectedZip ? 'primary.main' : 'text.secondary', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{selectedZip ? selectedZip.name : t('images.clickToSelectZip', { defaultValue: '点击选择 ZIP 文件' })}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('images.supportedZipFormat', { defaultValue: '支持格式ZIP' })}
</Typography>
{selectedZip && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{t('images.fileSize', { defaultValue: '文件大小' })}: {(selectedZip.size / 1024 / 1024).toFixed(2)} MB
</Typography>
)}
</Paper>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
{t('common.cancel')}
</Button>
{mode === 0 ? (
<Button
onClick={handleImport}
variant="contained"
disabled={loading || directories.length === 0}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('images.startImport')}
</Button>
) : mode === 1 ? (
<Button
onClick={handlePdfImport}
variant="contained"
disabled={loading || !selectedPdf}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('images.convertAndImport', { defaultValue: '转换并导入' })}
</Button>
) : (
<Button
onClick={handleZipImport}
variant="contained"
disabled={loading || !selectedZip}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('images.extractAndImport', { defaultValue: '解压并导入' })}
</Button>
)}
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
CircularProgress,
Alert,
Box,
Typography
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { selectedModelInfoAtom } from '@/lib/store';
import { toast } from 'sonner';
import axios from 'axios';
export default function QuestionDialog({ open, projectId, image, onClose, onSuccess }) {
const { t, i18n } = useTranslation();
const selectedModel = useAtomValue(selectedModelInfoAtom);
const [count, setCount] = useState(3);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setCount(3);
setError('');
}
}, [open]);
const handleGenerate = async () => {
if (!selectedModel) {
setError(t('images.selectModelFirst'));
return;
}
if (selectedModel.type !== 'vision') {
setError(t('images.visionModelRequired'));
return;
}
if (count < 1 || count > 10) {
setError(t('images.countRange'));
return;
}
try {
setLoading(true);
setError('');
const response = await axios.post(`/api/projects/${projectId}/images/questions`, {
imageName: image.imageName,
count,
model: selectedModel,
language: i18n.language
});
toast.success(t('images.questionsGenerated', { count: response.data.questions.length }));
onSuccess?.();
onClose();
} catch (err) {
console.error('Failed to generate questions:', err);
setError(err.response?.data?.error || t('images.generateFailed'));
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
onClose();
}
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('images.generateQuestions')}</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{image && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{t('images.imageName')}
</Typography>
<Typography variant="body2" color="text.secondary">
{image.imageName}
</Typography>
</Box>
)}
<TextField
fullWidth
type="number"
label={t('images.questionCount')}
value={count}
onChange={e => setCount(parseInt(e.target.value) || 1)}
inputProps={{ min: 1, max: 10 }}
helperText={t('images.questionCountHelp')}
disabled={loading}
/>
{selectedModel && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
{t('images.currentModel')}: {selectedModel.modelName}
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
{t('common.cancel')}
</Button>
<Button
onClick={handleGenerate}
variant="contained"
disabled={loading || !selectedModel}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('images.generateQuestions')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { Button, CircularProgress } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { useAtomValue } from 'jotai/index';
import { selectedModelInfoAtom } from '@/lib/store';
/**
* AI 生成答案按钮组件
* @param {string} projectId - 项目ID
* @param {string} imageName - 图片名称
* @param {string} question - 问题内容
* @param {function} onSuccess - 生成成功的回调,接收生成的答案
* @param {boolean} previewOnly - 是否只预览(不保存数据集),默认 true
* @param {object} sx - 自定义样式
*/
export default function AIGenerateButton({
projectId,
imageName,
question,
onSuccess,
previewOnly = true,
sx = {},
answerType
}) {
const { t, i18n } = useTranslation();
const [loading, setLoading] = useState(false);
const model = useAtomValue(selectedModelInfoAtom);
const handleGenerate = async () => {
if (!projectId || !imageName || !question) {
toast.error(t('images.missingParameters', { defaultValue: '缺少必要参数' }));
return;
}
if (model.type !== 'vision') {
toast.error(t('images.visionModelRequired', { defaultValue: '请选择支持视觉的模型' }));
return;
}
setLoading(true);
try {
const response = await axios.post(`/api/projects/${projectId}/images/datasets`, {
imageName,
question,
model,
language: i18n.language,
previewOnly
});
if (response.data.success && response.data.answer) {
let data = response.data.answer;
if (answerType === 'label') {
try {
data = JSON.parse(response.data.answer);
} catch {}
}
onSuccess(data);
toast.success(t('images.aiGenerateSuccess', { defaultValue: 'AI 生成成功' }));
}
} catch (error) {
console.error('AI 生成失败:', error);
const errorMsg = error.response?.data?.error || t('images.aiGenerateFailed', { defaultValue: 'AI 生成失败' });
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
return (
<Button
startIcon={loading ? <CircularProgress size={20} /> : <AutoAwesomeIcon />}
onClick={handleGenerate}
disabled={loading}
variant="outlined"
size="small"
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 500,
borderWidth: 2,
'&:hover': {
borderWidth: 2
},
...sx
}}
>
{loading
? t('common.generating', { defaultValue: '生成中...' })
: t('images.aiGenerate', { defaultValue: 'AI 识别' })}
</Button>
);
}

View File

@@ -0,0 +1,268 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Chip,
CircularProgress
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import Image from 'next/image';
import QuestionSelector from './QuestionSelector';
import AnswerInput from './AnswerInput';
export default function AnnotationDialog({
open,
onClose,
image,
templates,
selectedTemplate,
onTemplateChange,
answer,
onAnswerChange,
onSave,
onSaveAndContinue,
saving,
loading,
onOpenCreateQuestion,
onOpenCreateTemplate
}) {
const { t } = useTranslation();
if (!image) return null;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xl"
fullWidth
PaperProps={{
sx: {
borderRadius: 2,
maxHeight: '90vh'
}
}}
>
<DialogTitle sx={{ pb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h5" fontWeight="600">
{t('images.annotateImage', { defaultValue: '标注图片' })}
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{image && (
<Chip
label={`${image.answeredQuestions?.length || 0} / ${(image.answeredQuestions?.length || 0) + (image.unansweredQuestions?.length || 0)} 已完成`}
color="primary"
variant="outlined"
size="small"
/>
)}
</Box>
</Box>
</DialogTitle>
<DialogContent sx={{ p: 3 }}>
{/* 图片预览区域 */}
<Box
sx={{
display: 'flex',
gap: 4,
mb: 4,
minHeight: 450
}}
>
{/* 图片预览 */}
<Box
sx={{
flex: '0 0 450px',
display: 'flex',
flexDirection: 'column',
gap: 2
}}
>
{image && (
<>
<Box
sx={{
position: 'relative',
width: '100%',
height: 400,
border: '2px solid',
borderColor: 'divider',
borderRadius: 2,
overflow: 'hidden',
bgcolor: 'grey.50'
}}
>
{image.base64 ? (
<Image src={image.base64} alt={image.imageName} fill style={{ objectFit: 'contain' }} priority />
) : (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'text.secondary'
}}
>
<Typography variant="body2">
{t('images.imageLoadError', { defaultValue: '图片加载失败' })}
</Typography>
</Box>
)}
</Box>
{/* 图片信息卡片 */}
<Box
sx={{
p: 2,
bgcolor: 'grey.50',
borderRadius: 2,
border: '1px solid',
borderColor: 'divider'
}}
>
<Typography variant="subtitle1" fontWeight="600" gutterBottom>
{image.imageName}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1 }}>
{image.width && image.height && (
<Chip label={`${image.width} × ${image.height}`} size="small" variant="outlined" />
)}
{image.size && (
<Chip label={`${(image.size / 1024).toFixed(2)} KB`} size="small" variant="outlined" />
)}
{image.format && <Chip label={image.format?.toUpperCase()} size="small" variant="outlined" />}
</Box>
<Typography variant="body2" color="text.secondary">
<strong>{t('images.annotatedCount', { defaultValue: '已标注' })}:</strong> {image.datasetCount || 0}{' '}
{t('images.questions', { defaultValue: '个问题' })}
</Typography>
</Box>
</>
)}
</Box>
{/* 标注区域 */}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 3,
minWidth: 0
}}
>
{/* 问题选择器 */}
<Box
sx={{
p: 3,
bgcolor: 'background.paper',
borderRadius: 2,
border: '1px solid',
borderColor: 'divider'
}}
>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<QuestionSelector
templates={templates}
selectedTemplate={selectedTemplate}
onTemplateChange={onTemplateChange}
answeredQuestions={image?.answeredQuestions || []}
unansweredQuestions={image?.unansweredQuestions || []}
onOpenCreateQuestion={onOpenCreateQuestion}
onOpenCreateTemplate={onOpenCreateTemplate}
/>
)}
</Box>
{/* 答案输入区域 */}
{selectedTemplate && (
<Box
sx={{
p: 3,
bgcolor: 'background.paper',
borderRadius: 2,
border: '1px solid',
borderColor: 'divider'
}}
>
<AnswerInput
answerType={selectedTemplate.answerType}
answer={answer}
onAnswerChange={onAnswerChange}
labels={selectedTemplate.labels}
customFormat={selectedTemplate.customFormat}
projectId={image?.projectId}
imageName={image?.imageName}
question={selectedTemplate}
/>
</Box>
)}
</Box>
</Box>
</DialogContent>
<DialogActions
sx={{
p: 3,
pt: 0,
gap: 1,
justifyContent: 'space-between',
display: 'flex',
flexWrap: 'wrap'
}}
>
{/* 左侧:创建按钮 */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
onClick={onOpenCreateQuestion}
variant="outlined"
size="small"
sx={{ borderRadius: 2, textTransform: 'none' }}
>
{t('images.createQuestion', { defaultValue: '创建问题' })}
</Button>
<Button
onClick={onOpenCreateTemplate}
variant="outlined"
size="small"
sx={{ borderRadius: 2, textTransform: 'none' }}
>
{t('images.createTemplate', { defaultValue: '创建问题模板' })}
</Button>
</Box>
{/* 右侧:操作按钮 */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button onClick={onClose} disabled={saving} variant="outlined" sx={{ borderRadius: 2 }}>
{t('common.cancel')}
</Button>
<Button
onClick={onSaveAndContinue}
disabled={saving || !selectedTemplate}
variant="outlined"
sx={{ borderRadius: 2 }}
>
{saving ? <CircularProgress size={20} /> : t('images.saveAndContinue', { defaultValue: '保存并继续' })}
</Button>
<Button onClick={onSave} disabled={saving || !selectedTemplate} variant="contained" sx={{ borderRadius: 2 }}>
{saving ? <CircularProgress size={20} /> : t('common.save')}
</Button>
</Box>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,437 @@
'use client';
import { Box, Typography, TextField, Chip, Button, Paper } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import AIGenerateButton from './AIGenerateButton';
export default function AnswerInput({
answerType,
answer,
onAnswerChange,
labels,
customFormat,
projectId,
imageName,
question
}) {
const { t, i18n } = useTranslation();
const [newLabel, setNewLabel] = useState('');
const [jsonError, setJsonError] = useState('');
// 文字类型输入
if (answerType === 'text') {
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" fontWeight="600" sx={{ color: 'text.primary' }}>
{t('images.answer', { defaultValue: '文本答案' })} *
</Typography>
<AIGenerateButton
projectId={projectId}
imageName={imageName}
question={question}
onSuccess={onAnswerChange}
/>
</Box>
<TextField
fullWidth
multiline
rows={6}
value={answer}
onChange={e => onAnswerChange(e.target.value)}
placeholder={t('images.answerPlaceholder', { defaultValue: '请输入答案...' })}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 3,
backgroundColor: 'background.paper',
'& fieldset': {
borderWidth: 2,
borderColor: 'divider'
},
'&:hover fieldset': {
borderColor: 'primary.main'
},
'&.Mui-focused fieldset': {
borderColor: 'primary.main',
borderWidth: 2
}
},
'& textarea': {
fontSize: '14px',
lineHeight: 1.6
}
}}
/>
</Box>
);
}
// 标签类型输入 - 提前解析 labels避免条件中的 hooks 问题
if (answerType === 'label') {
const selectedLabels = Array.isArray(answer) ? answer : [];
// 解析 labels可能是 JSON 字符串或数组)
let labelOptions = [];
if (typeof labels === 'string' && labels) {
try {
labelOptions = JSON.parse(labels);
} catch (e) {
labelOptions = [];
}
} else if (Array.isArray(labels)) {
labelOptions = labels;
}
if (!labelOptions.includes('其他') && !labelOptions.includes('other')) {
labelOptions.push(i18n.language === 'en' ? 'other' : '其他');
}
const handleToggleLabel = label => {
if (selectedLabels.includes(label)) {
onAnswerChange(selectedLabels.filter(l => l !== label));
} else {
let newLabels = [...selectedLabels, label];
onAnswerChange(newLabels);
}
};
const handleAddNewLabel = () => {
if (newLabel.trim() && !labelOptions.includes(newLabel.trim())) {
handleToggleLabel(newLabel.trim());
setNewLabel('');
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6" fontWeight="600" sx={{ color: 'text.primary' }}>
{t('images.selectLabels', { defaultValue: '标签选择' })} *
</Typography>
<AIGenerateButton
projectId={projectId}
imageName={imageName}
question={question}
onSuccess={onAnswerChange}
answerType={answerType}
/>
</Box>
{/* 可选标签 */}
<Paper
variant="outlined"
sx={{
p: 3,
mb: 3,
borderRadius: 3,
backgroundColor: 'grey.50',
border: '2px solid',
borderColor: 'divider',
'&:hover': {
borderColor: 'primary.light'
}
}}
>
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
{t('images.availableLabels', { defaultValue: '可选标签' })}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
{labelOptions && labelOptions.length > 0 ? (
labelOptions.map(label => (
<Chip
key={label}
label={label}
onClick={() => handleToggleLabel(label)}
color={selectedLabels.includes(label) ? 'primary' : 'default'}
variant={selectedLabels.includes(label) ? 'filled' : 'outlined'}
sx={{
borderRadius: 2,
fontWeight: 500,
fontSize: '0.875rem',
height: 36,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
transform: 'translateY(-1px)',
boxShadow: 2
}
}}
/>
))
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
{t('images.noLabelsAvailable', { defaultValue: '暂无可选标签' })}
</Typography>
)}
</Box>
</Paper>
{/* 添加新标签 */}
{/* <Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<TextField
size="small"
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
placeholder={t('images.addNewLabel', { defaultValue: '添加新标签...' })}
onKeyPress={e => {
if (e.key === 'Enter') {
handleAddNewLabel();
}
}}
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: 'background.paper',
'& fieldset': {
borderWidth: 2
},
'&:hover fieldset': {
borderColor: 'primary.main'
}
}
}}
/>
<Button
startIcon={<AddIcon />}
onClick={handleAddNewLabel}
disabled={!newLabel.trim()}
variant="contained"
sx={{
borderRadius: 2,
px: 3,
fontWeight: 600,
textTransform: 'none',
boxShadow: 2,
'&:hover': {
boxShadow: 4
}
}}
>
{t('common.add', { defaultValue: '添加' })}
</Button>
</Box> */}
{/* 已选择标签 */}
{/* {selectedLabels.length > 0 && (
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
{t('images.selectedLabels', { defaultValue: '已选择' })} ({selectedLabels.length})
</Typography>
<Paper
sx={{
p: 2.5,
borderRadius: 3,
backgroundColor: 'primary.50',
border: '2px solid',
borderColor: 'primary.200'
}}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
{selectedLabels.map(label => (
<Chip
key={label}
label={label}
onDelete={() => handleToggleLabel(label)}
color="primary"
sx={{
borderRadius: 2,
fontWeight: 500,
fontSize: '0.875rem',
height: 36,
'& .MuiChip-deleteIcon': {
fontSize: '18px',
'&:hover': {
color: 'error.main'
}
}
}}
/>
))}
</Box>
</Paper>
</Box>
)} */}
</Box>
);
}
// 自定义格式输入
if (answerType === 'custom_format') {
const handleJsonChange = value => {
onAnswerChange(value);
// 验证 JSON 格式
if (value.trim()) {
try {
JSON.parse(value);
setJsonError('');
} catch (e) {
setJsonError(t('images.invalidJsonFormat', { defaultValue: 'JSON 格式不正确' }));
}
} else {
setJsonError('');
}
};
const handleUseTemplate = () => {
if (customFormat) {
try {
let templateJson;
if (typeof customFormat === 'string') {
templateJson = JSON.parse(customFormat);
} else {
templateJson = customFormat;
}
const formatted = JSON.stringify(templateJson, null, 2);
onAnswerChange(formatted);
setJsonError('');
} catch (e) {
onAnswerChange('{}');
}
}
};
if (answer && typeof answer === 'object') {
answer = JSON.stringify(answer, null, 2);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6" fontWeight="600" sx={{ color: 'text.primary' }}>
{t('images.customFormatAnswer', { defaultValue: '自定义格式答案' })} *
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<AIGenerateButton
projectId={projectId}
imageName={imageName}
question={question}
onSuccess={onAnswerChange}
/>
{customFormat && (
<Button
size="small"
onClick={handleUseTemplate}
variant="outlined"
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 500,
borderWidth: 2,
'&:hover': {
borderWidth: 2
}
}}
>
{t('images.useTemplate', { defaultValue: '使用模板' })}
</Button>
)}
{/* <Button
size="small"
onClick={handleFormatJson}
variant="outlined"
disabled={!answer.trim()}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 500,
borderWidth: 2,
'&:hover': {
borderWidth: 2
}
}}
>
{t('images.formatJson', { defaultValue: '格式化' })}
</Button> */}
</Box>
</Box>
{/* 显示格式要求 */}
{customFormat && (
<Paper
variant="outlined"
sx={{
p: 3,
mb: 3,
bgcolor: 'grey.50',
borderRadius: 3,
border: '2px solid',
borderColor: 'divider'
}}
>
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
{t('images.formatRequirement', { defaultValue: '格式要求' })}
</Typography>
<Box
sx={{
backgroundColor: 'background.paper',
borderRadius: 2,
p: 2,
border: '1px solid',
borderColor: 'divider'
}}
>
<pre
style={{
margin: 0,
fontSize: '13px',
overflow: 'auto',
maxHeight: '150px',
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
lineHeight: 1.5,
color: '#2d3748'
}}
>
{typeof customFormat === 'string' ? customFormat : JSON.stringify(customFormat, null, 2)}
</pre>
</Box>
</Paper>
)}
{/* JSON 输入框 */}
<TextField
fullWidth
multiline
rows={10}
value={answer}
onChange={e => handleJsonChange(e.target.value)}
placeholder={t('images.customFormatPlaceholder', { defaultValue: '请输入符合格式的 JSON...' })}
error={!!jsonError}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 3,
backgroundColor: 'background.paper',
'& fieldset': {
borderWidth: 2
},
'&:hover fieldset': {
borderColor: 'primary.main'
},
'&.Mui-focused fieldset': {
borderColor: 'primary.main',
borderWidth: 2
},
'&.Mui-error fieldset': {
borderColor: 'error.main',
borderWidth: 2
}
},
'& textarea': {
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
fontSize: '13px',
lineHeight: 1.5
},
'& .MuiFormHelperText-root': {
fontSize: '0.875rem',
fontWeight: 500
}
}}
/>
</Box>
);
}
return null;
}

View File

@@ -0,0 +1,200 @@
'use client';
import { Autocomplete, TextField, Box, Typography, Chip, Button, Dialog } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
export default function QuestionSelector({
templates,
selectedTemplate,
onTemplateChange,
answeredQuestions = [],
unansweredQuestions = [],
onOpenCreateQuestion,
onOpenCreateTemplate
}) {
const { t } = useTranslation();
const [showNoQuestionsMessage, setShowNoQuestionsMessage] = useState(false);
// 构建未完成标注的问题选项(用于下拉框)
const dropdownOptions = unansweredQuestions.map(q => ({
...q,
isUnanswered: true
}));
const getAnswerTypeLabel = answerType => {
switch (answerType) {
case 'text':
return t('images.answerTypeText', { defaultValue: '文字' });
case 'label':
return t('images.answerTypeLabel', { defaultValue: '标签' });
case 'custom_format':
return t('images.answerTypeCustomFormat', { defaultValue: '自定义格式' });
default:
return answerType;
}
};
// 判断是否有待标注问题
const hasUnansweredQuestions = unansweredQuestions.length > 0;
const hasAnsweredQuestions = answeredQuestions.length > 0;
const hasAnyQuestions = hasUnansweredQuestions || hasAnsweredQuestions;
return (
<Box>
{/* 已标注问题区域 - 优化显示为一行,添加最大高度 */}
{answeredQuestions.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" fontWeight="600" gutterBottom sx={{ mb: 1.5, color: 'text.secondary' }}>
{t('images.answeredQuestions', { defaultValue: '已标注问题' })} ({answeredQuestions.length})
</Typography>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
maxHeight: 120,
overflowY: 'auto',
paddingRight: 1,
'&::-webkit-scrollbar': {
width: '6px'
},
'&::-webkit-scrollbar-track': {
bgcolor: 'transparent'
},
'&::-webkit-scrollbar-thumb': {
bgcolor: 'action.disabled',
borderRadius: 1,
'&:hover': {
bgcolor: 'action.active'
}
}
}}
>
{answeredQuestions.map(question => (
<Chip
key={question.id}
label={question.question}
size="small"
color="success"
variant="outlined"
sx={{
borderRadius: 2,
fontWeight: 500,
fontSize: '0.875rem',
maxWidth: '100%',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
}}
/>
))}
</Box>
</Box>
)}
{/* 问题选择下拉框 */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" fontWeight="600" gutterBottom sx={{ mb: 2 }}>
{t('images.selectNewQuestion', { defaultValue: '选择新问题' })}
</Typography>
{!hasUnansweredQuestions ? (
// 没有待标注问题的提示
<Box
sx={{
p: 3,
textAlign: 'center',
bgcolor: 'background.paper',
border: '1px dashed',
borderColor: 'divider',
borderRadius: 2,
mb: 2
}}
>
{hasAnsweredQuestions ? (
<Typography color="text.secondary" sx={{ mb: 1 }}>
{t('images.allQuestionsAnnotated', { defaultValue: '当前图片所有问题已标注完成' })}
</Typography>
) : (
<Typography color="text.secondary" sx={{ mb: 1 }}>
{t('images.noQuestionsAssociated', { defaultValue: '当前图片未关联任何问题' })}
</Typography>
)}
</Box>
) : (
// 有待标注问题时显示下拉框
<Autocomplete
fullWidth
options={dropdownOptions}
value={selectedTemplate}
onChange={(event, newValue) => {
if (newValue) {
onTemplateChange(newValue);
}
}}
getOptionLabel={option => option.question || ''}
renderOption={(props, option) => (
<Box
component="li"
{...props}
sx={{
py: 1.5,
borderRadius: 1,
mx: 1,
my: 0.5,
'&:hover': {
bgcolor: 'action.hover'
}
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight="500">
{option.question}
</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
<Chip label={getAnswerTypeLabel(option.answerType)} size="small" sx={{ borderRadius: 1 }} />
<Chip
label={t('images.pendingAnswer', { defaultValue: '待标注' })}
size="small"
color="warning"
variant="filled"
sx={{ borderRadius: 1, fontSize: '0.75rem' }}
/>
</Box>
</Box>
</Box>
)}
renderInput={params => (
<TextField
{...params}
placeholder={t('images.selectQuestionPlaceholder', { defaultValue: '请选择问题进行标注...' })}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
'& fieldset': {
borderWidth: 2
},
'&:hover fieldset': {
borderColor: 'primary.main'
}
}
}}
/>
)}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
)}
{selectedTemplate && selectedTemplate.description && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{selectedTemplate.description}
</Typography>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,287 @@
import { useState } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
// 深度遍历 JSON将所有值设为空字符串
function clearJsonValues(obj) {
if (Array.isArray(obj)) {
return obj.map(item => clearJsonValues(item));
} else if (obj !== null && typeof obj === 'object') {
const cleared = {};
for (const key in obj) {
cleared[key] = clearJsonValues(obj[key]);
}
return cleared;
} else {
return ''; // 所有基础类型值都变为空字符串
}
}
export function useAnnotation(projectId, onSuccess, onFindNextImage) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
const [currentImage, setCurrentImage] = useState(null);
const [selectedTemplate, setSelectedTemplate] = useState(null);
const [answer, setAnswer] = useState('');
// 打开标注对话框
const openAnnotation = async (image, template = null) => {
setLoading(true);
try {
// 获取图片详情,包括已标注和未标注的问题
const response = await axios.get(`/api/projects/${projectId}/images/${image.id}`);
if (response.data.success) {
const imageDetail = response.data.data;
setCurrentImage(imageDetail);
// 如果没有指定模板,尝试选择第一个未标注的问题
if (!template) {
if (imageDetail.unansweredQuestions?.length > 0) {
template = imageDetail.unansweredQuestions[0];
}
}
setSelectedTemplate(template);
// 根据问题类型初始化答案
let initialAnswer = '';
if (template?.answerType === 'label') {
initialAnswer = [];
} else if (template?.answerType === 'custom_format' && template?.customFormat) {
// 为自定义格式提供默认值(所有字段值清空)
try {
let templateJson;
if (typeof template.customFormat === 'string') {
// 如果customFormat是字符串尝试解析为JSON
templateJson = JSON.parse(template.customFormat);
} else {
// 如果customFormat已经是对象直接使用
templateJson = template.customFormat;
}
// 深度遍历,将所有字段值清空
const clearedJson = clearJsonValues(templateJson);
initialAnswer = JSON.stringify(clearedJson, null, 2);
} catch (error) {
// 如枟解析失败提供一个空的JSON对象
initialAnswer = '{}';
}
}
setAnswer(initialAnswer);
setOpen(true);
} else {
toast.error(t('images.loadImageDetailFailed', { defaultValue: '加载图片详情失败' }));
}
} catch (error) {
console.error('获取图片详情失败:', error);
toast.error(t('images.loadImageDetailFailed', { defaultValue: '加载图片详情失败' }));
} finally {
setLoading(false);
}
};
// 关闭对话框
const closeAnnotation = () => {
setOpen(false);
setCurrentImage(null);
setSelectedTemplate(null);
setAnswer('');
};
// 刷新当前图片的问题列表(创建问题后调用)
const refreshCurrentImage = async () => {
if (!currentImage) return;
try {
const response = await axios.get(`/api/projects/${projectId}/images/${currentImage.id}`);
if (response.data.success) {
const imageDetail = response.data.data;
// 更新当前图片数据
setCurrentImage(imageDetail);
return imageDetail;
}
} catch (error) {
console.error('刷新图片详情失败:', error);
}
};
// 查找下一个未标注的问题
const findNextUnansweredQuestion = async () => {
// 重新获取图片详情,获取最新的问题列表
try {
const response = await axios.get(`/api/projects/${projectId}/images/${currentImage.id}`);
if (response.data.success) {
const imageDetail = response.data.data;
// 更新当前图片数据
setCurrentImage(imageDetail);
// 返回第一个未标注的问题
if (imageDetail.unansweredQuestions?.length > 0) {
return imageDetail.unansweredQuestions[0];
}
return null;
}
} catch (error) {
console.error('获取下一个问题失败:', error);
return null;
}
};
// 保存标注
const saveAnnotation = async (continueNext = false) => {
if (!currentImage) {
toast.error(t('images.noImageSelected', { defaultValue: '未选择图片' }));
return;
}
if (!selectedTemplate) {
toast.error(t('images.noTemplateSelected', { defaultValue: '请选择问题' }));
return;
}
// 验证答案
if (!answer || (Array.isArray(answer) && answer.length === 0)) {
toast.error(t('images.answerRequired', { defaultValue: '请输入答案' }));
return;
}
// 如果是自定义格式,验证 JSON 格式
if (selectedTemplate.answerType === 'custom_format') {
try {
JSON.parse(answer);
} catch (e) {
toast.error(t('images.invalidJsonFormat', { defaultValue: 'JSON 格式不正确' }));
return;
}
}
console.log(999, answer);
setSaving(true);
try {
const response = await axios.post(`/api/projects/${projectId}/images/annotations`, {
imageId: currentImage.id,
imageName: currentImage.imageName,
questionId: selectedTemplate.id,
question: selectedTemplate.question,
templateId: selectedTemplate.id,
answerType: selectedTemplate.answerType,
answer
});
if (response.data.success) {
toast.success(t('images.annotationSuccess', { defaultValue: '标注保存成功' }));
// 触发刷新回调
if (onSuccess) {
onSuccess();
}
if (continueNext) {
// 查找下一个未标注的问题
const nextQuestion = await findNextUnansweredQuestion();
if (nextQuestion) {
// 切换到下一个问题
setSelectedTemplate(nextQuestion);
// 根据问题类型初始化答案
let initialAnswer = '';
if (nextQuestion.answerType === 'label') {
initialAnswer = [];
} else if (nextQuestion.answerType === 'custom_format' && nextQuestion.customFormat) {
try {
let templateJson;
if (typeof nextQuestion.customFormat === 'string') {
templateJson = JSON.parse(nextQuestion.customFormat);
} else {
templateJson = nextQuestion.customFormat;
}
const clearedJson = clearJsonValues(templateJson);
initialAnswer = JSON.stringify(clearedJson, null, 2);
} catch (error) {
initialAnswer = '{}';
}
}
setAnswer(initialAnswer);
} else {
// 没有更多未标注的问题了,尝试查找下一个有未标注问题的图片
if (onFindNextImage) {
const nextImage = await onFindNextImage();
if (nextImage) {
// 打开下一个图片的标注
await openAnnotation(nextImage);
} else {
// 没有更多图片了
toast.info(t('images.allImagesAnnotated', { defaultValue: '所有图片的问题都已标注完成' }));
closeAnnotation();
}
} else {
toast.info(t('images.allQuestionsAnnotated', { defaultValue: '当前图片所有问题已标注完成' }));
closeAnnotation();
}
}
} else {
closeAnnotation();
}
}
} catch (error) {
console.error('保存标注失败:', error);
const errorMsg = error.response?.data?.error || t('images.annotationFailed', { defaultValue: '保存标注失败' });
toast.error(errorMsg);
} finally {
setSaving(false);
}
};
// 处理模板变更
const handleTemplateChange = template => {
setSelectedTemplate(template);
// 根据新模板类型初始化答案
let initialAnswer = '';
if (template?.answerType === 'label') {
initialAnswer = [];
} else if (template?.answerType === 'custom_format' && template?.customFormat) {
// 为自定义格式提供默认值(所有字段值清空)
try {
let templateJson;
if (typeof template.customFormat === 'string') {
// 如果customFormat是字符串尝试解析为JSON
templateJson = JSON.parse(template.customFormat);
} else {
// 如果customFormat已经是对象直接使用
templateJson = template.customFormat;
}
// 深度遍历,将所有字段值清空
const clearedJson = clearJsonValues(templateJson);
initialAnswer = JSON.stringify(clearedJson, null, 2);
} catch (error) {
// 如枟解析失败提供一个空的JSON对象
initialAnswer = '{}';
}
}
setAnswer(initialAnswer);
};
return {
open,
saving,
loading,
currentImage,
selectedTemplate,
answer,
setSelectedTemplate,
setAnswer,
handleTemplateChange,
openAnnotation,
closeAnnotation,
saveAnnotation,
refreshCurrentImage
};
}

View File

@@ -0,0 +1,486 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import {
Container,
Box,
Typography,
Button,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField
} from '@mui/material';
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import DeleteIcon from '@mui/icons-material/Delete';
import { imageStyles } from './styles/imageStyles';
import { toast } from 'sonner';
import axios from 'axios';
import { useAtomValue } from 'jotai';
import { selectedModelInfoAtom } from '@/lib/store';
import ImageFilters from './components/ImageFilters';
import ImageGrid from './components/ImageGrid';
import ImageList from './components/ImageList';
import ImportDialog from './components/ImportDialog';
import QuestionDialog from './components/QuestionDialog';
import DatasetDialog from './components/DatasetDialog';
import AnnotationDialog from './components/annotation/AnnotationDialog';
import { useQuestionTemplates } from '../questions/hooks/useQuestionTemplates';
import { useAnnotation } from './hooks/useAnnotation';
import { useQuestionEdit } from '../questions/hooks/useQuestionEdit';
import QuestionEditDialog from '../questions/components/QuestionEditDialog';
import TemplateFormDialog from '../questions/components/template/TemplateFormDialog';
export default function ImagesPage() {
const { projectId } = useParams();
const router = useRouter();
const { t, i18n } = useTranslation();
const selectedModel = useAtomValue(selectedModelInfoAtom);
const [loading, setLoading] = useState(false);
const [images, setImages] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(8);
// 筛选条件
const [imageName, setImageName] = useState('');
const [hasQuestions, setHasQuestions] = useState('all');
const [hasDatasets, setHasDatasets] = useState('all');
// 视图模式
const [viewMode, setViewMode] = useState('grid');
// 选中状态(仅列表视图使用)
const [selectedIds, setSelectedIds] = useState([]);
// 对话框状态
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [questionDialogOpen, setQuestionDialogOpen] = useState(false);
const [datasetDialogOpen, setDatasetDialogOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState(null);
const [autoGenerateDialogOpen, setAutoGenerateDialogOpen] = useState(false);
const [questionCount, setQuestionCount] = useState(3);
// 问题模板和标注功能 (只获取图像类型的模板)
const { templates, createTemplate } = useQuestionTemplates(projectId, 'image');
// 问题编辑 Hook
const { editDialogOpen, editMode, editingQuestion, handleOpenCreateDialog, handleCloseDialog, handleSubmitQuestion } =
useQuestionEdit(projectId, async () => {
fetchImages();
if (annotationOpen && currentImage) {
await refreshCurrentImage();
}
toast.success(t('questions.operationSuccess'));
});
// 模板管理状态
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
// 获取图片列表
const fetchImages = async () => {
try {
setLoading(true);
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString()
});
if (imageName) params.append('imageName', imageName);
if (hasQuestions !== 'all') params.append('hasQuestions', hasQuestions);
if (hasDatasets !== 'all') params.append('hasDatasets', hasDatasets);
const response = await axios.get(`/api/projects/${projectId}/images?${params.toString()}`);
setImages(response.data.data);
setTotal(response.data.total);
} catch (error) {
console.error('Failed to fetch images:', error);
toast.error(t('common.fetchError'));
} finally {
setLoading(false);
}
};
// 查找下一个有未标注问题的图片
const handleFindNextImage = async () => {
try {
const response = await axios.get(`/api/projects/${projectId}/images/next-unanswered`);
return response.data.data || null;
} catch (error) {
console.error('查找下一个图片失败:', error);
return null;
}
};
const {
open: annotationOpen,
saving: annotationSaving,
loading: annotationLoading,
currentImage,
selectedTemplate,
answer,
setAnswer,
handleTemplateChange,
openAnnotation,
closeAnnotation,
saveAnnotation,
refreshCurrentImage
} = useAnnotation(projectId, fetchImages, handleFindNextImage);
useEffect(() => {
fetchImages();
}, [projectId, page, imageName, hasQuestions, hasDatasets]);
useEffect(() => {
setSelectedIds([]);
}, [viewMode]);
// 处理导入成功
const handleImportSuccess = () => {
setImportDialogOpen(false);
setPage(1);
fetchImages();
};
// 处理生成问题
const handleGenerateQuestions = image => {
setSelectedImage(image);
setQuestionDialogOpen(true);
};
// 处理生成数据集
const handleGenerateDataset = image => {
setSelectedImage(image);
setDatasetDialogOpen(true);
};
// 删除图片
const handleDeleteImage = async imageId => {
if (!confirm(t('images.deleteConfirm', { defaultValue: '确定要删除这张图片吗?' }))) {
return;
}
try {
await axios.delete(`/api/projects/${projectId}/images?imageId=${imageId}`);
toast.success(t('images.deleteSuccess', { defaultValue: '删除成功' }));
fetchImages();
} catch (error) {
console.error('Failed to delete image:', error);
toast.error(t('images.deleteFailed', { defaultValue: '删除失败' }));
}
};
// 批量删除图片
const handleBatchDelete = async () => {
if (selectedIds.length === 0) {
toast.error(t('images.selectImagesToDelete', { defaultValue: '请选择要删除的图片' }));
return;
}
if (
!confirm(
t('images.batchDeleteConfirm', {
defaultValue: `确定要删除选中的 ${selectedIds.length} 张图片吗?`,
count: selectedIds.length
})
)
) {
return;
}
try {
setLoading(true);
let successCount = 0;
let failCount = 0;
// 逐个调用删除接口
for (const imageId of selectedIds) {
try {
await axios.delete(`/api/projects/${projectId}/images?imageId=${imageId}`);
successCount++;
} catch (error) {
console.error(`Failed to delete image ${imageId}:`, error);
failCount++;
}
}
// 显示结果
if (failCount === 0) {
toast.success(
t('images.batchDeleteSuccess', {
defaultValue: `成功删除 ${successCount} 张图片`,
count: successCount
})
);
} else {
toast.warning(
t('images.batchDeletePartialSuccess', {
defaultValue: `成功删除 ${successCount} 张,失败 ${failCount}`,
success: successCount,
fail: failCount
})
);
}
// 清空选中状态并刷新列表
setSelectedIds([]);
fetchImages();
} catch (error) {
console.error('Batch delete failed:', error);
toast.error(t('images.batchDeleteFailed', { defaultValue: '批量删除失败' }));
} finally {
setLoading(false);
}
};
// 处理自动提取问题
const handleAutoGenerateQuestions = () => {
if (!selectedModel) {
toast.error(t('images.selectModelFirst'));
return;
}
if (selectedModel.type !== 'vision') {
toast.error(t('images.visionModelRequired'));
return;
}
setAutoGenerateDialogOpen(true);
};
// 确认创建自动提取任务
const handleConfirmAutoGenerate = async () => {
// 验证问题数量
if (questionCount < 1 || questionCount > 10) {
toast.error(t('images.countRange'));
return;
}
try {
setAutoGenerateDialogOpen(false);
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
taskType: 'image-question-generation',
modelInfo: selectedModel,
language: i18n.language,
note: { questionCount }
});
if (response.data.code === 0) {
toast.success(t('images.taskCreated'));
// 跳转到任务管理页面
router.push(`/projects/${projectId}/tasks`);
} else {
toast.error(response.data.error || t('images.taskCreateFailed'));
}
} catch (error) {
console.error('Failed to create auto-generate task:', error);
toast.error(t('images.taskCreateFailed'));
}
};
// 模板管理函数
const handleOpenCreateTemplateDialog = () => {
setTemplateDialogOpen(true);
};
const handleCloseTemplateDialog = () => {
setTemplateDialogOpen(false);
};
const handleSubmitTemplate = async data => {
try {
await createTemplate(data);
handleCloseTemplateDialog();
fetchImages();
if (annotationOpen && currentImage) {
await refreshCurrentImage();
}
toast.success(t('questions.operationSuccess'));
} catch (error) {
console.error('Failed to save template:', error);
}
};
return (
<Container maxWidth="xl" sx={imageStyles.pageContainer}>
{/* 页面头部 */}
<Box sx={imageStyles.header}>
<Box sx={imageStyles.headerTitle}>
<Typography variant="h4" component="h1" sx={imageStyles.title}>
{t('images.title', { defaultValue: '图片管理' })}
</Typography>
</Box>
<Box sx={imageStyles.headerActions}>
{viewMode === 'list' && selectedIds.length > 0 && (
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={handleBatchDelete}
sx={imageStyles.actionButton}
>
{t('images.batchDelete', { defaultValue: '批量删除' })} ({selectedIds.length})
</Button>
)}
<Button
variant="outlined"
startIcon={<AutoAwesomeIcon />}
onClick={handleAutoGenerateQuestions}
sx={imageStyles.actionButton}
>
{t('images.autoGenerateQuestions', { defaultValue: 'AI 批量生成问题' })}
</Button>
<Button
variant="contained"
startIcon={<AddPhotoAlternateIcon />}
onClick={() => setImportDialogOpen(true)}
sx={imageStyles.actionButton}
>
{t('images.importImages', { defaultValue: '导入图片' })}
</Button>
</Box>
</Box>
{/* 筛选区域 */}
<ImageFilters
imageName={imageName}
onImageNameChange={setImageName}
hasQuestions={hasQuestions}
onHasQuestionsChange={setHasQuestions}
hasDatasets={hasDatasets}
onHasDatasetsChange={setHasDatasets}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* 图片列表 */}
{loading ? (
<Box sx={imageStyles.loadingContainer}>
<CircularProgress size={48} />
</Box>
) : viewMode === 'grid' ? (
<ImageGrid
images={images}
total={total}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onGenerateQuestions={handleGenerateQuestions}
onGenerateDataset={handleGenerateDataset}
onDelete={handleDeleteImage}
onAnnotate={openAnnotation}
/>
) : (
<ImageList
images={images}
total={total}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onGenerateQuestions={handleGenerateQuestions}
onGenerateDataset={handleGenerateDataset}
onDelete={handleDeleteImage}
onAnnotate={openAnnotation}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
)}
<ImportDialog
open={importDialogOpen}
projectId={projectId}
onClose={() => setImportDialogOpen(false)}
onSuccess={handleImportSuccess}
/>
<QuestionDialog
open={questionDialogOpen}
projectId={projectId}
image={selectedImage}
onClose={() => setQuestionDialogOpen(false)}
onSuccess={fetchImages}
/>
<DatasetDialog
open={datasetDialogOpen}
projectId={projectId}
image={selectedImage}
onClose={() => setDatasetDialogOpen(false)}
onSuccess={fetchImages}
/>
<Dialog open={autoGenerateDialogOpen} onClose={() => setAutoGenerateDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{t('images.autoGenerateQuestions')}</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 3 }}>{t('images.autoGenerateConfirm')}</Typography>
<TextField
fullWidth
type="number"
label={t('images.questionCount')}
value={questionCount}
onChange={e => setQuestionCount(parseInt(e.target.value) || 1)}
inputProps={{ min: 1, max: 10 }}
helperText={t('images.questionCountHelp')}
sx={{ mb: 2 }}
/>
<Typography variant="body2" color="text.secondary">
{t('images.currentModel')}: {selectedModel?.modelName || t('common.none')}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setAutoGenerateDialogOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleConfirmAutoGenerate} variant="contained">
{t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
<AnnotationDialog
open={annotationOpen}
onClose={closeAnnotation}
image={currentImage}
templates={templates}
selectedTemplate={selectedTemplate}
onTemplateChange={handleTemplateChange}
answer={answer}
onAnswerChange={setAnswer}
onSave={() => saveAnnotation(false)}
onSaveAndContinue={() => saveAnnotation(true)}
saving={annotationSaving}
loading={annotationLoading}
onOpenCreateQuestion={handleOpenCreateDialog}
onOpenCreateTemplate={handleOpenCreateTemplateDialog}
/>
{/* 问题编辑对话框 */}
<QuestionEditDialog
open={editDialogOpen}
mode={editMode}
question={editingQuestion}
onClose={handleCloseDialog}
onSubmit={handleSubmitQuestion}
projectId={projectId}
initialData={{ sourceType: 'image', imageId: currentImage?.id }}
/>
{/* 问题模板对话框 */}
<TemplateFormDialog
open={templateDialogOpen}
onClose={handleCloseTemplateDialog}
onSubmit={handleSubmitTemplate}
projectId={projectId}
template={{ sourceType: 'image' }}
/>
</Container>
);
}

View File

@@ -0,0 +1,286 @@
/**
* 图片管理页面样式配置
*/
export const imageStyles = {
// 页面容器
pageContainer: {
py: 4
},
// 页面头部
header: {
mb: 4,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexWrap: 'wrap',
gap: 3
},
headerTitle: {
display: 'flex',
flexDirection: 'column',
gap: 0.5
},
title: {
fontWeight: 700
},
subtitle: {
color: 'text.secondary',
fontSize: '0.875rem'
},
headerActions: {
display: 'flex',
gap: 2,
flexWrap: 'wrap'
},
actionButton: {
borderRadius: 2,
textTransform: 'none',
px: 3,
fontWeight: 600,
boxShadow: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 4
}
},
// 筛选区域
filterCard: {
mb: 3,
borderRadius: 2,
boxShadow: 1,
border: '1px solid',
borderColor: 'divider',
overflow: 'visible'
},
filterContent: {
display: 'flex',
gap: 2,
alignItems: 'center',
flexWrap: 'wrap'
},
searchField: {
minWidth: { xs: '100%', sm: 300 },
flex: { xs: '1 1 100%', sm: '1 1 auto' },
'& .MuiOutlinedInput-root': {
borderRadius: 2
}
},
filterSelect: {
minWidth: { xs: '48%', sm: 150 },
'& .MuiOutlinedInput-root': {
borderRadius: 2
}
},
viewToggle: {
ml: 'auto',
borderRadius: 2,
'& .MuiToggleButton-root': {
border: '1px solid',
borderColor: 'divider',
'&.Mui-selected': {
bgcolor: 'primary.main',
color: 'white',
'&:hover': {
bgcolor: 'primary.dark'
}
}
}
},
// 图片网格
gridContainer: {
spacing: 3
},
// 图片卡片
imageCard: {
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRadius: 3,
overflow: 'hidden',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
'&:hover': {
transform: 'translateY(-8px)',
boxShadow: theme => `0 12px 24px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.15)'}`,
borderColor: 'primary.main',
'& .image-overlay': {
opacity: 1
}
}
},
imageWrapper: {
position: 'relative',
overflow: 'hidden',
bgcolor: 'grey.100'
},
imageMedia: {
height: 220,
objectFit: 'cover',
transition: 'transform 0.3s ease',
cursor: 'pointer',
'&:hover': {
transform: 'scale(1.05)'
}
},
imageOverlay: {
className: 'image-overlay',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background:
'linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.6) 100%)',
opacity: 0,
transition: 'opacity 0.3s ease',
pointerEvents: 'none'
},
statusChipsContainer: {
position: 'absolute',
top: 12,
right: 12,
display: 'flex',
gap: 0.5,
flexDirection: 'column',
alignItems: 'flex-end',
zIndex: 2
},
statusChip: {
backdropFilter: 'blur(10px)',
fontWeight: 600,
fontSize: '0.75rem',
height: 24,
boxShadow: 2
},
imageNameContainer: {
position: 'absolute',
bottom: 12,
left: 12,
right: 12,
display: 'flex',
justifyContent: 'center',
zIndex: 2
},
imageNameChip: {
backdropFilter: 'blur(10px)',
bgcolor: 'rgba(255, 255, 255, 0.95)',
fontWeight: 600,
maxWidth: '90%',
boxShadow: 2,
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
},
cardContent: {
flexGrow: 1,
p: 2,
pb: 1.5
},
imageName: {
fontWeight: 600,
fontSize: '0.9rem',
lineHeight: 1.4
},
cardActions: {
p: 2,
pt: 0,
gap: 1,
mt: 2,
display: 'flex',
justifyContent: 'space-between'
},
actionIconButton: {
transition: 'all 0.2s ease',
'&:hover': {
transform: 'scale(1.1)'
}
},
primaryActionButton: {
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
flex: 1
},
// 分页
pagination: {
display: 'flex',
justifyContent: 'center',
mt: 4
},
// 空状态
emptyState: {
textAlign: 'center',
py: 12,
px: 3
},
emptyIcon: {
width: 120,
height: 120,
borderRadius: '50%',
bgcolor: 'primary.lighter',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 3
},
emptyTitle: {
fontWeight: 600,
mb: 1
},
emptyDescription: {
color: 'text.secondary',
mb: 4
},
emptyButton: {
borderRadius: 2,
px: 4,
textTransform: 'none',
fontWeight: 600
},
// 加载状态
loadingContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
py: 8
}
};