first-update
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user