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