first-update
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
486
easy-dataset-main/app/projects/[projectId]/images/page.js
Normal file
486
easy-dataset-main/app/projects/[projectId]/images/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user