first-update
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { Button, CircularProgress } from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
|
||||
/**
|
||||
* AI 生成答案按钮组件
|
||||
* @param {string} projectId - 项目ID
|
||||
* @param {string} imageName - 图片名称
|
||||
* @param {string} question - 问题内容
|
||||
* @param {function} onSuccess - 生成成功的回调,接收生成的答案
|
||||
* @param {boolean} previewOnly - 是否只预览(不保存数据集),默认 true
|
||||
* @param {object} sx - 自定义样式
|
||||
*/
|
||||
export default function AIGenerateButton({
|
||||
projectId,
|
||||
imageName,
|
||||
question,
|
||||
onSuccess,
|
||||
previewOnly = true,
|
||||
sx = {},
|
||||
answerType
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const model = useAtomValue(selectedModelInfoAtom);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!projectId || !imageName || !question) {
|
||||
toast.error(t('images.missingParameters', { defaultValue: '缺少必要参数' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.type !== 'vision') {
|
||||
toast.error(t('images.visionModelRequired', { defaultValue: '请选择支持视觉的模型' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.post(`/api/projects/${projectId}/images/datasets`, {
|
||||
imageName,
|
||||
question,
|
||||
model,
|
||||
language: i18n.language,
|
||||
previewOnly
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.answer) {
|
||||
let data = response.data.answer;
|
||||
if (answerType === 'label') {
|
||||
try {
|
||||
data = JSON.parse(response.data.answer);
|
||||
} catch {}
|
||||
}
|
||||
onSuccess(data);
|
||||
toast.success(t('images.aiGenerateSuccess', { defaultValue: 'AI 生成成功' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI 生成失败:', error);
|
||||
const errorMsg = error.response?.data?.error || t('images.aiGenerateFailed', { defaultValue: 'AI 生成失败' });
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <AutoAwesomeIcon />}
|
||||
onClick={handleGenerate}
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
borderWidth: 2,
|
||||
'&:hover': {
|
||||
borderWidth: 2
|
||||
},
|
||||
...sx
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? t('common.generating', { defaultValue: '生成中...' })
|
||||
: t('images.aiGenerate', { defaultValue: 'AI 识别' })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Image from 'next/image';
|
||||
import QuestionSelector from './QuestionSelector';
|
||||
import AnswerInput from './AnswerInput';
|
||||
|
||||
export default function AnnotationDialog({
|
||||
open,
|
||||
onClose,
|
||||
image,
|
||||
templates,
|
||||
selectedTemplate,
|
||||
onTemplateChange,
|
||||
answer,
|
||||
onAnswerChange,
|
||||
onSave,
|
||||
onSaveAndContinue,
|
||||
saving,
|
||||
loading,
|
||||
onOpenCreateQuestion,
|
||||
onOpenCreateTemplate
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="xl"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
maxHeight: '90vh'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h5" fontWeight="600">
|
||||
{t('images.annotateImage', { defaultValue: '标注图片' })}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{image && (
|
||||
<Chip
|
||||
label={`${image.answeredQuestions?.length || 0} / ${(image.answeredQuestions?.length || 0) + (image.unansweredQuestions?.length || 0)} 已完成`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 3 }}>
|
||||
{/* 图片预览区域 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
mb: 4,
|
||||
minHeight: 450
|
||||
}}
|
||||
>
|
||||
{/* 图片预览 */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 450px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
{image && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 400,
|
||||
border: '2px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'grey.50'
|
||||
}}
|
||||
>
|
||||
{image.base64 ? (
|
||||
<Image src={image.base64} alt={image.imageName} fill style={{ objectFit: 'contain' }} priority />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{t('images.imageLoadError', { defaultValue: '图片加载失败' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 图片信息卡片 */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="600" gutterBottom>
|
||||
{image.imageName}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||||
{image.width && image.height && (
|
||||
<Chip label={`${image.width} × ${image.height}`} size="small" variant="outlined" />
|
||||
)}
|
||||
{image.size && (
|
||||
<Chip label={`${(image.size / 1024).toFixed(2)} KB`} size="small" variant="outlined" />
|
||||
)}
|
||||
{image.format && <Chip label={image.format?.toUpperCase()} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>{t('images.annotatedCount', { defaultValue: '已标注' })}:</strong> {image.datasetCount || 0}{' '}
|
||||
{t('images.questions', { defaultValue: '个问题' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 标注区域 */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 3,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
{/* 问题选择器 */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<QuestionSelector
|
||||
templates={templates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onTemplateChange={onTemplateChange}
|
||||
answeredQuestions={image?.answeredQuestions || []}
|
||||
unansweredQuestions={image?.unansweredQuestions || []}
|
||||
onOpenCreateQuestion={onOpenCreateQuestion}
|
||||
onOpenCreateTemplate={onOpenCreateTemplate}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 答案输入区域 */}
|
||||
{selectedTemplate && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<AnswerInput
|
||||
answerType={selectedTemplate.answerType}
|
||||
answer={answer}
|
||||
onAnswerChange={onAnswerChange}
|
||||
labels={selectedTemplate.labels}
|
||||
customFormat={selectedTemplate.customFormat}
|
||||
projectId={image?.projectId}
|
||||
imageName={image?.imageName}
|
||||
question={selectedTemplate}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions
|
||||
sx={{
|
||||
p: 3,
|
||||
pt: 0,
|
||||
gap: 1,
|
||||
justifyContent: 'space-between',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
{/* 左侧:创建按钮 */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
onClick={onOpenCreateQuestion}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderRadius: 2, textTransform: 'none' }}
|
||||
>
|
||||
{t('images.createQuestion', { defaultValue: '创建问题' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onOpenCreateTemplate}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderRadius: 2, textTransform: 'none' }}
|
||||
>
|
||||
{t('images.createTemplate', { defaultValue: '创建问题模板' })}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button onClick={onClose} disabled={saving} variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSaveAndContinue}
|
||||
disabled={saving || !selectedTemplate}
|
||||
variant="outlined"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{saving ? <CircularProgress size={20} /> : t('images.saveAndContinue', { defaultValue: '保存并继续' })}
|
||||
</Button>
|
||||
<Button onClick={onSave} disabled={saving || !selectedTemplate} variant="contained" sx={{ borderRadius: 2 }}>
|
||||
{saving ? <CircularProgress size={20} /> : t('common.save')}
|
||||
</Button>
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, TextField, Chip, Button, Paper } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import AIGenerateButton from './AIGenerateButton';
|
||||
|
||||
export default function AnswerInput({
|
||||
answerType,
|
||||
answer,
|
||||
onAnswerChange,
|
||||
labels,
|
||||
customFormat,
|
||||
projectId,
|
||||
imageName,
|
||||
question
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
|
||||
// 文字类型输入
|
||||
if (answerType === 'text') {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight="600" sx={{ color: 'text.primary' }}>
|
||||
{t('images.answer', { defaultValue: '文本答案' })} *
|
||||
</Typography>
|
||||
<AIGenerateButton
|
||||
projectId={projectId}
|
||||
imageName={imageName}
|
||||
question={question}
|
||||
onSuccess={onAnswerChange}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
value={answer}
|
||||
onChange={e => onAnswerChange(e.target.value)}
|
||||
placeholder={t('images.answerPlaceholder', { defaultValue: '请输入答案...' })}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'background.paper',
|
||||
'& fieldset': {
|
||||
borderWidth: 2,
|
||||
borderColor: 'divider'
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main'
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
'& textarea': {
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 标签类型输入 - 提前解析 labels,避免条件中的 hooks 问题
|
||||
if (answerType === 'label') {
|
||||
const selectedLabels = Array.isArray(answer) ? answer : [];
|
||||
|
||||
// 解析 labels(可能是 JSON 字符串或数组)
|
||||
let labelOptions = [];
|
||||
if (typeof labels === 'string' && labels) {
|
||||
try {
|
||||
labelOptions = JSON.parse(labels);
|
||||
} catch (e) {
|
||||
labelOptions = [];
|
||||
}
|
||||
} else if (Array.isArray(labels)) {
|
||||
labelOptions = labels;
|
||||
}
|
||||
|
||||
if (!labelOptions.includes('其他') && !labelOptions.includes('other')) {
|
||||
labelOptions.push(i18n.language === 'en' ? 'other' : '其他');
|
||||
}
|
||||
|
||||
const handleToggleLabel = label => {
|
||||
if (selectedLabels.includes(label)) {
|
||||
onAnswerChange(selectedLabels.filter(l => l !== label));
|
||||
} else {
|
||||
let newLabels = [...selectedLabels, label];
|
||||
onAnswerChange(newLabels);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewLabel = () => {
|
||||
if (newLabel.trim() && !labelOptions.includes(newLabel.trim())) {
|
||||
handleToggleLabel(newLabel.trim());
|
||||
setNewLabel('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight="600" sx={{ color: 'text.primary' }}>
|
||||
{t('images.selectLabels', { defaultValue: '标签选择' })} *
|
||||
</Typography>
|
||||
<AIGenerateButton
|
||||
projectId={projectId}
|
||||
imageName={imageName}
|
||||
question={question}
|
||||
onSuccess={onAnswerChange}
|
||||
answerType={answerType}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 可选标签 */}
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'grey.50',
|
||||
border: '2px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.light'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{t('images.availableLabels', { defaultValue: '可选标签' })}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
|
||||
{labelOptions && labelOptions.length > 0 ? (
|
||||
labelOptions.map(label => (
|
||||
<Chip
|
||||
key={label}
|
||||
label={label}
|
||||
onClick={() => handleToggleLabel(label)}
|
||||
color={selectedLabels.includes(label) ? 'primary' : 'default'}
|
||||
variant={selectedLabels.includes(label) ? 'filled' : 'outlined'}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: 2
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
{t('images.noLabelsAvailable', { defaultValue: '暂无可选标签' })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* 添加新标签 */}
|
||||
{/* <Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={newLabel}
|
||||
onChange={e => setNewLabel(e.target.value)}
|
||||
placeholder={t('images.addNewLabel', { defaultValue: '添加新标签...' })}
|
||||
onKeyPress={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddNewLabel();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
'& fieldset': {
|
||||
borderWidth: 2
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAddNewLabel}
|
||||
disabled={!newLabel.trim()}
|
||||
variant="contained"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
px: 3,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
boxShadow: 2,
|
||||
'&:hover': {
|
||||
boxShadow: 4
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.add', { defaultValue: '添加' })}
|
||||
</Button>
|
||||
</Box> */}
|
||||
|
||||
{/* 已选择标签 */}
|
||||
{/* {selectedLabels.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{t('images.selectedLabels', { defaultValue: '已选择' })} ({selectedLabels.length})
|
||||
</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'primary.50',
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.200'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
|
||||
{selectedLabels.map(label => (
|
||||
<Chip
|
||||
key={label}
|
||||
label={label}
|
||||
onDelete={() => handleToggleLabel(label)}
|
||||
color="primary"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
height: 36,
|
||||
'& .MuiChip-deleteIcon': {
|
||||
fontSize: '18px',
|
||||
'&:hover': {
|
||||
color: 'error.main'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)} */}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 自定义格式输入
|
||||
if (answerType === 'custom_format') {
|
||||
const handleJsonChange = value => {
|
||||
onAnswerChange(value);
|
||||
// 验证 JSON 格式
|
||||
if (value.trim()) {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setJsonError('');
|
||||
} catch (e) {
|
||||
setJsonError(t('images.invalidJsonFormat', { defaultValue: 'JSON 格式不正确' }));
|
||||
}
|
||||
} else {
|
||||
setJsonError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseTemplate = () => {
|
||||
if (customFormat) {
|
||||
try {
|
||||
let templateJson;
|
||||
if (typeof customFormat === 'string') {
|
||||
templateJson = JSON.parse(customFormat);
|
||||
} else {
|
||||
templateJson = customFormat;
|
||||
}
|
||||
const formatted = JSON.stringify(templateJson, null, 2);
|
||||
onAnswerChange(formatted);
|
||||
setJsonError('');
|
||||
} catch (e) {
|
||||
onAnswerChange('{}');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (answer && typeof answer === 'object') {
|
||||
answer = JSON.stringify(answer, null, 2);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight="600" sx={{ color: 'text.primary' }}>
|
||||
{t('images.customFormatAnswer', { defaultValue: '自定义格式答案' })} *
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<AIGenerateButton
|
||||
projectId={projectId}
|
||||
imageName={imageName}
|
||||
question={question}
|
||||
onSuccess={onAnswerChange}
|
||||
/>
|
||||
{customFormat && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleUseTemplate}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
borderWidth: 2,
|
||||
'&:hover': {
|
||||
borderWidth: 2
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('images.useTemplate', { defaultValue: '使用模板' })}
|
||||
</Button>
|
||||
)}
|
||||
{/* <Button
|
||||
size="small"
|
||||
onClick={handleFormatJson}
|
||||
variant="outlined"
|
||||
disabled={!answer.trim()}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
borderWidth: 2,
|
||||
'&:hover': {
|
||||
borderWidth: 2
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('images.formatJson', { defaultValue: '格式化' })}
|
||||
</Button> */}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 显示格式要求 */}
|
||||
{customFormat && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 3,
|
||||
border: '2px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{t('images.formatRequirement', { defaultValue: '格式要求' })}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '150px',
|
||||
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
|
||||
lineHeight: 1.5,
|
||||
color: '#2d3748'
|
||||
}}
|
||||
>
|
||||
{typeof customFormat === 'string' ? customFormat : JSON.stringify(customFormat, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* JSON 输入框 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={10}
|
||||
value={answer}
|
||||
onChange={e => handleJsonChange(e.target.value)}
|
||||
placeholder={t('images.customFormatPlaceholder', { defaultValue: '请输入符合格式的 JSON...' })}
|
||||
error={!!jsonError}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'background.paper',
|
||||
'& fieldset': {
|
||||
borderWidth: 2
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main'
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
borderWidth: 2
|
||||
},
|
||||
'&.Mui-error fieldset': {
|
||||
borderColor: 'error.main',
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
'& textarea': {
|
||||
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.5
|
||||
},
|
||||
'& .MuiFormHelperText-root': {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { Autocomplete, TextField, Box, Typography, Chip, Button, Dialog } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function QuestionSelector({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
onTemplateChange,
|
||||
answeredQuestions = [],
|
||||
unansweredQuestions = [],
|
||||
onOpenCreateQuestion,
|
||||
onOpenCreateTemplate
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [showNoQuestionsMessage, setShowNoQuestionsMessage] = useState(false);
|
||||
|
||||
// 构建未完成标注的问题选项(用于下拉框)
|
||||
const dropdownOptions = unansweredQuestions.map(q => ({
|
||||
...q,
|
||||
isUnanswered: true
|
||||
}));
|
||||
|
||||
const getAnswerTypeLabel = answerType => {
|
||||
switch (answerType) {
|
||||
case 'text':
|
||||
return t('images.answerTypeText', { defaultValue: '文字' });
|
||||
case 'label':
|
||||
return t('images.answerTypeLabel', { defaultValue: '标签' });
|
||||
case 'custom_format':
|
||||
return t('images.answerTypeCustomFormat', { defaultValue: '自定义格式' });
|
||||
default:
|
||||
return answerType;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否有待标注问题
|
||||
const hasUnansweredQuestions = unansweredQuestions.length > 0;
|
||||
const hasAnsweredQuestions = answeredQuestions.length > 0;
|
||||
const hasAnyQuestions = hasUnansweredQuestions || hasAnsweredQuestions;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 已标注问题区域 - 优化显示为一行,添加最大高度 */}
|
||||
{answeredQuestions.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" fontWeight="600" gutterBottom sx={{ mb: 1.5, color: 'text.secondary' }}>
|
||||
{t('images.answeredQuestions', { defaultValue: '已标注问题' })} ({answeredQuestions.length})
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
maxHeight: 120,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 1,
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px'
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
bgcolor: 'transparent'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
bgcolor: 'action.disabled',
|
||||
borderRadius: 1,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.active'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{answeredQuestions.map(question => (
|
||||
<Chip
|
||||
key={question.id}
|
||||
label={question.question}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
maxWidth: '100%',
|
||||
'& .MuiChip-label': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 问题选择下拉框 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom sx={{ mb: 2 }}>
|
||||
{t('images.selectNewQuestion', { defaultValue: '选择新问题' })}
|
||||
</Typography>
|
||||
|
||||
{!hasUnansweredQuestions ? (
|
||||
// 没有待标注问题的提示
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
{hasAnsweredQuestions ? (
|
||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('images.allQuestionsAnnotated', { defaultValue: '当前图片所有问题已标注完成' })}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('images.noQuestionsAssociated', { defaultValue: '当前图片未关联任何问题' })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
// 有待标注问题时显示下拉框
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
options={dropdownOptions}
|
||||
value={selectedTemplate}
|
||||
onChange={(event, newValue) => {
|
||||
if (newValue) {
|
||||
onTemplateChange(newValue);
|
||||
}
|
||||
}}
|
||||
getOptionLabel={option => option.question || ''}
|
||||
renderOption={(props, option) => (
|
||||
<Box
|
||||
component="li"
|
||||
{...props}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
borderRadius: 1,
|
||||
mx: 1,
|
||||
my: 0.5,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight="500">
|
||||
{option.question}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Chip label={getAnswerTypeLabel(option.answerType)} size="small" sx={{ borderRadius: 1 }} />
|
||||
<Chip
|
||||
label={t('images.pendingAnswer', { defaultValue: '待标注' })}
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="filled"
|
||||
sx={{ borderRadius: 1, fontSize: '0.75rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={t('images.selectQuestionPlaceholder', { defaultValue: '请选择问题进行标注...' })}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
'& fieldset': {
|
||||
borderWidth: 2
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedTemplate && selectedTemplate.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{selectedTemplate.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user