first-update

This commit is contained in:
2026-03-17 14:36:31 +08:00
parent 72f08aee7c
commit 4eddf05e79
516 changed files with 115270 additions and 1 deletions

View File

@@ -0,0 +1,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>
);
}

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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;

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

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

View File

@@ -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
}
};