first-update
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Container, Box, CircularProgress, Alert } from '@mui/material';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useImageDatasetDetails from '../hooks/useImageDatasetDetails';
|
||||
import ImageDatasetHeader from '../components/ImageDatasetHeader';
|
||||
import DatasetContent from '../components/DatasetContent';
|
||||
import DatasetSidebar from '../components/DatasetSidebar';
|
||||
|
||||
export default function ImageDatasetDetailPage() {
|
||||
const { projectId, datasetId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
currentDataset,
|
||||
loading,
|
||||
confirming,
|
||||
unconfirming,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
updateDataset,
|
||||
handleNavigate,
|
||||
handleConfirm,
|
||||
handleUnconfirm,
|
||||
handleDelete
|
||||
} = useImageDatasetDetails(projectId, datasetId);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '70vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据状态
|
||||
if (!currentDataset) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{t('imageDatasets.notFound', '数据集不存在')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* 顶部导航栏 */}
|
||||
<ImageDatasetHeader
|
||||
projectId={projectId}
|
||||
datasetsAllCount={datasetsAllCount}
|
||||
datasetsConfirmCount={datasetsConfirmCount}
|
||||
confirming={confirming}
|
||||
unconfirming={unconfirming}
|
||||
currentDataset={currentDataset}
|
||||
onNavigate={handleNavigate}
|
||||
onConfirm={handleConfirm}
|
||||
onUnconfirm={handleUnconfirm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 主要布局:左右分栏 */}
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{/* 左侧主要内容区域 */}
|
||||
<DatasetContent
|
||||
dataset={currentDataset}
|
||||
projectId={projectId}
|
||||
onAnswerChange={async newAnswer => {
|
||||
// 直接传递答案字符串,DatasetContent 已经处理了格式转换
|
||||
await updateDataset({ answer: newAnswer });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 右侧固定侧边栏 */}
|
||||
<DatasetSidebar dataset={currentDataset} projectId={projectId} onUpdate={updateDataset} />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Paper, Typography, Button } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Image from 'next/image';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import AnswerInput from '../../images/components/annotation/AnswerInput';
|
||||
|
||||
function handleAnswer(dataset) {
|
||||
const { answer, answerType } = dataset;
|
||||
if (answerType === 'label' || answerType === 'custom_format') {
|
||||
try {
|
||||
return JSON.parse(answer);
|
||||
} catch (e) {
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据集主要内容组件
|
||||
*/
|
||||
export default function DatasetContent({ dataset, projectId, onAnswerChange }) {
|
||||
const { t } = useTranslation();
|
||||
const [currentAnswer, setCurrentAnswer] = useState(() => handleAnswer(dataset));
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 当 dataset 变化时,重置状态
|
||||
useEffect(() => {
|
||||
setCurrentAnswer(handleAnswer(dataset));
|
||||
setHasChanges(false);
|
||||
}, [dataset.id, dataset.answer]);
|
||||
|
||||
// 处理答案变化
|
||||
const handleAnswerChange = newAnswer => {
|
||||
setCurrentAnswer(newAnswer);
|
||||
|
||||
// 检测是否有变化
|
||||
const originalAnswer = handleAnswer(dataset);
|
||||
const hasChanged = JSON.stringify(newAnswer) !== JSON.stringify(originalAnswer);
|
||||
setHasChanges(hasChanged);
|
||||
};
|
||||
|
||||
// 保存答案
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
let answerToSave = currentAnswer;
|
||||
if (typeof answerToSave !== 'string') {
|
||||
answerToSave = JSON.stringify(answerToSave, null, 2);
|
||||
}
|
||||
await onAnswerChange(answerToSave);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{/* 问题和保存按钮 */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.7,
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'grey.100',
|
||||
p: 2,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{dataset.question}
|
||||
</Typography>
|
||||
|
||||
{/* 保存按钮 - 只在有变化时显示 */}
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
sx={{
|
||||
minWidth: 100,
|
||||
height: 'fit-content',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{saving ? t('common.saving', '保存中...') : t('common.save', '保存')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 答案编辑器 */}
|
||||
<AnswerInput
|
||||
answerType={dataset.answerType || 'text'}
|
||||
answer={currentAnswer}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
labels={dataset.availableLabels || []}
|
||||
customFormat={dataset.customFormat}
|
||||
projectId={projectId}
|
||||
imageName={dataset.imageName}
|
||||
question={dataset.questionData}
|
||||
/>
|
||||
|
||||
{/* 图片 */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
paddingTop: '56.25%',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'grey.100',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{dataset.base64 ? (
|
||||
<img
|
||||
src={dataset.base64}
|
||||
alt={dataset.imageName}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image src="/placeholder.png" alt={dataset.imageName} fill style={{ objectFit: 'contain' }} unoptimized />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, textAlign: 'center' }}>
|
||||
{dataset.imageName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
import MetadataInfo from './MetadataInfo';
|
||||
import MetadataEditor from './MetadataEditor';
|
||||
|
||||
/**
|
||||
* 数据集右侧边栏组件
|
||||
*/
|
||||
export default function DatasetSidebar({ dataset, projectId, onUpdate }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 360,
|
||||
position: 'sticky',
|
||||
top: 24,
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* 元数据信息 - Chip 形式 */}
|
||||
<MetadataInfo dataset={dataset} />
|
||||
|
||||
{/* 操作卡片 */}
|
||||
<MetadataEditor dataset={dataset} projectId={projectId} onUpdate={onUpdate} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { imageDatasetStyles } from '../styles/imageDatasetStyles';
|
||||
|
||||
export default function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box sx={imageDatasetStyles.emptyState}>
|
||||
<Box sx={imageDatasetStyles.emptyIcon}>
|
||||
<ImageSearchIcon sx={{ fontSize: 60, color: 'primary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={imageDatasetStyles.emptyTitle}>
|
||||
{t('imageDatasets.noData', { defaultValue: '暂无图片数据集' })}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={imageDatasetStyles.emptyDescription}>
|
||||
{t('imageDatasets.noDataTip', { defaultValue: '请先在图片管理中生成问答数据集' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
Checkbox,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
|
||||
const ExportImageDatasetDialog = ({ open, onClose, onExport }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formatType, setFormatType] = useState('raw');
|
||||
const [exportImages, setExportImages] = useState(false);
|
||||
const [includeImagePath, setIncludeImagePath] = useState(true);
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [confirmedOnly, setConfirmedOnly] = useState(false);
|
||||
|
||||
const handleExport = () => {
|
||||
onExport({
|
||||
formatType,
|
||||
exportImages,
|
||||
includeImagePath,
|
||||
systemPrompt,
|
||||
confirmedOnly
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t('imageDatasets.exportTitle', '导出图片数据集')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 2 }}>
|
||||
{/* 导出格式选择 */}
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">{t('imageDatasets.exportFormat', '导出格式')}</FormLabel>
|
||||
<RadioGroup value={formatType} onChange={e => setFormatType(e.target.value)}>
|
||||
<FormControlLabel value="raw" control={<Radio />} label={t('imageDatasets.rawFormat', '原始格式')} />
|
||||
<FormControlLabel value="sharegpt" control={<Radio />} label="ShareGPT (OpenAI)" />
|
||||
<FormControlLabel value="alpaca" control={<Radio />} label="Alpaca" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{/* 图片导出选项 */}
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={exportImages} onChange={e => setExportImages(e.target.checked)} />}
|
||||
label={t('imageDatasets.exportImagesOption', '导出图片文件')}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 4 }}>
|
||||
{t('imageDatasets.exportImagesDesc', '将所有图片打包成 ZIP 压缩包一起下载')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 图片路径选项 */}
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={includeImagePath} onChange={e => setIncludeImagePath(e.target.checked)} />}
|
||||
label={t('imageDatasets.includeImagePath', '在数据集中包含图片路径')}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 4 }}>
|
||||
{t('imageDatasets.includeImagePathDesc', '在问题或答案中添加图片路径(格式:/images/图片名称)')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 系统提示词 */}
|
||||
<TextField
|
||||
label={t('imageDatasets.systemPrompt', '系统提示词(可选)')}
|
||||
multiline
|
||||
rows={3}
|
||||
value={systemPrompt}
|
||||
onChange={e => setSystemPrompt(e.target.value)}
|
||||
placeholder={t('imageDatasets.systemPromptPlaceholder', '输入系统提示词...')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{/* 仅导出已确认 */}
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={confirmedOnly} onChange={e => setConfirmedOnly(e.target.checked)} />}
|
||||
label={t('imageDatasets.confirmedOnly', '仅导出已确认的数据集')}
|
||||
/>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
{t('imageDatasets.exportTip', '标签格式的答案将自动解析为文本(逗号分隔)')}
|
||||
</Alert>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleClose} variant="outlined">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button onClick={handleExport} variant="contained">
|
||||
{t('export.title', '导出')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportImageDatasetDialog;
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardMedia, Box, Chip, Typography, Tooltip, IconButton } from '@mui/material';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { imageDatasetStyles } from '../styles/imageDatasetStyles';
|
||||
|
||||
export default function ImageDatasetCard({
|
||||
dataset,
|
||||
onClick,
|
||||
onView = () => {},
|
||||
onDelete = () => {},
|
||||
onEvaluate = () => {}
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getAnswerText = () => {
|
||||
if (!dataset.answer) return t('imageDatasets.noAnswer', '暂无答案');
|
||||
if (dataset.answerType === 'label') {
|
||||
try {
|
||||
const labels = JSON.parse(dataset.answer);
|
||||
return `${t('imageDatasets.labels', '标签')}: ${labels.join(', ')}`;
|
||||
} catch {
|
||||
return dataset.answer;
|
||||
}
|
||||
}
|
||||
return dataset.answer;
|
||||
};
|
||||
|
||||
const getAnswerTypeLabel = type => {
|
||||
switch (type) {
|
||||
case 'label':
|
||||
return t('imageDatasets.typeLabel', '标签');
|
||||
case 'custom_format':
|
||||
return t('imageDatasets.typeCustom', '自定义');
|
||||
default:
|
||||
return t('imageDatasets.typeText', '文本');
|
||||
}
|
||||
};
|
||||
|
||||
const getAnswerTypeColor = type => {
|
||||
switch (type) {
|
||||
case 'label':
|
||||
return 'secondary';
|
||||
case 'custom_format':
|
||||
return 'info';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreLabel = () => {
|
||||
if (!dataset.score || dataset.score === 0) {
|
||||
return t('imageDatasets.unscored', '未评分');
|
||||
}
|
||||
return dataset.score;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={imageDatasetStyles.datasetCard}>
|
||||
{/* 图片区域 */}
|
||||
<Box sx={imageDatasetStyles.imageWrapper}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={dataset.base64 || '/placeholder.png'}
|
||||
alt={dataset.imageName}
|
||||
sx={imageDatasetStyles.imageMedia}
|
||||
/>
|
||||
|
||||
{/* 悬停遮罩 */}
|
||||
<Box sx={imageDatasetStyles.imageOverlay} />
|
||||
|
||||
{/* 问题内容 - 底部,毛玻璃背景 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
p: 1.5,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.6) 70%, transparent 100%)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.5)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
{dataset.question}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 内容区域 - 标签和操作按钮 */}
|
||||
<Tooltip title={getAnswerText()} placement="top" arrow>
|
||||
<Box sx={{ p: 1.5, cursor: 'help' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
{/* 左侧:所有标签 */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={getAnswerTypeLabel(dataset.answerType)}
|
||||
size="small"
|
||||
color={getAnswerTypeColor(dataset.answerType)}
|
||||
sx={{ fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
<Chip
|
||||
label={
|
||||
dataset.confirmed ? t('imageDatasets.confirmed', '已确认') : t('imageDatasets.unconfirmed', '未确认')
|
||||
}
|
||||
size="small"
|
||||
color={dataset.confirmed ? 'success' : 'default'}
|
||||
sx={{ fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
<Chip
|
||||
icon={<span style={{ fontSize: '0.7rem' }}>⭐</span>}
|
||||
label={getScoreLabel()}
|
||||
size="small"
|
||||
color={dataset.score && dataset.score > 0 ? 'warning' : 'default'}
|
||||
sx={{ fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:操作按钮 - 不同颜色 */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={t('imageDatasets.view', '查看详情')} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onView(dataset.id);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
color: '#1976d2',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('imageDatasets.evaluate', '质量评估')} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onEvaluate(dataset.id);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
color: '#f57c00',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(245, 124, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AssessmentIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('imageDatasets.delete', '删除')} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(dataset.id);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
color: '#d32f2f',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(211, 47, 47, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
Slider,
|
||||
TextField,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ImageDatasetFilterDialog({
|
||||
open,
|
||||
onClose,
|
||||
statusFilter,
|
||||
scoreFilter,
|
||||
onStatusChange,
|
||||
onScoreChange,
|
||||
onResetFilters,
|
||||
onApplyFilters
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t('datasets.filtersTitle', '筛选条件')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{/* 确认状态筛选 */}
|
||||
<Box sx={{ mb: 3, mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
{t('imageDatasets.status', { defaultValue: '确认状态' })}
|
||||
</Typography>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={e => onStatusChange(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem value="all">{t('common.all', '全部')}</MenuItem>
|
||||
<MenuItem value="confirmed">{t('imageDatasets.confirmed', { defaultValue: '已确认' })}</MenuItem>
|
||||
<MenuItem value="unconfirmed">{t('imageDatasets.unconfirmed', { defaultValue: '未确认' })}</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
{/* 评分范围筛选 */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
{t('imageDatasets.scoreRange', { defaultValue: '评分范围' })}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
{scoreFilter[0]} - {scoreFilter[1]} 分
|
||||
</Typography>
|
||||
<Slider
|
||||
value={scoreFilter}
|
||||
onChange={(e, newValue) => onScoreChange(newValue)}
|
||||
valueLabelDisplay="auto"
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
marks
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
{/* 对话框操作按钮 */}
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onResetFilters} variant="outlined">
|
||||
{t('common.reset', '重置')}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button onClick={onApplyFilters} variant="contained">
|
||||
{t('common.apply', '应用')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Paper, IconButton, InputBase, Button, Badge } from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ImageDatasetFilters({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onMoreFiltersClick,
|
||||
activeFilterCount = 0
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* 搜索框 - 完全参考数据集管理的设计 */}
|
||||
<Paper
|
||||
component="form"
|
||||
sx={{
|
||||
p: '2px 4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 400,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<IconButton sx={{ p: '10px' }} aria-label="search">
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
<InputBase
|
||||
sx={{ ml: 1, flex: 1 }}
|
||||
placeholder={t('imageDatasets.searchPlaceholder', { defaultValue: '搜索问题或答案...' })}
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* 更多筛选按钮 - 带 Badge 显示活跃筛选条件数 */}
|
||||
<Badge badgeContent={activeFilterCount} color="error" overlap="circular">
|
||||
<Button variant="outlined" onClick={onMoreFiltersClick} startIcon={<FilterListIcon />} sx={{ borderRadius: 2 }}>
|
||||
{t('datasets.moreFilters', '更多筛选')}
|
||||
</Button>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Divider, Typography, IconButton, CircularProgress, Paper } from '@mui/material';
|
||||
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import UndoIcon from '@mui/icons-material/Undo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 图片数据集详情页面的头部导航组件
|
||||
*/
|
||||
export default function ImageDatasetHeader({
|
||||
projectId,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
confirming,
|
||||
unconfirming,
|
||||
currentDataset,
|
||||
onNavigate,
|
||||
onConfirm,
|
||||
onUnconfirm,
|
||||
onDelete
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{/* 左侧:返回按钮和统计信息 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<NavigateBeforeIcon />}
|
||||
onClick={() => router.push(`/projects/${projectId}/image-datasets`)}
|
||||
>
|
||||
{t('imageDatasets.title', '图片数据集')}
|
||||
</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
共 {datasetsAllCount} 个数据集,已确认 {datasetsConfirmCount} 个 (
|
||||
{datasetsAllCount > 0 ? ((datasetsConfirmCount / datasetsAllCount) * 100).toFixed(2) : 0}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:翻页、确认/取消确认、删除按钮 */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton onClick={() => onNavigate('prev')}>
|
||||
<NavigateBeforeIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onNavigate('next')}>
|
||||
<NavigateNextIcon />
|
||||
</IconButton>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* 确认/取消确认按钮 */}
|
||||
{currentDataset?.confirmed ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={unconfirming}
|
||||
onClick={onUnconfirm}
|
||||
startIcon={unconfirming ? <CircularProgress size={16} /> : <UndoIcon />}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{unconfirming ? t('common.unconfirming', '取消中...') : t('datasets.unconfirm', '取消确认')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" color="primary" disabled={confirming} onClick={onConfirm} sx={{ mr: 1 }}>
|
||||
{confirming ? <CircularProgress size={24} /> : t('datasets.confirmSave', '确认保留')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={onDelete}>
|
||||
{t('common.delete', '删除')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Divider, Paper } from '@mui/material';
|
||||
import { toast } from 'sonner';
|
||||
import StarRating from '@/components/datasets/StarRating';
|
||||
import TagSelector from '@/components/datasets/TagSelector';
|
||||
import NoteInput from '@/components/datasets/NoteInput';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function MetadataEditor({ dataset, projectId, onUpdate }) {
|
||||
const { t } = useTranslation();
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 解析数据集中的标签
|
||||
const parseDatasetTags = tagsString => {
|
||||
try {
|
||||
return JSON.parse(tagsString || '[]');
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 本地状态管理,从 props 初始化
|
||||
const [localScore, setLocalScore] = useState(dataset?.score || 0);
|
||||
const [localTags, setLocalTags] = useState(() => {
|
||||
const tags = parseDatasetTags(dataset?.tags);
|
||||
// 确保 localTags 始终是数组
|
||||
return Array.isArray(tags) ? tags : [];
|
||||
});
|
||||
const [localNote, setLocalNote] = useState(dataset?.note || '');
|
||||
|
||||
// 获取项目中已使用的标签
|
||||
useEffect(() => {
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/image-datasets/tags`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableTags(data.tags || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取可用标签失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
fetchAvailableTags();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 同步props中的dataset到本地状态
|
||||
useEffect(() => {
|
||||
if (dataset) {
|
||||
setLocalScore(dataset.score || 0);
|
||||
const tags = parseDatasetTags(dataset.tags);
|
||||
setLocalTags(Array.isArray(tags) ? tags : []);
|
||||
setLocalNote(dataset.note || '');
|
||||
}
|
||||
}, [dataset]);
|
||||
|
||||
// 更新数据集元数据
|
||||
const updateMetadata = async updates => {
|
||||
if (loading) return;
|
||||
|
||||
// 立即更新本地状态,提升响应速度
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(updates.score);
|
||||
}
|
||||
// 注意:tags 已经在 handleTagsChange 中更新过了,这里不需要再更新
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(updates.note);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 调用父组件的更新方法
|
||||
if (onUpdate) {
|
||||
await onUpdate(updates);
|
||||
}
|
||||
toast.success(t('imageDatasets.updateSuccess', '更新成功'));
|
||||
} catch (error) {
|
||||
console.error('更新数据集元数据失败:', error);
|
||||
toast.error(t('imageDatasets.updateFailed', '更新失败'));
|
||||
|
||||
// 出错时恢复本地状态
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(dataset?.score || 0);
|
||||
}
|
||||
if (updates.tags !== undefined) {
|
||||
// 恢复为原始的标签数组
|
||||
const tags = parseDatasetTags(dataset?.tags);
|
||||
setLocalTags(Array.isArray(tags) ? tags : []);
|
||||
}
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(dataset?.note || '');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理评分变更
|
||||
const handleScoreChange = newScore => {
|
||||
updateMetadata({ score: newScore });
|
||||
};
|
||||
|
||||
// 处理标签变更
|
||||
const handleTagsChange = newTags => {
|
||||
// 立即更新本地状态(保持为数组)
|
||||
setLocalTags(newTags);
|
||||
// 发送给父组件时转换为 JSON 字符串
|
||||
updateMetadata({ tags: JSON.stringify(newTags) });
|
||||
};
|
||||
|
||||
// 处理备注变更
|
||||
const handleNoteChange = newNote => {
|
||||
updateMetadata({ note: newNote });
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
{/* 评分区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('datasets.rating', '评分')}
|
||||
</Typography>
|
||||
<StarRating value={localScore} onChange={handleScoreChange} readOnly={loading} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 标签区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('datasets.customTags', '自定义标签')}
|
||||
</Typography>
|
||||
<TagSelector
|
||||
value={localTags}
|
||||
onChange={handleTagsChange}
|
||||
availableTags={availableTags}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addCustomTag', '添加自定义标签...')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 备注区域 */}
|
||||
<NoteInput
|
||||
value={localNote}
|
||||
onChange={handleNoteChange}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addNote', '添加备注...')}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Chip, alpha, Divider } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
/**
|
||||
* 元数据信息展示组件 - Chip 形式(参考 DatasetMetadata)
|
||||
*/
|
||||
export default function MetadataInfo({ dataset }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
// 解析标签
|
||||
const parsedTags = (() => {
|
||||
try {
|
||||
if (typeof dataset.tags === 'string' && dataset.tags) {
|
||||
return JSON.parse(dataset.tags);
|
||||
}
|
||||
return Array.isArray(dataset.tags) ? dataset.tags : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = bytes => {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{/* 数据集信息 */}
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('common.detailInfo', '详细信息')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 2 }}>
|
||||
{/* 使用模型 */}
|
||||
{dataset.model && (
|
||||
<Chip
|
||||
label={`${t('imageDatasets.modelInfo', '使用模型')}: ${dataset.model}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标签数量 */}
|
||||
{parsedTags.length > 0 && (
|
||||
<Chip
|
||||
label={`${t('imageDatasets.tags', '标签')}: ${parsedTags.length} ${t('common.items', '项')}`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 创建时间 */}
|
||||
<Chip
|
||||
label={`${t('imageDatasets.createdAt', '创建时间')}: ${new Date(dataset.createAt).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* 文本块信息 */}
|
||||
{dataset.questionTemplate?.description && (
|
||||
<Chip
|
||||
label={`${t('imageDatasets.description', '描述')}: ${dataset.questionTemplate.description}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ maxWidth: '100%' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 确认状态 */}
|
||||
{dataset.confirmed && (
|
||||
<Chip
|
||||
label={t('datasets.confirmed', '已确认')}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.success.main, 0.1),
|
||||
color: theme.palette.success.dark,
|
||||
fontWeight: 'medium'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 图片信息 */}
|
||||
{dataset.image && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('images.imageInfo', '图片信息')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
{/* 图片尺寸 */}
|
||||
{dataset.image.width && dataset.image.height && (
|
||||
<Chip
|
||||
label={`${t('images.resolution', '分辨率')}: ${dataset.image.width}×${dataset.image.height}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件大小 */}
|
||||
{dataset.image.size && (
|
||||
<Chip
|
||||
label={`${t('images.fileSize', '文件大小')}: ${formatFileSize(dataset.image.size)}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片创建时间 */}
|
||||
{dataset.image.createAt && (
|
||||
<Chip
|
||||
label={`${t('images.uploadTime', '上传时间')}: ${new Date(dataset.image.createAt).toLocaleString(
|
||||
'zh-CN',
|
||||
{
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片名称 */}
|
||||
{dataset.image.imageName && (
|
||||
<Chip
|
||||
label={`${t('images.fileName', '文件名')}: ${dataset.image.imageName}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ maxWidth: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useImageDatasetDetail(projectId, datasetId) {
|
||||
const { t } = useTranslation();
|
||||
const [dataset, setDataset] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 获取详情
|
||||
const fetchDetail = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
setDataset(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dataset detail:', error);
|
||||
toast.error(t('imageDatasets.fetchDetailFailed', { defaultValue: '获取详情失败' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, datasetId, t]);
|
||||
|
||||
// 更新数据集
|
||||
const updateDataset = useCallback(
|
||||
async updates => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await axios.put(`/api/projects/${projectId}/image-datasets/${datasetId}`, updates);
|
||||
setDataset(response.data);
|
||||
toast.success(t('imageDatasets.updateSuccess', { defaultValue: '更新成功' }));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to update dataset:', error);
|
||||
toast.error(t('imageDatasets.updateFailed', { defaultValue: '更新失败' }));
|
||||
throw error;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[projectId, datasetId, t]
|
||||
);
|
||||
|
||||
// AI 重新识别
|
||||
const regenerateAnswer = useCallback(async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await axios.post(`/api/projects/${projectId}/image-datasets/${datasetId}/regenerate`);
|
||||
setDataset(response.data);
|
||||
toast.success(t('imageDatasets.regenerateSuccess', { defaultValue: 'AI 识别成功' }));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate answer:', error);
|
||||
toast.error(t('imageDatasets.regenerateFailed', { defaultValue: 'AI 识别失败' }));
|
||||
throw error;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [projectId, datasetId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && datasetId) {
|
||||
fetchDetail();
|
||||
}
|
||||
}, [projectId, datasetId, fetchDetail]);
|
||||
|
||||
return {
|
||||
dataset,
|
||||
loading,
|
||||
saving,
|
||||
updateDataset,
|
||||
regenerateAnswer,
|
||||
fetchDetail
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function useImageDatasetDetails(projectId, datasetId) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [currentDataset, setCurrentDataset] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [unconfirming, setUnconfirming] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [datasetsAllCount, setDatasetsAllCount] = useState(0);
|
||||
const [datasetsConfirmCount, setDatasetsConfirmCount] = useState(0);
|
||||
|
||||
// 获取数据集列表信息
|
||||
const fetchDatasetsList = useCallback(async () => {
|
||||
try {
|
||||
// 获取所有数据集以正确统计已确认数量
|
||||
const response = await axios.get(`/api/projects/${projectId}/image-datasets?page=1&pageSize=10000`);
|
||||
const data = response.data;
|
||||
setDatasetsAllCount(data.total || 0);
|
||||
setDatasetsConfirmCount(data.data?.filter(d => d.confirmed).length || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch datasets list:', error);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 获取当前数据集详情
|
||||
const fetchDatasetDetail = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
setCurrentDataset(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dataset detail:', error);
|
||||
toast.error(t('imageDatasets.fetchDetailFailed', '获取详情失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, datasetId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && datasetId) {
|
||||
fetchDatasetDetail();
|
||||
fetchDatasetsList();
|
||||
}
|
||||
}, [projectId, datasetId, fetchDatasetDetail, fetchDatasetsList]);
|
||||
|
||||
// 更新数据集
|
||||
const updateDataset = useCallback(
|
||||
async updates => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await axios.put(`/api/projects/${projectId}/image-datasets/${datasetId}`, updates);
|
||||
toast.success(t('imageDatasets.updateSuccess', '更新成功'));
|
||||
// 刷新数据
|
||||
await fetchDatasetDetail();
|
||||
await fetchDatasetsList();
|
||||
} catch (error) {
|
||||
console.error('Failed to update dataset:', error);
|
||||
toast.error(t('imageDatasets.updateFailed', '更新失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[projectId, datasetId, t, fetchDatasetDetail, fetchDatasetsList]
|
||||
);
|
||||
|
||||
// 翻页导航
|
||||
const handleNavigate = useCallback(
|
||||
async (direction, skipCurrentId = null) => {
|
||||
try {
|
||||
// 获取所有数据集(不分页),使用一个足够大的 pageSize
|
||||
const response = await axios.get(`/api/projects/${projectId}/image-datasets?page=1&pageSize=10000`);
|
||||
const datasets = response.data.data || [];
|
||||
|
||||
if (datasets.length === 0) {
|
||||
router.push(`/projects/${projectId}/image-datasets`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确定当前索引
|
||||
let currentIndex = -1;
|
||||
const searchId = skipCurrentId || datasetId;
|
||||
const currentDatasetId = String(searchId);
|
||||
|
||||
// 查找当前数据集的索引
|
||||
currentIndex = datasets.findIndex(d => String(d.id) === currentDatasetId);
|
||||
|
||||
// 如果找不到(删除场景或其他原因),从第一个开始
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
// 计算下一个索引
|
||||
let nextIndex;
|
||||
if (direction === 'prev') {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : datasets.length - 1;
|
||||
} else {
|
||||
nextIndex = currentIndex < datasets.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
const nextDataset = datasets[nextIndex];
|
||||
if (nextDataset) {
|
||||
router.push(`/projects/${projectId}/image-datasets/${nextDataset.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate:', error);
|
||||
toast.error(t('common.navigationFailed', '导航失败'));
|
||||
}
|
||||
},
|
||||
[projectId, datasetId, router, t]
|
||||
);
|
||||
|
||||
// 确认保留
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setConfirming(true);
|
||||
try {
|
||||
await updateDataset({ confirmed: true });
|
||||
// 确认后导航到下一条
|
||||
await handleNavigate('next');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}, [updateDataset, handleNavigate]);
|
||||
|
||||
// 取消确认
|
||||
const handleUnconfirm = useCallback(async () => {
|
||||
setUnconfirming(true);
|
||||
try {
|
||||
await updateDataset({ confirmed: false });
|
||||
} finally {
|
||||
setUnconfirming(false);
|
||||
}
|
||||
}, [updateDataset]);
|
||||
|
||||
// 删除数据集
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (confirm(t('imageDatasets.deleteConfirm', '确定要删除这个数据集吗?'))) {
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
toast.success(t('imageDatasets.deleteSuccess', '删除成功'));
|
||||
// 导航到下一条,传递 datasetId 以便 handleNavigate 知道是删除场景
|
||||
await handleNavigate('next', datasetId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete dataset:', error);
|
||||
toast.error(t('imageDatasets.deleteFailed', '删除失败'));
|
||||
}
|
||||
}
|
||||
}, [projectId, datasetId, handleNavigate, t]);
|
||||
|
||||
return {
|
||||
currentDataset,
|
||||
loading,
|
||||
saving,
|
||||
confirming,
|
||||
unconfirming,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
updateDataset,
|
||||
handleNavigate,
|
||||
handleConfirm,
|
||||
handleUnconfirm,
|
||||
handleDelete
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
const useImageDatasetExport = projectId => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* 解析标签格式的答案
|
||||
* 如果答案是 JSON 数组格式,解析并用逗号连接
|
||||
*/
|
||||
const parseAnswerLabels = item => {
|
||||
const { answer, answerType } = item;
|
||||
if (answerType !== 'label' || !answer) {
|
||||
return answer;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析 JSON
|
||||
const parsed = JSON.parse(answer);
|
||||
if (Array.isArray(parsed)) {
|
||||
// 如果是数组,用逗号连接
|
||||
return parsed.join(', ');
|
||||
}
|
||||
return answer;
|
||||
} catch (e) {
|
||||
// 不是 JSON 格式,直接返回原答案
|
||||
return answer;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出图片数据集
|
||||
*/
|
||||
const exportImageDatasets = async exportOptions => {
|
||||
try {
|
||||
// 1. 获取数据集数据
|
||||
const apiUrl = `/api/projects/${projectId}/image-datasets/export`;
|
||||
const response = await axios.post(apiUrl, {
|
||||
confirmedOnly: exportOptions.confirmedOnly
|
||||
});
|
||||
|
||||
let datasets = response.data;
|
||||
|
||||
if (!datasets || datasets.length === 0) {
|
||||
toast.warning(t('imageDatasets.noDataToExport', '没有可导出的数据'));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 处理答案中的标签格式
|
||||
datasets = datasets.map(item => ({
|
||||
...item,
|
||||
answer: parseAnswerLabels(item)
|
||||
}));
|
||||
|
||||
// 3. 根据格式类型转换数据
|
||||
let formattedData;
|
||||
|
||||
if (exportOptions.formatType === 'raw') {
|
||||
// 原始格式:直接导出数据集
|
||||
formattedData = datasets.map(item => {
|
||||
const result = { ...item };
|
||||
|
||||
// 如果需要包含图片路径
|
||||
if (exportOptions.includeImagePath && item.imageName) {
|
||||
result.image_path = `/images/${item.imageName}`;
|
||||
}
|
||||
|
||||
if (item.answerType === 'custom_format') {
|
||||
try {
|
||||
result.answerObj = JSON.parse(item.answer);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
} else if (exportOptions.formatType === 'alpaca') {
|
||||
formattedData = datasets.map(({ question, answer, imageName }) => {
|
||||
const item = {
|
||||
instruction: question,
|
||||
input: '',
|
||||
output: answer
|
||||
};
|
||||
|
||||
// 如果需要包含图片路径
|
||||
if (exportOptions.includeImagePath && imageName) {
|
||||
item.images = [`/images/${imageName}`];
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
} else if (exportOptions.formatType === 'sharegpt') {
|
||||
formattedData = datasets.map(({ question, answer, imageName }) => {
|
||||
const messages = [];
|
||||
|
||||
// 添加系统提示词(如果有)
|
||||
if (exportOptions.systemPrompt) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: exportOptions.systemPrompt
|
||||
});
|
||||
}
|
||||
|
||||
// 添加用户问题
|
||||
const userContent = [];
|
||||
|
||||
// 如果需要包含图片路径
|
||||
if (exportOptions.includeImagePath && imageName) {
|
||||
userContent.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `/images/${imageName}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
userContent.push({
|
||||
type: 'text',
|
||||
text: question
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: userContent
|
||||
});
|
||||
|
||||
// 添加助手回答
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: answer
|
||||
});
|
||||
|
||||
return { messages };
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 生成 JSON 文件
|
||||
const jsonContent = JSON.stringify(formattedData, null, 2);
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
const formatSuffix = exportOptions.formatType;
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
a.download = `image-datasets-${projectId}-${formatSuffix}-${dateStr}.json`;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(t('imageDatasets.exportSuccess', '数据集导出成功'));
|
||||
|
||||
// 5. 如果需要导出图片,调用压缩包接口
|
||||
if (exportOptions.exportImages) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
confirmedOnly: exportOptions.confirmedOnly.toString()
|
||||
});
|
||||
|
||||
const zipUrl = `/api/projects/${projectId}/image-datasets/export-zip?${params.toString()}`;
|
||||
|
||||
// 创建一个隐藏的 a 标签来触发下载
|
||||
const a = document.createElement('a');
|
||||
a.href = zipUrl;
|
||||
a.style.display = 'none';
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t('imageDatasets.exportImagesSuccess', '图片压缩包导出成功'));
|
||||
} catch (error) {
|
||||
console.error('Failed to export images:', error);
|
||||
toast.error(t('imageDatasets.exportImagesFailed', '图片导出失败'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
toast.error(error.message || t('imageDatasets.exportFailed', '导出失败'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
exportImageDatasets
|
||||
};
|
||||
};
|
||||
|
||||
export default useImageDatasetExport;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'imageDatasetFilters';
|
||||
|
||||
export function useImageDatasetFilters(projectId) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [scoreFilter, setScoreFilter] = useState([0, 5]);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// 从 localStorage 恢复筛选条件
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(`${STORAGE_KEY}_${projectId}`);
|
||||
if (stored) {
|
||||
const filters = JSON.parse(stored);
|
||||
setSearchQuery(filters.searchQuery || '');
|
||||
setStatusFilter(filters.statusFilter || 'all');
|
||||
setScoreFilter(filters.scoreFilter || [0, 5]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore filters:', error);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, [projectId]);
|
||||
|
||||
// 保存筛选条件到 localStorage
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${STORAGE_KEY}_${projectId}`,
|
||||
JSON.stringify({
|
||||
searchQuery,
|
||||
statusFilter,
|
||||
scoreFilter
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save filters:', error);
|
||||
}
|
||||
}
|
||||
}, [projectId, searchQuery, statusFilter, scoreFilter, isInitialized]);
|
||||
|
||||
// 计算活跃筛选条件数
|
||||
const getActiveFilterCount = useCallback(() => {
|
||||
let count = 0;
|
||||
if (statusFilter !== 'all') count++;
|
||||
if (scoreFilter[0] > 0 || scoreFilter[1] < 5) count++;
|
||||
return count;
|
||||
}, [statusFilter, scoreFilter]);
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
setStatusFilter('all');
|
||||
setScoreFilter([0, 5]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
scoreFilter,
|
||||
setScoreFilter,
|
||||
isInitialized,
|
||||
getActiveFilterCount,
|
||||
resetFilters
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useImageDatasets(projectId, filters = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [datasets, setDatasets] = useState({ data: [], total: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// 使用 useMemo 稳定 filters 对象引用
|
||||
const stableFilters = useMemo(
|
||||
() => ({
|
||||
search: filters.search || '',
|
||||
confirmed: filters.confirmed,
|
||||
minScore: filters.minScore,
|
||||
maxScore: filters.maxScore
|
||||
}),
|
||||
[filters.search, filters.confirmed, filters.minScore, filters.maxScore]
|
||||
);
|
||||
|
||||
// 获取数据集列表
|
||||
const fetchDatasets = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let url = `/api/projects/${projectId}/image-datasets?page=${page}&pageSize=${pageSize}`;
|
||||
|
||||
// 搜索条件
|
||||
if (stableFilters.search) {
|
||||
url += `&search=${encodeURIComponent(stableFilters.search)}`;
|
||||
}
|
||||
|
||||
// 确认状态筛选
|
||||
if (stableFilters.confirmed !== undefined) {
|
||||
url += `&confirmed=${stableFilters.confirmed}`;
|
||||
}
|
||||
|
||||
// 评分筛选
|
||||
if (stableFilters.minScore !== undefined || stableFilters.maxScore !== undefined) {
|
||||
if (stableFilters.minScore !== undefined) {
|
||||
url += `&minScore=${stableFilters.minScore}`;
|
||||
}
|
||||
if (stableFilters.maxScore !== undefined) {
|
||||
url += `&maxScore=${stableFilters.maxScore}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.get(url);
|
||||
setDatasets(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch datasets:', error);
|
||||
toast.error(t('imageDatasets.fetchFailed', { defaultValue: '获取数据集失败' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, page, pageSize, stableFilters, t]);
|
||||
|
||||
// 删除数据集
|
||||
const deleteDataset = useCallback(
|
||||
async datasetId => {
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
toast.success(t('imageDatasets.deleteSuccess', { defaultValue: '删除成功' }));
|
||||
fetchDatasets();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete dataset:', error);
|
||||
toast.error(t('imageDatasets.deleteFailed', { defaultValue: '删除失败' }));
|
||||
}
|
||||
},
|
||||
[projectId, fetchDatasets, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchDatasets();
|
||||
}
|
||||
}, [projectId, page, stableFilters, fetchDatasets]);
|
||||
|
||||
return {
|
||||
datasets,
|
||||
loading,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
fetchDatasets,
|
||||
deleteDataset
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Container, Box, Typography, Grid, Pagination, CircularProgress, Card, Button } from '@mui/material';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { imageDatasetStyles } from './styles/imageDatasetStyles';
|
||||
import { useImageDatasets } from './hooks/useImageDatasets';
|
||||
import { useImageDatasetFilters } from './hooks/useImageDatasetFilters';
|
||||
import ImageDatasetFilters from './components/ImageDatasetFilters';
|
||||
import ImageDatasetFilterDialog from './components/ImageDatasetFilterDialog';
|
||||
import ImageDatasetCard from './components/ImageDatasetCard';
|
||||
import EmptyState from './components/EmptyState';
|
||||
import ExportImageDatasetDialog from './components/ExportImageDatasetDialog';
|
||||
import useImageDatasetExport from './hooks/useImageDatasetExport';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
export default function ImageDatasetsPage() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
|
||||
// 使用筛选 Hook
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
scoreFilter,
|
||||
setScoreFilter,
|
||||
getActiveFilterCount,
|
||||
resetFilters
|
||||
} = useImageDatasetFilters(projectId);
|
||||
|
||||
// 使用数据 Hook
|
||||
const { datasets, loading, page, setPage, pageSize, fetchDatasets } = useImageDatasets(projectId, {
|
||||
search: searchQuery,
|
||||
confirmed: statusFilter === 'all' ? undefined : statusFilter === 'confirmed',
|
||||
minScore: scoreFilter[0],
|
||||
maxScore: scoreFilter[1]
|
||||
});
|
||||
|
||||
// 使用导出 Hook
|
||||
const { exportImageDatasets } = useImageDatasetExport(projectId);
|
||||
|
||||
const handlePageChange = (event, value) => {
|
||||
setPage(value);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCardClick = datasetId => {
|
||||
router.push(`/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
};
|
||||
|
||||
const handleViewDetails = datasetId => {
|
||||
router.push(`/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
};
|
||||
|
||||
const handleDeleteDataset = async datasetId => {
|
||||
if (confirm(t('imageDatasets.deleteConfirm', '确定要删除这个数据集吗?'))) {
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
toast.success(t('imageDatasets.deleteSuccess', '删除成功'));
|
||||
// 重新查询数据
|
||||
fetchDatasets();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete dataset:', error);
|
||||
toast.error(t('imageDatasets.deleteFailed', '删除失败'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEvaluateDataset = datasetId => {
|
||||
toast.info(t('common.comingSoon', '功能开发中...'));
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
resetFilters();
|
||||
setFilterDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilterDialogOpen(false);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleExport = async exportOptions => {
|
||||
setExportDialogOpen(false);
|
||||
await exportImageDatasets(exportOptions);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(datasets.total / pageSize);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={imageDatasetStyles.pageContainer}>
|
||||
{/* 筛选区域 - 参考数据集管理的设计 */}
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
py: 2,
|
||||
px: 2,
|
||||
borderRadius: 2,
|
||||
boxShadow: 0,
|
||||
bgcolor: theme => alpha(theme.palette.primary.main, 0.06)
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<ImageDatasetFilters
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={value => {
|
||||
setSearchQuery(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onMoreFiltersClick={() => setFilterDialogOpen(true)}
|
||||
activeFilterCount={getActiveFilterCount()}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileDownloadIcon />}
|
||||
sx={{ borderRadius: 2 }}
|
||||
onClick={() => setExportDialogOpen(true)}
|
||||
>
|
||||
{t('export.title')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* 数据集列表 */}
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : datasets.data.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<Grid container spacing={3}>
|
||||
{datasets.data.map(dataset => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={dataset.id}>
|
||||
<ImageDatasetCard
|
||||
dataset={dataset}
|
||||
onClick={handleCardClick}
|
||||
onView={handleViewDetails}
|
||||
onDelete={handleDeleteDataset}
|
||||
onEvaluate={handleEvaluateDataset}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={imageDatasetStyles.pagination}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
size="large"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 筛选对话框 */}
|
||||
<ImageDatasetFilterDialog
|
||||
open={filterDialogOpen}
|
||||
onClose={() => setFilterDialogOpen(false)}
|
||||
statusFilter={statusFilter}
|
||||
scoreFilter={scoreFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
onScoreChange={setScoreFilter}
|
||||
onResetFilters={handleResetFilters}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
/>
|
||||
|
||||
{/* 导出对话框 */}
|
||||
<ExportImageDatasetDialog
|
||||
open={exportDialogOpen}
|
||||
onClose={() => setExportDialogOpen(false)}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 图片数据集模块样式配置
|
||||
* 参考图片管理模块的精美设计
|
||||
*/
|
||||
|
||||
export const imageDatasetStyles = {
|
||||
// 页面容器
|
||||
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'
|
||||
},
|
||||
|
||||
// 筛选区域
|
||||
filterCard: {
|
||||
mb: 3,
|
||||
borderRadius: 2,
|
||||
boxShadow: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'visible'
|
||||
},
|
||||
|
||||
filterContent: {
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
|
||||
// 数据集卡片 - 参考图片管理的设计
|
||||
datasetCard: {
|
||||
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',
|
||||
cursor: 'pointer',
|
||||
'&: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
|
||||
},
|
||||
'& .image-media': {
|
||||
transform: 'scale(1.05)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 图片包装器
|
||||
imageWrapper: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'grey.100'
|
||||
},
|
||||
|
||||
// 图片媒体
|
||||
imageMedia: {
|
||||
className: 'image-media',
|
||||
height: 220,
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.3s ease'
|
||||
},
|
||||
|
||||
// 悬停遮罩
|
||||
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.5
|
||||
},
|
||||
|
||||
// 问题文本
|
||||
questionText: {
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.5,
|
||||
mb: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
|
||||
// 答案预览
|
||||
answerPreview: {
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.6,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
mb: 2
|
||||
},
|
||||
|
||||
// 元数据信息
|
||||
metaInfo: {
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
flexWrap: 'wrap',
|
||||
mt: 2,
|
||||
pt: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider'
|
||||
},
|
||||
|
||||
metaItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary'
|
||||
},
|
||||
|
||||
// 分页样式
|
||||
pagination: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mt: 4
|
||||
},
|
||||
|
||||
// 操作按钮容器
|
||||
actionButtonsContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 0.5,
|
||||
mt: 'auto'
|
||||
},
|
||||
|
||||
// 操作按钮样式
|
||||
actionButton: {
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
color: 'primary.main'
|
||||
}
|
||||
},
|
||||
|
||||
// 空状态
|
||||
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
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user